From b4334d68e764095f0d5cda69e977b83ba6df6350 Mon Sep 17 00:00:00 2001 From: Stefan Moro Date: Tue, 28 May 2024 06:13:38 +0200 Subject: [PATCH] Flatten history Flattens the commit history in preparation of making this repo public. The code in this repo has been written by: @stoyicker, @kgrevehagen, @mariusgreve, @michpohl, @paulinaaniola, @sula-sa and @ziad-halabi9. Co-authored-by: Jorge Antonio Diaz-Benito Soriano Co-authored-by: Kristian Greve Hagen Co-authored-by: Marius Greve Hagen Co-authored-by: Michael Pohl Co-authored-by: Paulina Aniola Co-authored-by: Sultan Seidalin Co-authored-by: Ziad Al Halabi on-behalf-of: @tidal-music --- .editorconfig | 27 + .fossa.yml | 6 + .github/CODEOWNERS | 1 + .github/scripts/CheckChangelogs.main.kts | 27 + .github/workflows/check-changelog-files.yml | 56 + .github/workflows/create-release.yml | 59 + .github/workflows/fossa-scan.yml | 33 + .github/workflows/instrumented-test.yml | 31 + .github/workflows/lint.yml | 51 + .github/workflows/post-merge.yml | 73 + .github/workflows/publish-pages.yml | 55 + .github/workflows/publish-release.yml | 52 + .github/workflows/pull-request.yml | 36 + .github/workflows/unit-test.yml | 22 + .gitignore | 15 + .scripts/ci/checkout_module_version.sh | 41 + ...act_module_version_from_bom_module_json.sh | 39 + LICENSE | 201 ++ README.md | 38 + auth/CHANGELOG.md | 30 + auth/README.md | 98 + auth/apps/demo/build.gradle.kts | 37 + auth/apps/demo/src/main/AndroidManifest.xml | 23 + auth/apps/demo/src/main/assets/logback.xml | 18 + .../main/kotlin/com/tidal/sdk/demo/DemoApp.kt | 5 + .../com/tidal/sdk/demo/DeviceLoginScreen.kt | 92 + .../kotlin/com/tidal/sdk/demo/LoginScreen.kt | 35 + .../kotlin/com/tidal/sdk/demo/MainActivity.kt | 142 + .../com/tidal/sdk/demo/NavigationHost.kt | 31 + .../kotlin/com/tidal/sdk/demo/StartScreen.kt | 76 + .../tidal/sdk/demo/webview/ComposeWebView.kt | 35 + .../sdk/demo/webview/ExtendedChromeClient.kt | 21 + .../sdk/demo/webview/ExtendedWebClient.kt | 62 + .../sdk/demo/webview/JavaScriptInterface.kt | 32 + .../drawable-v24/ic_launcher_foreground.xml | 31 + .../res/drawable/ic_launcher_background.xml | 74 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes auth/apps/demo/src/main/res/values/colors.xml | 10 + .../apps/demo/src/main/res/values/strings.xml | 3 + .../demo/src/main/res/xml/backup_rules.xml | 14 + .../main/res/xml/data_extraction_rules.xml | 20 + auth/build.gradle.kts | 45 + auth/consumer-rules.pro | 0 auth/gradle.properties | 2 + .../com/tidal/auth/ExampleInstrumentedTest.kt | 20 + auth/src/main/AndroidManifest.xml | 2 + .../main/kotlin/com/tidal/sdk/auth/Auth.kt | 99 + .../com/tidal/sdk/auth/CredentialsProvider.kt | 38 + .../sdk/auth/DefaultCredentialsProvider.kt | 26 + .../kotlin/com/tidal/sdk/auth/TidalAuth.kt | 59 + .../com/tidal/sdk/auth/TokenRepository.kt | 233 ++ .../com/tidal/sdk/auth/di/AuthComponent.kt | 32 + .../com/tidal/sdk/auth/di/AuthModule.kt | 40 + .../tidal/sdk/auth/di/CredentialsModule.kt | 51 + .../com/tidal/sdk/auth/di/LoginModule.kt | 73 + .../com/tidal/sdk/auth/di/NetworkModule.kt | 82 + .../com/tidal/sdk/auth/di/StorageModule.kt | 47 + .../sdk/auth/login/CodeChallengeBuilder.kt | 33 + .../sdk/auth/login/DeviceLoginPollHelper.kt | 139 + .../tidal/sdk/auth/login/LoginRepository.kt | 157 + .../tidal/sdk/auth/login/LoginUriBuilder.kt | 88 + .../com/tidal/sdk/auth/login/RedirectUri.kt | 46 + .../tidal/sdk/auth/model/ApiErrorSubStatus.kt | 15 + .../com/tidal/sdk/auth/model/AuthConfig.kt | 35 + .../com/tidal/sdk/auth/model/AuthResult.kt | 40 + .../com/tidal/sdk/auth/model/Credentials.kt | 119 + .../auth/model/CredentialsUpdatedMessage.kt | 11 + .../auth/model/DeviceAuthorizationResponse.kt | 13 + .../com/tidal/sdk/auth/model/ErrorResponse.kt | 12 + .../kotlin/com/tidal/sdk/auth/model/Errors.kt | 29 + .../com/tidal/sdk/auth/model/LoginConfig.kt | 18 + .../com/tidal/sdk/auth/model/LoginResponse.kt | 21 + .../tidal/sdk/auth/model/QueryParameter.kt | 11 + .../tidal/sdk/auth/model/RefreshResponse.kt | 19 + .../kotlin/com/tidal/sdk/auth/model/Tokens.kt | 9 + .../tidal/sdk/auth/model/UpgradeResponse.kt | 18 + .../tidal/sdk/auth/network/LoginService.kt | 40 + .../tidal/sdk/auth/network/NetworkLogLevel.kt | 11 + .../sdk/auth/storage/DefaultTokensStore.kt | 71 + .../sdk/auth/storage/LegacyCredentials.kt | 32 + .../tidal/sdk/auth/storage/LegacyTokens.kt | 17 + .../com/tidal/sdk/auth/storage/Scopes.kt | 32 + .../com/tidal/sdk/auth/storage/TokensStore.kt | 10 + .../com/tidal/sdk/auth/token/TokenService.kt | 40 + .../tidal/sdk/auth/util/AuthErrorPolicy.kt | 58 + .../com/tidal/sdk/auth/util/AuthHttp.kt | 10 + .../sdk/auth/util/DefaultTimeProvider.kt | 9 + .../com/tidal/sdk/auth/util/Extensions.kt | 10 + .../tidal/sdk/auth/util/InternalExtensions.kt | 60 + .../com/tidal/sdk/auth/util/RetryPolicy.kt | 71 + .../com/tidal/sdk/auth/util/TimeProvider.kt | 7 + .../kotlin/com/tidal/sdk/auth/util/Utils.kt | 72 + .../com/tidal/sdk/auth/FakeLoginService.kt | 152 + .../com/tidal/sdk/auth/FakeTokenService.kt | 82 + .../com/tidal/sdk/auth/LoginRepositoryTest.kt | 835 ++++++ .../com/tidal/sdk/auth/TokenRepositoryTest.kt | 758 +++++ .../tidal/sdk/auth/login/FakeTokensStore.kt | 46 + .../tidal/sdk/auth/login/RedirectUriTest.kt | 56 + .../tidal/sdk/auth/model/CredentialsTest.kt | 148 + .../auth/storage/DefaultTokensStoreTest.kt | 89 + .../sdk/auth/util/RetryWithPolicyTest.kt | 87 + .../com/tidal/sdk/auth/util/TestUtils.kt | 26 + .../sdk/util/CoroutineTestTimeProvider.kt | 72 + .../com/tidal/sdk/util/TestTimeProvider.kt | 22 + .../kotlin/com/tidal/sdk/util/TestUtils.kt | 35 + bom/build.gradle.kts | 27 + bom/gradle.properties | 2 + build.gradle.kts | 38 + buildlogic/build.gradle.kts | 39 + buildlogic/gradle/libs.versions.toml | 1 + buildlogic/settings.gradle.kts | 7 + .../main/kotlin/com/tidal/sdk/SdkModules.kt | 6 + .../com/tidal/sdk/plugins/ConfiguresDokka.kt | 32 + .../plugins/ConfiguresGradleProjectVersion.kt | 20 + .../com/tidal/sdk/plugins/ConfiguresJUnit5.kt | 14 + .../sdk/plugins/ConfiguresKotlinCompiler.kt | 18 + .../sdk/plugins/ConfiguresMavenPublish.kt | 107 + .../plugins/JvmPlatformConventionPlugin.kt | 13 + ...otlinAndroidApplicationConventionPlugin.kt | 92 + .../plugins/KotlinAndroidConventionPlugin.kt | 14 + .../KotlinAndroidLibraryConventionPlugin.kt | 55 + .../KotlinJvmLibraryConventionPlugin.kt | 16 + .../com/tidal/sdk/plugins/constant/Config.kt | 8 + .../tidal/sdk/plugins/constant/PluginId.kt | 11 + .../plugins/extensions/GradleAPIExtensions.kt | 30 + common/CHANGELOG.md | 23 + common/README.md | 8 + common/build.gradle.kts | 13 + common/consumer-rules.pro | 0 common/gradle.properties | 2 + common/src/main/AndroidManifest.xml | 2 + .../sdk/common/IllegalConfigurationError.kt | 13 + .../kotlin/com/tidal/sdk/common/Logging.kt | 63 + .../com/tidal/sdk/common/NetworkError.kt | 3 + .../com/tidal/sdk/common/RetryableError.kt | 7 + .../kotlin/com/tidal/sdk/common/TidalError.kt | 6 + .../com/tidal/sdk/common/TidalMessage.kt | 3 + .../com/tidal/sdk/common/UnexpectedError.kt | 13 + eventproducer/CHANGELOG.md | 25 + eventproducer/README.md | 79 + eventproducer/apps/demo/build.gradle.kts | 33 + .../apps/demo/src/main/AndroidManifest.xml | 21 + .../main/kotlin/com/tidal/sdk/demo/DemoApp.kt | 5 + .../tidal/sdk/demo/EventSenderDemoScreen.kt | 43 + .../kotlin/com/tidal/sdk/demo/MainActivity.kt | 83 + .../drawable-v24/ic_launcher_foreground.xml | 31 + .../res/drawable/ic_launcher_background.xml | 74 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../apps/demo/src/main/res/values/colors.xml | 10 + .../apps/demo/src/main/res/values/strings.xml | 3 + .../demo/src/main/res/xml/backup_rules.xml | 14 + .../main/res/xml/data_extraction_rules.xml | 20 + eventproducer/build.gradle.kts | 44 + eventproducer/consumer-rules.pro | 11 + eventproducer/gradle.properties | 2 + eventproducer/src/main/AndroidManifest.xml | 4 + .../sdk/eventproducer/DefaultEventSender.kt | 52 + .../tidal/sdk/eventproducer/EventProducer.kt | 103 + .../tidal/sdk/eventproducer/EventSender.kt | 15 + .../com/tidal/sdk/eventproducer/Submitter.kt | 63 + .../sdk/eventproducer/auth/AuthHeadersUtil.kt | 25 + .../auth/DefaultAuthenticator.kt | 49 + .../eventproducer/database/EventsDatabase.kt | 35 + .../sdk/eventproducer/di/ConvertersModule.kt | 26 + .../sdk/eventproducer/di/DatabaseModule.kt | 42 + .../eventproducer/di/EventProducerModule.kt | 13 + .../sdk/eventproducer/di/EventsComponent.kt | 38 + .../sdk/eventproducer/di/NetworkModule.kt | 26 + .../sdk/eventproducer/di/OkHttpModule.kt | 46 + .../tidal/sdk/eventproducer/di/UtilsModule.kt | 17 + .../sdk/eventproducer/events/EventDao.kt | 32 + .../sdk/eventproducer/events/EventEntity.kt | 17 + .../events/EventsLocalDataSource.kt | 9 + .../eventproducer/model/ConsentCategory.kt | 26 + .../tidal/sdk/eventproducer/model/Event.kt | 11 + .../sdk/eventproducer/model/EventsConfig.kt | 14 + .../model/EventsConfigProvider.kt | 12 + .../eventproducer/model/MonitoringEvent.kt | 3 + .../model/MonitoringEventType.kt | 7 + .../tidal/sdk/eventproducer/model/Result.kt | 39 + .../eventproducer/monitoring/MonitoringDao.kt | 24 + .../monitoring/MonitoringEntity.kt | 16 + .../monitoring/MonitoringInfo.kt | 32 + .../monitoring/MonitoringLocalDataSource.kt | 8 + .../network/HeadersInterceptor.kt | 26 + .../service/SendMessageBatchResponse.kt | 24 + .../network/service/SqsService.kt | 21 + .../eventproducer/outage/OutageEndMessage.kt | 5 + .../eventproducer/outage/OutageStartError.kt | 6 + .../sdk/eventproducer/outage/OutageState.kt | 11 + .../repository/EventsRepository.kt | 126 + .../repository/RepositoryHelper.kt | 51 + .../scheduler/MonitoringScheduler.kt | 27 + .../scheduler/SendEventBatchScheduler.kt | 37 + .../utils/CoroutineScopeCanceledException.kt | 4 + .../utils/DatabaseSizeChecker.kt | 18 + .../eventproducer/utils/EventSizeValidator.kt | 21 + .../sdk/eventproducer/utils/HeadersUtils.kt | 54 + .../sdk/eventproducer/utils/MapConverter.kt | 45 + .../utils/SqsRequestParametersConverter.kt | 45 + .../tidal/sdk/eventproducer/utils/Utils.kt | 14 + .../auth/DefaultAuthenticatorTest.kt | 100 + .../fakes/FakeCredentialsProvider.kt | 47 + .../repository/EventsRepositoryTest.kt | 332 +++ .../eventproducer/utils/HeadersUtilsTest.kt | 41 + .../utils/SqsParametersConverterTest.kt | 78 + generate-module.sh | 69 + gradle.properties | 6 + gradle/libs.versions.toml | 135 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 61574 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 244 ++ gradlew.bat | 92 + local-setup.sh | 11 + player/CHANGELOG.md | 40 + player/README.md | 76 + player/apps/demo/build.gradle.kts | 39 + player/apps/demo/src/main/AndroidManifest.xml | 25 + .../com/tidal/sdk/player/MyApplication.kt | 31 + .../player/auth/weblogin/ComposeWebView.kt | 52 + .../auth/weblogin/ExtendedChromeClient.kt | 21 + .../player/auth/weblogin/ExtendedWebClient.kt | 69 + .../auth/weblogin/JavaScriptInterface.kt | 32 + .../player/mainactivity/DemoPlayableItem.kt | 64 + .../DemoPlayableItemComposable.kt | 88 + .../sdk/player/mainactivity/DeriveUiState.kt | 41 + .../sdk/player/mainactivity/LoadingScreen.kt | 20 + .../sdk/player/mainactivity/LoginScreen.kt | 67 + .../sdk/player/mainactivity/MainActivity.kt | 154 + .../player/mainactivity/MainActivityScreen.kt | 105 + .../player/mainactivity/MainActivityState.kt | 38 + .../mainactivity/MainActivityViewModel.kt | 554 ++++ .../MainActivityViewModelState.kt | 65 + .../mainactivity/PlayerInitializedScreen.kt | 343 +++ .../PlayerNotInitializedScreen.kt | 73 + .../tidal/sdk/player/mainactivity/Selector.kt | 106 + .../com/tidal/sdk/player/ui/theme/Theme.kt | 48 + .../res/drawable/ic_launcher_foreground.xml | 33 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1405 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 2871 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 951 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 1726 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 2032 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4092 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 3249 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 6477 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 4372 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 9195 bytes .../res/values/ic_launcher_background.xml | 4 + .../apps/demo/src/main/res/values/themes.xml | 5 + .../main/res/xml/network_security_config.xml | 8 + player/build.gradle.kts | 42 + player/common-android/README.md | 1 + player/common-android/build.gradle.kts | 20 + .../player/commonandroid/Base64CodecTest.kt | 28 + .../commonandroid/jwt/Base64JwtDecoderTest.kt | 53 + .../src/main/AndroidManifest.xml | 2 + .../sdk/player/commonandroid/Base64Codec.kt | 15 + .../commonandroid/SystemClockWrapper.kt | 9 + .../player/commonandroid/TrueTimeWrapper.kt | 13 + .../commonandroid/jwt/Base64JwtDecoder.kt | 25 + .../player/commonandroid/ExampleUnitTest.kt | 15 + player/common/README.md | 1 + player/common/build.gradle.kts | 13 + .../com/tidal/sdk/player/common/Common.kt | 8 + .../tidal/sdk/player/common/Configuration.kt | 27 + .../player/common/ConfigurationListener.kt | 6 + .../player/common/ForwardingMediaProduct.kt | 5 + .../player/common/RequestBuilderFactory.kt | 8 + .../tidal/sdk/player/common/UUIDWrapper.kt | 9 + .../tidal/sdk/player/common/model/ApiError.kt | 119 + .../player/common/model/AssetPresentation.kt | 17 + .../sdk/player/common/model/AudioMode.kt | 18 + .../sdk/player/common/model/AudioQuality.kt | 23 + .../player/common/model/BaseMediaProduct.kt | 9 + .../common/model/LoudnessNormalizationMode.kt | 7 + .../sdk/player/common/model/MediaProduct.kt | 21 + .../sdk/player/common/model/MediaStorage.kt | 20 + .../sdk/player/common/model/ProductQuality.kt | 6 + .../sdk/player/common/model/ProductType.kt | 29 + .../sdk/player/common/model/StreamType.kt | 17 + .../sdk/player/common/model/VideoQuality.kt | 23 + player/consumer-rules.pro | 0 player/events/README.md | 1 + player/events/build.gradle.kts | 39 + .../player/events/ExampleInstrumentedTest.kt | 20 + player/events/src/main/AndroidManifest.xml | 6 + .../tidal/sdk/player/events/ClientSupplier.kt | 36 + .../sdk/player/events/DefaultEventReporter.kt | 43 + .../tidal/sdk/player/events/EventReporter.kt | 11 + .../player/events/EventReporterModuleRoot.kt | 45 + .../tidal/sdk/player/events/UserSupplier.kt | 32 + .../AudioDownloadStatisticsEventFactory.kt | 26 + .../AudioPlaybackSessionEventFactory.kt | 25 + .../AudioPlaybackStatisticsEventFactory.kt | 26 + .../BroadcastPlaybackSessionEventFactory.kt | 26 + ...BroadcastPlaybackStatisticsEventFactory.kt | 25 + .../converter/DrmLicenseFetchEventFactory.kt | 25 + .../player/events/converter/EventFactory.kt | 7 + ...otStartedPlaybackStatisticsEventFactory.kt | 26 + .../PlaybackInfoFetchEventFactory.kt | 25 + .../StreamingSessionEndEventFactory.kt | 25 + .../StreamingSessionStartEventFactory.kt | 27 + .../StreamingSessionStartPayloadDecorator.kt | 36 + .../UCPlaybackSessionEventFactory.kt | 26 + .../UCPlaybackStatisticsEventFactory.kt | 26 + .../VideoDownloadStatisticsEventFactory.kt | 26 + .../VideoPlaybackSessionEventFactory.kt | 25 + .../VideoPlaybackStatisticsEventFactory.kt | 26 + .../di/DefaultEventReporterComponent.kt | 45 + .../events/di/DefaultEventReporterModule.kt | 26 + .../player/events/di/EventFactoryModule.kt | 367 +++ .../events/model/AudioDownloadStatistics.kt | 50 + .../events/model/AudioPlaybackSession.kt | 57 + .../events/model/AudioPlaybackStatistics.kt | 54 + .../events/model/BroadcastPlaybackSession.kt | 52 + .../model/BroadcastPlaybackStatistics.kt | 54 + .../tidal/sdk/player/events/model/Client.kt | 51 + .../player/events/model/DownloadStatistics.kt | 26 + .../player/events/model/DrmLicenseFetch.kt | 40 + .../sdk/player/events/model/EndReason.kt | 11 + .../tidal/sdk/player/events/model/Event.kt | 42 + .../model/NotStartedPlaybackStatistics.kt | 50 + .../tidal/sdk/player/events/model/PlayLog.kt | 7 + .../player/events/model/PlaybackInfoFetch.kt | 40 + .../player/events/model/PlaybackSession.kt | 51 + .../player/events/model/PlaybackStatistics.kt | 79 + .../player/events/model/StreamingMetrics.kt | 15 + .../events/model/StreamingSessionEnd.kt | 34 + .../events/model/StreamingSessionStart.kt | 118 + .../player/events/model/UCPlaybackSession.kt | 57 + .../events/model/UCPlaybackStatistics.kt | 59 + .../com/tidal/sdk/player/events/model/User.kt | 14 + .../events/model/VideoDownloadStatistics.kt | 50 + .../events/model/VideoPlaybackSession.kt | 56 + .../events/model/VideoPlaybackStatistics.kt | 54 + .../events/util/ActiveMobileNetworkType.kt | 10 + .../player/events/util/ActiveNetworkType.kt | 25 + .../player/events/util/HardwarePlatform.kt | 13 + .../src/main/res/values-sw600dp/bool.xml | 4 + player/events/src/main/res/values/bool.xml | 4 + .../sdk/player/events/ClientSupplierTest.kt | 235 ++ .../player/events/DefaultEventReporterTest.kt | 63 + .../events/EventReporterModuleRootTest.kt | 120 + .../sdk/player/events/UserSupplierTest.kt | 89 + ...AudioDownloadStatisticsEventFactoryTest.kt | 77 + .../AudioPlaybackSessionEventFactoryTest.kt | 83 + ...AudioPlaybackStatisticsEventFactoryTest.kt | 83 + ...roadcastPlaybackSessionEventFactoryTest.kt | 83 + ...dcastPlaybackStatisticsEventFactoryTest.kt | 85 + .../DrmLicenseFetchEventFactoryTest.kt | 77 + ...artedPlaybackStatisticsEventFactoryTest.kt | 83 + .../PlaybackInfoFetchEventFactoryTest.kt | 77 + .../StreamingSessionEndEventFactoryTest.kt | 77 + .../StreamingSessionStartEventFactoryTest.kt | 86 + ...reamingSessionStartPayloadDecoratorTest.kt | 131 + .../UCPlaybackSessionEventFactoryTest.kt | 83 + .../UCPlaybackStatisticsEventFactoryTest.kt | 85 + ...VideoDownloadStatisticsEventFactoryTest.kt | 77 + .../VideoPlaybackSessionEventFactoryTest.kt | 83 + ...VideoPlaybackStatisticsEventFactoryTest.kt | 83 + .../AudioDownloadStatisticsMarshallingTest.kt | 33 + ...ownloadStatisticsPayloadMarshallingTest.kt | 39 + .../AudioPlaybackSessionMarshallingTest.kt | 42 + ...ioPlaybackSessionPayloadMarshallingTest.kt | 57 + .../AudioPlaybackStatisticsMarshallingTest.kt | 47 + ...laybackStatisticsPayloadMarshallingTest.kt | 169 ++ ...BroadcastPlaybackSessionMarshallingTest.kt | 38 + ...stPlaybackSessionPayloadMarshallingTest.kt | 54 + ...adcastPlaybackStatisticsMarshallingTest.kt | 43 + ...laybackStatisticsPayloadMarshallingTest.kt | 152 + .../player/events/model/ClientExtensions.kt | 12 + .../events/model/ClientMarshallingTest.kt | 46 + .../DownloadStatisticsMarshallingTest.kt | 43 + ...ownloadStatisticsPayloadMarshallingTest.kt | 195 ++ .../model/DrmLicenseFetchMarshallingTest.kt | 51 + .../DrmLicenseFetchPayloadMarshallingTest.kt | 76 + ...tartedPlaybackStatisticsMarshallingTest.kt | 55 + ...laybackStatisticsPayloadMarshallingTest.kt | 90 + .../model/PlaybackInfoFetchMarshallingTest.kt | 51 + ...PlaybackInfoFetchPayloadMarshallingTest.kt | 76 + .../model/PlaybackSessionMarshallingTest.kt | 45 + ...backSessionPayloadActionMarshallingTest.kt | 39 + .../PlaybackSessionPayloadMarshallingTest.kt | 137 + ...backStatisticsAdaptationMarshallingTest.kt | 66 + .../PlaybackStatisticsMarshallingTest.kt | 37 + ...laybackStatisticsPayloadMarshallingTest.kt | 123 + .../PlaybackStatisticsStallMarshallingTest.kt | 52 + .../StreamingSessionEndMarshallingTest.kt | 44 + ...reamingSessionEndPayloadMarshallingTest.kt | 31 + ...ionStartDecoratedPayloadMarshallingTest.kt | 106 + .../StreamingSessionStartMarshallingTest.kt | 62 + .../model/UCPlaybackSessionMarshallingTest.kt | 38 + ...UCPlaybackSessionPayloadMarshallingTest.kt | 55 + .../UCPlaybackStatisticsMarshallingTest.kt | 44 + ...laybackStatisticsPayloadMarshallingTest.kt | 157 + .../sdk/player/events/model/UserExtensions.kt | 9 + .../events/model/UserMarshallingTest.kt | 38 + .../VideoDownloadStatisticsMarshallingTest.kt | 33 + ...ownloadStatisticsPayloadMarshallingTest.kt | 39 + .../VideoPlaybackSessionMarshallingTest.kt | 40 + ...eoPlaybackSessionPayloadMarshallingTest.kt | 45 + .../VideoPlaybackStatisticsMarshallingTest.kt | 47 + ...laybackStatisticsPayloadMarshallingTest.kt | 152 + .../util/ActiveMobileNetworkTypeTest.kt | 48 + .../events/util/ActiveNetworkTypeTest.kt | 75 + .../events/util/HardwarePlatformTest.kt | 39 + .../EventReporterModuleRootExtensions.kt | 10 + player/gradle.properties | 2 + player/playback-engine/README.md | 1 + player/playback-engine/build.gradle.kts | 51 + .../ExoPlayerPlaybackEngineLooperTest.kt | 59 + ...lerPlaybackEngineHandlerPostOrThrowTest.kt | 131 + ...ingleHandlerPlaybackEngineThreadingTest.kt | 235 ++ .../util/AudioManagerExtensionsTest.kt | 47 + ...onousSurfaceHolderCallbackThreadingTest.kt | 99 + .../AspectRatioAdjustingSurfaceViewTest.kt | 60 + .../src/main/AndroidManifest.xml | 9 + .../sdk/player/playbackengine/AssetSource.kt | 6 + .../sdk/player/playbackengine/Encryption.kt | 24 + .../playbackengine/ExoPlayerPlaybackEngine.kt | 1242 ++++++++ .../playbackengine/PlaybackContextFactory.kt | 132 + .../player/playbackengine/PlaybackEngine.kt | 176 ++ .../PlaybackEngineModuleRoot.kt | 84 + .../PlayerLoadErrorHandlingPolicy.kt | 65 + .../SingleHandlerPlaybackEngine.kt | 81 + .../playbackengine/StreamingApiRepository.kt | 166 ++ .../playbackengine/TidalExtractorsFactory.kt | 19 + .../player/playbackengine/bts/BtsManifest.kt | 6 + .../playbackengine/bts/BtsManifestFactory.kt | 7 + .../bts/DefaultBtsManifestFactory.kt | 20 + .../cache/DefaultCacheKeyFactory.kt | 8 + .../dash/DashManifestFactory.kt | 27 + ...cheKeyAesCipherDataSourceFactoryFactory.kt | 20 + .../DecryptedHeaderFileDataSource.kt | 51 + .../DecryptedHeaderFileDataSourceFactory.kt | 14 + ...yptedHeaderFileDataSourceFactoryFactory.kt | 13 + .../di/ExoPlayerPlaybackEngineComponent.kt | 65 + .../di/ExoPlayerPlaybackEngineModule.kt | 185 ++ .../di/ExtendedExoPlayerFactoryModule.kt | 17 + .../player/playbackengine/dj/DateParser.kt | 7 + .../sdk/player/playbackengine/dj/DateRange.kt | 10 + .../playbackengine/dj/DjSessionManager.kt | 56 + .../playbackengine/dj/DjSessionStatus.kt | 8 + .../player/playbackengine/dj/HlsTagsParser.kt | 36 + .../drm/DrmLicenseRequestFactory.kt | 13 + .../sdk/player/playbackengine/drm/DrmMode.kt | 13 + .../drm/DrmSessionManagerFactory.kt | 44 + .../drm/DrmSessionManagerProviderFactory.kt | 10 + .../drm/MediaDrmCallbackExceptionFactory.kt | 18 + .../drm/TidalMediaDrmCallback.kt | 73 + .../drm/TidalMediaDrmCallbackFactory.kt | 26 + .../player/playbackengine/emu/EmuManifest.kt | 6 + .../playbackengine/emu/EmuManifestFactory.kt | 17 + .../playbackengine/error/ErrorCodeFactory.kt | 69 + .../playbackengine/error/ErrorHandler.kt | 296 ++ .../DashMediaSourceFactoryFactory.kt | 13 + .../mediasource/MediaSourcerer.kt | 125 + .../mediasource/PlaybackInfoMediaSource.kt | 94 + .../PlaybackInfoMediaSourceFactory.kt | 61 + .../PlayerDashMediaSourceFactory.kt | 24 + .../PlayerDashOfflineMediaSourceFactory.kt | 43 + ...derProgressiveOfflineMediaSourceFactory.kt | 32 + .../PlayerHlsMediaSourceFactory.kt | 45 + .../PlayerProgressiveMediaSourceFactory.kt | 22 + ...yerProgressiveOfflineMediaSourceFactory.kt | 30 + .../ProgressiveMediaSourceFactoryFactory.kt | 16 + .../mediasource/TidalMediaSourceCreator.kt | 109 + .../loadable/PlaybackInfoFetchException.kt | 16 + .../loadable/PlaybackInfoListener.kt | 17 + .../loadable/PlaybackInfoLoadable.kt | 76 + .../loadable/PlaybackInfoLoadableFactory.kt | 30 + .../PlaybackInfoLoadableLoaderCallback.kt | 84 + ...aybackInfoLoadableLoaderCallbackFactory.kt | 35 + .../streamingsession/PlaybackSession.kt | 86 + .../streamingsession/PlaybackStatistics.kt | 161 ++ .../streamingsession/StartedStall.kt | 9 + .../streamingsession/StreamingSession.kt | 182 ++ .../UndeterminedPlaybackSessionResolver.kt | 99 + .../streamingsession/VersionedCdm.kt | 37 + .../model/AssetTimeoutConfig.kt | 13 + .../model/BufferConfiguration.kt | 22 + .../player/playbackengine/model/ByteAmount.kt | 18 + .../model/DelayedMediaProductTransition.kt | 30 + .../sdk/player/playbackengine/model/Event.kt | 102 + .../playbackengine/model/PlaybackContext.kt | 80 + .../playbackengine/model/PlaybackState.kt | 35 + .../network/NetworkTransportHelper.kt | 14 + .../offline/OfflineDataSourceFactoryHelper.kt | 31 + .../offline/OfflineDrmHelper.kt | 23 + .../offline/OfflineExpiredException.kt | 11 + .../OfflinePlayDataSourceFactoryHelper.kt | 20 + .../OfflinePlayDrmDataSourceFactoryHelper.kt | 20 + .../offline/OfflineStorageProvider.kt | 47 + .../offline/StorageDataSource.kt | 48 + .../offline/StorageException.kt | 10 + .../offline/cache/OfflineCacheProvider.kt | 13 + .../crypto/CacheKeyAesCipherDataSource.kt | 29 + .../CacheKeyAesCipherDataSourceFactory.kt | 40 + .../outputdevice/OutputDeviceManager.kt | 80 + .../playbackprivilege/PlaybackPrivilege.kt | 7 + .../PlaybackPrivilegeProvider.kt | 11 + .../playbackengine/player/CacheProvider.kt | 23 + .../player/ExtendedExoPlayer.kt | 98 + .../player/ExtendedExoPlayerFactory.kt | 21 + .../player/ExtendedExoPlayerState.kt | 11 + .../ExtendedExoPlayerStateUpdateRunnable.kt | 21 + .../playbackengine/player/PlayerCache.kt | 14 + .../player/di/ExtendedExoPlayerComponent.kt | 38 + .../player/di/ExtendedExoPlayerModule.kt | 118 + .../player/di/MediaSourcererModule.kt | 546 ++++ .../player/di/ProgressiveModule.kt | 25 + .../player/di/RendererModule.kt | 81 + .../player/renderer/PlayerRenderersFactory.kt | 32 + .../renderer/audio/AudioDecodingMode.kt | 30 + .../renderer/audio/AudioRendererFactory.kt | 13 + .../fallback/FallbackAudioRendererFactory.kt | 27 + .../audio/flac/LibflacAudioRendererFactory.kt | 19 + .../video/MediaCodecVideoRendererFactory.kt | 24 + .../quality/AudioQualityRepository.kt | 22 + .../quality/VideoQualityRepository.kt | 12 + .../util/AudioManagerExtensions.kt | 7 + .../util/SynchronousSurfaceHolder.kt | 48 + .../util/SynchronousSurfaceHolderCallback.kt | 59 + .../view/AspectRatioAdjustingSurfaceView.kt | 122 + .../volume/LoudnessNormalizer.kt | 19 + .../playbackengine/volume/VolumeHelper.kt | 36 + .../MediaCodecRendererExtensions.kt | 10 + .../source/BaseMediaSourceExtensions.kt | 7 + .../source/CompositeMediaSourceExtensions.kt | 15 + .../upstream/LoaderErrorActionExtensions.kt | 6 + .../ExoPlayerPlaybackEngineTest.kt | 2545 +++++++++++++++++ .../PlayerLoadErrorHandlingPolicyTest.kt | 217 ++ .../StreamingApiRepositoryTest.kt | 327 +++ .../bts/BtsManifestFactoryTest.kt | 50 + .../cache/DefaultCacheKeyFactoryTest.kt | 66 + .../dash/DashManifestFactoryTest.kt | 42 + .../playbackengine/dj/DjSessionManagerTest.kt | 161 ++ .../sdk/player/playbackengine/dj/HlsTags.kt | 28 + .../playbackengine/dj/HlsTagsParserTest.kt | 54 + .../drm/DrmSessionManagerFactoryTest.kt | 89 + .../drm/TidalMediaDrmCallbackTest.kt | 117 + .../emu/EmuManifestFactoryTest.kt | 50 + .../error/ErrorCodeFactoryTest.kt | 80 + .../playbackengine/error/ErrorHandlerTest.kt | 745 +++++ .../LoudnessNormalizerTest.kt | 110 + .../mediasource/MediaSourcererExtensions.kt | 13 + .../mediasource/MediaSourcererTest.kt | 260 ++ .../PlaybackInfoMediaSourceExtensions.kt | 19 + .../PlaybackInfoMediaSourceTest.kt | 182 ++ .../PlayerDashMediaSourceFactoryTest.kt | 45 + ...PlayerDashOfflineMediaSourceFactoryTest.kt | 152 + ...rogressiveOfflineMediaSourceFactoryTest.kt | 112 + .../PlayerHlsMediaSourceFactoryTest.kt | 128 + ...PlayerProgressiveMediaSourceFactoryTest.kt | 92 + ...rogressiveOfflineMediaSourceFactoryTest.kt | 104 + .../TidalMediaSourceCreatorTest.kt | 243 ++ .../PlaybackInfoLoadableExtensions.kt | 7 + .../PlaybackInfoLoadableLoaderCallbackTest.kt | 129 + .../loadable/PlaybackInfoLoadableTest.kt | 147 + .../ExplicitStreamingSessionCreatorTest.kt | 16 + .../ExplicitStreamingSessionFactoryTest.kt | 16 + .../ImplicitStreamingSessionCreatorTest.kt | 16 + .../ImplicitStreamingSessionFactoryTest.kt | 16 + .../StreamingSessionCreatorTest.kt | 64 + .../StreamingSessionFactoryTest.kt | 41 + ...UndeterminedPlaybackSessionResolverTest.kt | 363 +++ .../VersionedCdmCalculatorTest.kt | 64 + .../playbackengine/model/ByteAmountTest.kt | 40 + .../DelayedMediaProductTransitionTest.kt | 61 + .../network/NetworkTransportHelperTest.kt | 88 + .../OfflinePlayDataSourceFactoryHelperTest.kt | 48 + ...flinePlayDrmDataSourceFactoryHelperTest.kt | 44 + .../offline/OfflineStorageProviderTest.kt | 93 + .../outputdevice/OutputDeviceManagerTest.kt | 95 + .../player/ExtendedExoPlayerFactoryTest.kt | 61 + ...xtendedExoPlayerStateUpdateRunnableTest.kt | 41 + .../player/ExtendedExoPlayerTest.kt | 248 ++ .../renderer/PlayerRenderersFactoryTest.kt | 92 + .../quality/AudioQualityRepositoryTest.kt | 51 + .../quality/VideoQualityRepositoryTest.kt | 18 + .../util/SynchronousSurfaceHolderTest.kt | 211 ++ .../playbackengine/volume/VolumeHelperTest.kt | 83 + .../exoplayer/hls/HlsManifestExtensions.kt | 7 + .../playlist/HlsMediaPlaylistExtensions.kt | 6 + .../ExoPlaybackExceptionExtensions.kt | 10 + .../ExoPlayerPlaybackEngineExtensions.kt | 61 + .../PlaybackEngineModuleRootExtensions.kt | 10 + .../SingleHandlerPlaybackEngineExtensions.kt | 10 + .../dash/DashManifestFactoryExtensions.kt | 8 + .../dj/DjSessionManagerExtensions.kt | 8 + ...dExoPlayerStateUpdateRunnableExtensions.kt | 6 + .../SynchronousSurfaceHolderExtensions.kt | 8 + .../com/tidal/sdk/player/PlayerPlayTest.kt | 142 + .../kotlin/com/tidal/sdk/player/PlayerTest.kt | 62 + .../playbackinfo/tracks/get_1_bts.json | 16 + .../androidTest/resources/raw/test_5sec.m4a | Bin 0 -> 156793 bytes player/src/main/AndroidManifest.xml | 2 + .../kotlin/com/tidal/sdk/player/Player.kt | 111 + .../player/auth/AuthorizationInterceptor.kt | 42 + .../sdk/player/auth/DefaultAuthenticator.kt | 37 + .../auth/RequestAuthorizationDelegate.kt | 21 + .../auth/ShouldAddAuthorizationHeader.kt | 5 + .../com/tidal/sdk/player/auth/TokenType.kt | 5 + .../sdk/player/di/EventReporterModule.kt | 90 + .../com/tidal/sdk/player/di/NetworkModule.kt | 147 + .../sdk/player/di/PlaybackEngineModule.kt | 92 + .../tidal/sdk/player/di/PlayerComponent.kt | 63 + .../com/tidal/sdk/player/di/PlayerModule.kt | 36 + .../tidal/sdk/player/di/StreamingApiModule.kt | 37 + .../player/di/StreamingPrivilegesModule.kt | 29 + .../NonIntrusiveHttpLoggingInterceptor.kt | 26 + .../player/offlineplay/OfflinePlayProvider.kt | 14 + .../auth/AuthorizationInterceptorTest.kt | 115 + .../player/auth/DefaultAuthenticatorTest.kt | 109 + .../auth/RequestAuthorizationDelegateTest.kt | 139 + .../auth/ShouldAddAuthorizationHeaderTest.kt | 24 + .../NonIntrusiveHttpLoggingInterceptorTest.kt | 86 + player/streaming-api/README.md | 1 + player/streaming-api/build.gradle.kts | 31 + .../sdk/player/streamingapi/StreamingApi.kt | 107 + .../streamingapi/StreamingApiDefault.kt | 77 + .../streamingapi/StreamingApiModuleRoot.kt | 26 + .../streamingapi/StreamingApiTimeoutConfig.kt | 14 + .../streamingapi/di/DrmLicenseModule.kt | 26 + .../streamingapi/di/PlaybackInfoModule.kt | 29 + .../player/streamingapi/di/RetrofitModule.kt | 54 + .../streamingapi/di/StreamingApiComponent.kt | 42 + .../streamingapi/di/StreamingApiModule.kt | 49 + .../streamingapi/drm/api/DrmLicenseService.kt | 22 + .../streamingapi/drm/model/DrmLicense.kt | 13 + .../drm/model/DrmLicenseRequest.kt | 19 + .../drm/repository/DrmLicenseRepository.kt | 18 + .../repository/DrmLicenseRepositoryDefault.kt | 27 + .../player/streamingapi/offline/Storage.kt | 9 + .../playbackinfo/api/PlaybackInfoService.kt | 73 + .../playbackinfo/mapper/ApiErrorMapper.kt | 14 + .../playbackinfo/model/ManifestMimeType.kt | 43 + .../playbackinfo/model/PlaybackInfo.kt | 182 ++ .../playbackinfo/model/PlaybackMode.kt | 12 + .../offline/OfflinePlaybackInfoProvider.kt | 33 + .../repository/PlaybackInfoRepository.kt | 96 + .../PlaybackInfoRepositoryDefault.kt | 108 + .../sdk/player/streamingapi/ApiConstants.kt | 72 + .../BroadcastPlaybackInfoFactory.kt | 29 + .../player/streamingapi/DrmLicenseFactory.kt | 21 + .../streamingapi/StreamingApiDefaultTest.kt | 440 +++ .../streamingapi/TrackPlaybackInfoFactory.kt | 52 + .../streamingapi/UCPlaybackInfoFactory.kt | 22 + .../streamingapi/VideoPlaybackInfoFactory.kt | 49 + .../drm/api/DrmLicenseServiceStub.kt | 27 + .../drm/api/DrmLicenseServiceTest.kt | 105 + .../DrmLicenseRepositoryDefaultTest.kt | 55 + .../model/ApiErrorFactoryBlackBoxTest.kt | 77 + .../network/model/ApiErrorFactoryTest.kt | 112 + .../OfflinePlaybackInfoProviderStub.kt | 17 + .../api/PlaybackInfoServiceStub.kt | 62 + .../api/PlaybackInfoServiceTest.kt | 344 +++ .../playbackinfo/mapper/ApiErrorMapperTest.kt | 51 + .../model/ManifestMimeTypeDeserializerTest.kt | 40 + .../PlaybackInfoRepositoryDefaultTest.kt | 151 + .../drm/licenses/widevine/post.json | 4 + .../licenses/widevine/post_empty_payload.json | 4 + .../post_empty_streaming_session_id.json | 4 + .../playbackinfo/broadcasts/get_1.json | 6 + .../get_1_replacement_audio_quality.json | 6 + .../broadcasts/get_1_replacement_id.json | 6 + .../broadcasts/get_1_unknown_mime_type.json | 6 + .../playbackinfo/tracks/get_1.json | 16 + .../get_1_empty_streaming_session_id.json | 16 + .../playbackinfo/tracks/get_1_offline.json | 18 + .../playbackinfo/tracks/get_1_protected.json | 17 + .../get_1_replacement_audio_quality.json | 16 + .../tracks/get_1_replacement_track_id.json | 16 + .../tracks/get_1_unknown_mime_type.json | 16 + .../playbackinfo/videos/get_1.json | 10 + .../get_1_empty_streaming_session_id.json | 10 + .../playbackinfo/videos/get_1_offline.json | 12 + .../playbackinfo/videos/get_1_protected.json | 11 + .../videos/get_1_replacement_video_id.json | 10 + .../get_1_replacement_video_quality.json | 10 + .../videos/get_1_unknown_mime_type.json | 10 + player/streaming-privileges/README.md | 1 + player/streaming-privileges/build.gradle.kts | 36 + .../ExampleInstrumentedTest.kt | 20 + .../src/main/AndroidManifest.xml | 8 + .../streamingprivileges/MutableState.kt | 11 + .../RegisterDefaultNetworkCallbackRunnable.kt | 45 + .../streamingprivileges/ReleaseRunnable.kt | 14 + .../SetKeepAliveRunnable.kt | 32 + .../SetStreamingPrivilegesListenerRunnable.kt | 26 + .../StreamingPrivileges.kt | 38 + .../StreamingPrivilegesDefault.kt | 34 + .../StreamingPrivilegesEventDispatcher.kt | 28 + .../StreamingPrivilegesListener.kt | 24 + .../StreamingPrivilegesModuleRoot.kt | 29 + .../StreamingPrivilegesNetworkCallback.kt | 30 + .../acquire/AcquireRunnable.kt | 40 + .../connection/CloseReason.kt | 16 + .../connection/ConnectRunnable.kt | 99 + .../connection/ConnectionMutableState.kt | 11 + .../connection/DisconnectRunnable.kt | 31 + .../connection/IfRelevantOrCloseRunnable.kt | 31 + .../connection/SocketConnectionState.kt | 52 + .../connection/StreamingPrivilegesService.kt | 10 + .../StreamingPrivilegesWebSocketInfo.kt | 6 + .../WebSocketConnectionRequestFactory.kt | 11 + ...DumpCallbacksToHandlerWebSocketListener.kt | 53 + .../websocketevents/OnWebSocketFailure.kt | 17 + .../websocketevents/OnWebSocketMessage.kt | 41 + .../websocketevents/OnWebSocketOpen.kt | 17 + .../di/StreamingPrivilegesComponent.kt | 28 + .../di/StreamingPrivilegesModule.kt | 199 ++ .../messages/WebSocketMessage.kt | 38 + .../IncomingWebSocketMessageParser.kt | 27 + .../StreamingPrivilegesDefaultTest.kt | 109 + .../StreamingPrivilegesEventDispatcherTest.kt | 66 + .../StreamingPrivilegesModuleRootTest.kt | 79 + .../StreamingPrivilegesNetworkCallbackTest.kt | 68 + .../acquire/AcquireRunnableTest.kt | 88 + .../connection/ConnectRunnableTest.kt | 440 +++ .../connection/DisconnectRunnableTest.kt | 115 + .../connection/SocketConnectionStateTest.kt | 74 + .../WebSocketConnectionRequestFactoryTest.kt | 46 + ...CallbacksToHandlerWebSocketListenerTest.kt | 122 + .../websocketevents/OnWebSocketFailureTest.kt | 32 + .../websocketevents/OnWebSocketMessageTest.kt | 80 + .../websocketevents/OnWebSocketOpenTest.kt | 44 + .../messages/WebSocketMessageAcquireTest.kt | 23 + .../IncomingWebSocketMessageParserTest.kt | 108 + ...StreamingPrivilegesModuleRootExtensions.kt | 10 + .../connection/ConnectRunnableExtensions.kt | 9 + player/testutil/build.gradle.kts | 8 + .../sdk/player/MockWebServerExtensions.kt | 37 + .../com/tidal/sdk/player/ReflectionUtil.kt | 95 + .../RepeatableFlakyTest.kt | 12 + .../RepeatableFlakyTestRule.kt | 71 + .../api-responses/errors/401_11003.json | 5 + .../api-responses/errors/401_4005.json | 5 + .../api-responses/errors/401_6005.json | 5 + .../api-responses/errors/500_999.json | 5 + .../resources/api-responses/errors/empty.json | 2 + .../api-responses/errors/malformed.json | 3 + .../api-responses/errors/unsupported.json | 4 + renovate.json | 21 + settings.gradle.kts | 65 + static-analysis/config/detekt-cli-version.txt | 1 + static-analysis/config/detekt-rules.yml | 39 + static-analysis/config/ktlint-baseline.xml | 3 + static-analysis/config/ktlint-cli-version.txt | 1 + static-analysis/git-hooks/pre-commit | 27 + static-analysis/run-detekt.sh | 72 + static-analysis/run-ktlint.sh | 79 + template/README.md | 54 + template/apps/demo/build.gradle.kts | 34 + .../apps/demo/src/main/AndroidManifest.xml | 21 + .../main/kotlin/com/tidal/sdk/demo/DemoApp.kt | 5 + .../kotlin/com/tidal/sdk/demo/MainActivity.kt | 35 + .../drawable-v24/ic_launcher_foreground.xml | 31 + .../res/drawable/ic_launcher_background.xml | 74 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../apps/demo/src/main/res/values/colors.xml | 10 + .../apps/demo/src/main/res/values/strings.xml | 3 + .../demo/src/main/res/xml/backup_rules.xml | 14 + .../main/res/xml/data_extraction_rules.xml | 20 + template/build.gradle.kts | 21 + template/consumer-rules.pro | 0 template/gradle.properties | 2 + .../tidal/template/ExampleInstrumentedTest.kt | 20 + template/src/main/AndroidManifest.xml | 2 + .../kotlin/com/tidal/sdk/template/Template.kt | 19 + .../com/tidal/template/ExampleUnitTest.kt | 17 + 807 files changed, 43941 insertions(+) create mode 100644 .editorconfig create mode 100644 .fossa.yml create mode 100644 .github/CODEOWNERS create mode 100644 .github/scripts/CheckChangelogs.main.kts create mode 100644 .github/workflows/check-changelog-files.yml create mode 100644 .github/workflows/create-release.yml create mode 100644 .github/workflows/fossa-scan.yml create mode 100644 .github/workflows/instrumented-test.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/post-merge.yml create mode 100644 .github/workflows/publish-pages.yml create mode 100644 .github/workflows/publish-release.yml create mode 100644 .github/workflows/pull-request.yml create mode 100644 .github/workflows/unit-test.yml create mode 100644 .gitignore create mode 100755 .scripts/ci/checkout_module_version.sh create mode 100755 .scripts/ci/extract_module_version_from_bom_module_json.sh create mode 100644 LICENSE create mode 100644 README.md create mode 100644 auth/CHANGELOG.md create mode 100644 auth/README.md create mode 100644 auth/apps/demo/build.gradle.kts create mode 100644 auth/apps/demo/src/main/AndroidManifest.xml create mode 100644 auth/apps/demo/src/main/assets/logback.xml create mode 100644 auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DemoApp.kt create mode 100644 auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DeviceLoginScreen.kt create mode 100644 auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/LoginScreen.kt create mode 100644 auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/MainActivity.kt create mode 100644 auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/NavigationHost.kt create mode 100644 auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/StartScreen.kt create mode 100644 auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ComposeWebView.kt create mode 100644 auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ExtendedChromeClient.kt create mode 100644 auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ExtendedWebClient.kt create mode 100644 auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/JavaScriptInterface.kt create mode 100644 auth/apps/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 auth/apps/demo/src/main/res/drawable/ic_launcher_background.xml create mode 100644 auth/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 auth/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 auth/apps/demo/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 auth/apps/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 auth/apps/demo/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 auth/apps/demo/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 auth/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 auth/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 auth/apps/demo/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 auth/apps/demo/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 auth/apps/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 auth/apps/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 auth/apps/demo/src/main/res/values/colors.xml create mode 100644 auth/apps/demo/src/main/res/values/strings.xml create mode 100644 auth/apps/demo/src/main/res/xml/backup_rules.xml create mode 100644 auth/apps/demo/src/main/res/xml/data_extraction_rules.xml create mode 100644 auth/build.gradle.kts create mode 100644 auth/consumer-rules.pro create mode 100644 auth/gradle.properties create mode 100644 auth/src/androidTest/kotlin/com/tidal/auth/ExampleInstrumentedTest.kt create mode 100644 auth/src/main/AndroidManifest.xml create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/Auth.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/CredentialsProvider.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/DefaultCredentialsProvider.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/TidalAuth.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/TokenRepository.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/di/AuthComponent.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/di/AuthModule.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/di/CredentialsModule.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/di/LoginModule.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/di/NetworkModule.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/di/StorageModule.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/login/CodeChallengeBuilder.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/login/DeviceLoginPollHelper.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/login/LoginRepository.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/login/LoginUriBuilder.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/login/RedirectUri.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/ApiErrorSubStatus.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/AuthConfig.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/AuthResult.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/Credentials.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/CredentialsUpdatedMessage.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/DeviceAuthorizationResponse.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/ErrorResponse.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/Errors.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/LoginConfig.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/LoginResponse.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/QueryParameter.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/RefreshResponse.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/Tokens.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/model/UpgradeResponse.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/network/LoginService.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/network/NetworkLogLevel.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/storage/DefaultTokensStore.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/storage/LegacyCredentials.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/storage/LegacyTokens.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/storage/Scopes.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/storage/TokensStore.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/token/TokenService.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/util/AuthErrorPolicy.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/util/AuthHttp.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/util/DefaultTimeProvider.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/util/Extensions.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/util/InternalExtensions.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/util/RetryPolicy.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/util/TimeProvider.kt create mode 100644 auth/src/main/kotlin/com/tidal/sdk/auth/util/Utils.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/auth/FakeLoginService.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/auth/FakeTokenService.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/auth/LoginRepositoryTest.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/auth/TokenRepositoryTest.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/auth/login/FakeTokensStore.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/auth/login/RedirectUriTest.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/auth/model/CredentialsTest.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/auth/storage/DefaultTokensStoreTest.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/auth/util/RetryWithPolicyTest.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/auth/util/TestUtils.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/util/CoroutineTestTimeProvider.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/util/TestTimeProvider.kt create mode 100644 auth/src/test/kotlin/com/tidal/sdk/util/TestUtils.kt create mode 100644 bom/build.gradle.kts create mode 100644 bom/gradle.properties create mode 100644 build.gradle.kts create mode 100644 buildlogic/build.gradle.kts create mode 120000 buildlogic/gradle/libs.versions.toml create mode 100644 buildlogic/settings.gradle.kts create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/SdkModules.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresDokka.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresGradleProjectVersion.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresJUnit5.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresKotlinCompiler.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresMavenPublish.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/JvmPlatformConventionPlugin.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidApplicationConventionPlugin.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidConventionPlugin.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidLibraryConventionPlugin.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinJvmLibraryConventionPlugin.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/constant/Config.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/constant/PluginId.kt create mode 100644 buildlogic/src/main/kotlin/com/tidal/sdk/plugins/extensions/GradleAPIExtensions.kt create mode 100644 common/CHANGELOG.md create mode 100644 common/README.md create mode 100644 common/build.gradle.kts create mode 100644 common/consumer-rules.pro create mode 100644 common/gradle.properties create mode 100644 common/src/main/AndroidManifest.xml create mode 100644 common/src/main/kotlin/com/tidal/sdk/common/IllegalConfigurationError.kt create mode 100644 common/src/main/kotlin/com/tidal/sdk/common/Logging.kt create mode 100644 common/src/main/kotlin/com/tidal/sdk/common/NetworkError.kt create mode 100644 common/src/main/kotlin/com/tidal/sdk/common/RetryableError.kt create mode 100644 common/src/main/kotlin/com/tidal/sdk/common/TidalError.kt create mode 100644 common/src/main/kotlin/com/tidal/sdk/common/TidalMessage.kt create mode 100644 common/src/main/kotlin/com/tidal/sdk/common/UnexpectedError.kt create mode 100644 eventproducer/CHANGELOG.md create mode 100644 eventproducer/README.md create mode 100644 eventproducer/apps/demo/build.gradle.kts create mode 100644 eventproducer/apps/demo/src/main/AndroidManifest.xml create mode 100644 eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DemoApp.kt create mode 100644 eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/EventSenderDemoScreen.kt create mode 100644 eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/MainActivity.kt create mode 100644 eventproducer/apps/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 eventproducer/apps/demo/src/main/res/drawable/ic_launcher_background.xml create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 eventproducer/apps/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 eventproducer/apps/demo/src/main/res/values/colors.xml create mode 100644 eventproducer/apps/demo/src/main/res/values/strings.xml create mode 100644 eventproducer/apps/demo/src/main/res/xml/backup_rules.xml create mode 100644 eventproducer/apps/demo/src/main/res/xml/data_extraction_rules.xml create mode 100644 eventproducer/build.gradle.kts create mode 100644 eventproducer/consumer-rules.pro create mode 100644 eventproducer/gradle.properties create mode 100644 eventproducer/src/main/AndroidManifest.xml create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/DefaultEventSender.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/EventProducer.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/EventSender.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/Submitter.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/auth/AuthHeadersUtil.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/auth/DefaultAuthenticator.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/database/EventsDatabase.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/ConvertersModule.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/DatabaseModule.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/EventProducerModule.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/EventsComponent.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/NetworkModule.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/OkHttpModule.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/UtilsModule.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventDao.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventEntity.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventsLocalDataSource.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/ConsentCategory.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/Event.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/EventsConfig.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/EventsConfigProvider.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/MonitoringEvent.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/MonitoringEventType.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/Result.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringDao.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringEntity.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringInfo.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringLocalDataSource.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/HeadersInterceptor.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/service/SendMessageBatchResponse.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/service/SqsService.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageEndMessage.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageStartError.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageState.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/repository/EventsRepository.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/repository/RepositoryHelper.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/scheduler/MonitoringScheduler.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/scheduler/SendEventBatchScheduler.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/CoroutineScopeCanceledException.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/DatabaseSizeChecker.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/EventSizeValidator.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/HeadersUtils.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/MapConverter.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/SqsRequestParametersConverter.kt create mode 100644 eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/Utils.kt create mode 100644 eventproducer/src/test/kotlin/com/tidal/eventproducer/auth/DefaultAuthenticatorTest.kt create mode 100644 eventproducer/src/test/kotlin/com/tidal/eventproducer/fakes/FakeCredentialsProvider.kt create mode 100644 eventproducer/src/test/kotlin/com/tidal/eventproducer/repository/EventsRepositoryTest.kt create mode 100644 eventproducer/src/test/kotlin/com/tidal/eventproducer/utils/HeadersUtilsTest.kt create mode 100644 eventproducer/src/test/kotlin/com/tidal/eventproducer/utils/SqsParametersConverterTest.kt create mode 100755 generate-module.sh create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100755 local-setup.sh create mode 100644 player/CHANGELOG.md create mode 100644 player/README.md create mode 100644 player/apps/demo/build.gradle.kts create mode 100644 player/apps/demo/src/main/AndroidManifest.xml create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/MyApplication.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ComposeWebView.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ExtendedChromeClient.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ExtendedWebClient.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/JavaScriptInterface.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DemoPlayableItem.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DemoPlayableItemComposable.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DeriveUiState.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/LoadingScreen.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/LoginScreen.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivity.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityScreen.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityState.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityViewModel.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityViewModelState.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/PlayerInitializedScreen.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/PlayerNotInitializedScreen.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/Selector.kt create mode 100644 player/apps/demo/src/main/kotlin/com/tidal/sdk/player/ui/theme/Theme.kt create mode 100644 player/apps/demo/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 player/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 player/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 player/apps/demo/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 player/apps/demo/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 player/apps/demo/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 player/apps/demo/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 player/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 player/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 player/apps/demo/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 player/apps/demo/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 player/apps/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 player/apps/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 player/apps/demo/src/main/res/values/ic_launcher_background.xml create mode 100644 player/apps/demo/src/main/res/values/themes.xml create mode 100644 player/apps/demo/src/main/res/xml/network_security_config.xml create mode 100644 player/build.gradle.kts create mode 100644 player/common-android/README.md create mode 100644 player/common-android/build.gradle.kts create mode 100644 player/common-android/src/androidTest/kotlin/com/tidal/sdk/player/commonandroid/Base64CodecTest.kt create mode 100644 player/common-android/src/androidTest/kotlin/com/tidal/sdk/player/commonandroid/jwt/Base64JwtDecoderTest.kt create mode 100644 player/common-android/src/main/AndroidManifest.xml create mode 100644 player/common-android/src/main/kotlin/com/tidal/sdk/player/commonandroid/Base64Codec.kt create mode 100644 player/common-android/src/main/kotlin/com/tidal/sdk/player/commonandroid/SystemClockWrapper.kt create mode 100644 player/common-android/src/main/kotlin/com/tidal/sdk/player/commonandroid/TrueTimeWrapper.kt create mode 100644 player/common-android/src/main/kotlin/com/tidal/sdk/player/commonandroid/jwt/Base64JwtDecoder.kt create mode 100644 player/common-android/src/test/kotlin/com/tidal/sdk/player/commonandroid/ExampleUnitTest.kt create mode 100644 player/common/README.md create mode 100644 player/common/build.gradle.kts create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/Common.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/Configuration.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/ConfigurationListener.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/ForwardingMediaProduct.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/RequestBuilderFactory.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/UUIDWrapper.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/ApiError.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/AssetPresentation.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/AudioMode.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/AudioQuality.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/BaseMediaProduct.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/LoudnessNormalizationMode.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/MediaProduct.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/MediaStorage.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/ProductQuality.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/ProductType.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/StreamType.kt create mode 100644 player/common/src/main/kotlin/com/tidal/sdk/player/common/model/VideoQuality.kt create mode 100644 player/consumer-rules.pro create mode 100644 player/events/README.md create mode 100644 player/events/build.gradle.kts create mode 100644 player/events/src/androidTest/kotlin/com/tidal/sdk/player/events/ExampleInstrumentedTest.kt create mode 100644 player/events/src/main/AndroidManifest.xml create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/ClientSupplier.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/DefaultEventReporter.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/EventReporter.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/EventReporterModuleRoot.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/UserSupplier.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/AudioDownloadStatisticsEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/AudioPlaybackSessionEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/AudioPlaybackStatisticsEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/BroadcastPlaybackSessionEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/BroadcastPlaybackStatisticsEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/DrmLicenseFetchEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/EventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/NotStartedPlaybackStatisticsEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/PlaybackInfoFetchEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/StreamingSessionEndEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/StreamingSessionStartEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/StreamingSessionStartPayloadDecorator.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/UCPlaybackSessionEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/UCPlaybackStatisticsEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/VideoDownloadStatisticsEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/VideoPlaybackSessionEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/converter/VideoPlaybackStatisticsEventFactory.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/di/DefaultEventReporterComponent.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/di/DefaultEventReporterModule.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/di/EventFactoryModule.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/AudioDownloadStatistics.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/AudioPlaybackSession.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/AudioPlaybackStatistics.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/BroadcastPlaybackSession.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/BroadcastPlaybackStatistics.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/Client.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/DownloadStatistics.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/DrmLicenseFetch.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/EndReason.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/Event.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/NotStartedPlaybackStatistics.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/PlayLog.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/PlaybackInfoFetch.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/PlaybackSession.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/PlaybackStatistics.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/StreamingMetrics.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/StreamingSessionEnd.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/StreamingSessionStart.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/UCPlaybackSession.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/UCPlaybackStatistics.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/User.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/VideoDownloadStatistics.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/VideoPlaybackSession.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/model/VideoPlaybackStatistics.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/util/ActiveMobileNetworkType.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/util/ActiveNetworkType.kt create mode 100644 player/events/src/main/java/com/tidal/sdk/player/events/util/HardwarePlatform.kt create mode 100644 player/events/src/main/res/values-sw600dp/bool.xml create mode 100644 player/events/src/main/res/values/bool.xml create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/ClientSupplierTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/DefaultEventReporterTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/EventReporterModuleRootTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/UserSupplierTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/AudioDownloadStatisticsEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/AudioPlaybackSessionEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/AudioPlaybackStatisticsEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/BroadcastPlaybackSessionEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/BroadcastPlaybackStatisticsEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/DrmLicenseFetchEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/NotStartedPlaybackStatisticsEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/PlaybackInfoFetchEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/StreamingSessionEndEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/StreamingSessionStartEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/StreamingSessionStartPayloadDecoratorTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/UCPlaybackSessionEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/UCPlaybackStatisticsEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/VideoDownloadStatisticsEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/VideoPlaybackSessionEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/converter/VideoPlaybackStatisticsEventFactoryTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/AudioDownloadStatisticsMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/AudioDownloadStatisticsPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/AudioPlaybackSessionMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/AudioPlaybackSessionPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/AudioPlaybackStatisticsMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/AudioPlaybackStatisticsPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/BroadcastPlaybackSessionMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/BroadcastPlaybackSessionPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/BroadcastPlaybackStatisticsMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/BroadcastPlaybackStatisticsPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/ClientExtensions.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/ClientMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/DownloadStatisticsMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/DownloadStatisticsPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/DrmLicenseFetchMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/DrmLicenseFetchPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/NotStartedPlaybackStatisticsMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/NotStartedPlaybackStatisticsPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/PlaybackInfoFetchMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/PlaybackInfoFetchPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/PlaybackSessionMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/PlaybackSessionPayloadActionMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/PlaybackSessionPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/PlaybackStatisticsAdaptationMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/PlaybackStatisticsMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/PlaybackStatisticsPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/PlaybackStatisticsStallMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/StreamingSessionEndMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/StreamingSessionEndPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/StreamingSessionStartDecoratedPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/StreamingSessionStartMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/UCPlaybackSessionMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/UCPlaybackSessionPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/UCPlaybackStatisticsMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/UCPlaybackStatisticsPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/UserExtensions.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/UserMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/VideoDownloadStatisticsMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/VideoDownloadStatisticsPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/VideoPlaybackSessionMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/VideoPlaybackSessionPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/VideoPlaybackStatisticsMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/model/VideoPlaybackStatisticsPayloadMarshallingTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/util/ActiveMobileNetworkTypeTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/util/ActiveNetworkTypeTest.kt create mode 100644 player/events/src/test/kotlin/com/tidal/sdk/player/events/util/HardwarePlatformTest.kt create mode 100644 player/events/src/testext/kotlin/com/tidal/sdk/player/events/EventReporterModuleRootExtensions.kt create mode 100644 player/gradle.properties create mode 100644 player/playback-engine/README.md create mode 100644 player/playback-engine/build.gradle.kts create mode 100644 player/playback-engine/src/androidTest/kotlin/com/tidal/sdk/player/playbackengine/ExoPlayerPlaybackEngineLooperTest.kt create mode 100644 player/playback-engine/src/androidTest/kotlin/com/tidal/sdk/player/playbackengine/SingleHandlerPlaybackEngineHandlerPostOrThrowTest.kt create mode 100644 player/playback-engine/src/androidTest/kotlin/com/tidal/sdk/player/playbackengine/SingleHandlerPlaybackEngineThreadingTest.kt create mode 100644 player/playback-engine/src/androidTest/kotlin/com/tidal/sdk/player/playbackengine/util/AudioManagerExtensionsTest.kt create mode 100644 player/playback-engine/src/androidTest/kotlin/com/tidal/sdk/player/playbackengine/util/SynchronousSurfaceHolderCallbackThreadingTest.kt create mode 100644 player/playback-engine/src/androidTest/kotlin/com/tidal/sdk/player/playbackengine/view/AspectRatioAdjustingSurfaceViewTest.kt create mode 100644 player/playback-engine/src/main/AndroidManifest.xml create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/AssetSource.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/Encryption.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/ExoPlayerPlaybackEngine.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/PlaybackContextFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/PlaybackEngine.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/PlaybackEngineModuleRoot.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/PlayerLoadErrorHandlingPolicy.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/SingleHandlerPlaybackEngine.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/StreamingApiRepository.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/TidalExtractorsFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/bts/BtsManifest.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/bts/BtsManifestFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/bts/DefaultBtsManifestFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/cache/DefaultCacheKeyFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/dash/DashManifestFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/datasource/CacheKeyAesCipherDataSourceFactoryFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/datasource/DecryptedHeaderFileDataSource.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/datasource/DecryptedHeaderFileDataSourceFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/datasource/DecryptedHeaderFileDataSourceFactoryFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/di/ExoPlayerPlaybackEngineComponent.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/di/ExoPlayerPlaybackEngineModule.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/di/ExtendedExoPlayerFactoryModule.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/dj/DateParser.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/dj/DateRange.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/dj/DjSessionManager.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/dj/DjSessionStatus.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/dj/HlsTagsParser.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/drm/DrmLicenseRequestFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/drm/DrmMode.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/drm/DrmSessionManagerFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/drm/DrmSessionManagerProviderFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/drm/MediaDrmCallbackExceptionFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/drm/TidalMediaDrmCallback.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/drm/TidalMediaDrmCallbackFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/emu/EmuManifest.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/emu/EmuManifestFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/error/ErrorCodeFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/error/ErrorHandler.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/DashMediaSourceFactoryFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/MediaSourcerer.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlaybackInfoMediaSource.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlaybackInfoMediaSourceFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerDashMediaSourceFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerDashOfflineMediaSourceFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerDecryptedHeaderProgressiveOfflineMediaSourceFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerHlsMediaSourceFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerProgressiveMediaSourceFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerProgressiveOfflineMediaSourceFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/ProgressiveMediaSourceFactoryFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/TidalMediaSourceCreator.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/loadable/PlaybackInfoFetchException.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/loadable/PlaybackInfoListener.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/loadable/PlaybackInfoLoadable.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/loadable/PlaybackInfoLoadableFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/loadable/PlaybackInfoLoadableLoaderCallback.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/loadable/PlaybackInfoLoadableLoaderCallbackFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/PlaybackSession.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/PlaybackStatistics.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/StartedStall.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/StreamingSession.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/UndeterminedPlaybackSessionResolver.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/VersionedCdm.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/model/AssetTimeoutConfig.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/model/BufferConfiguration.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/model/ByteAmount.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/model/DelayedMediaProductTransition.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/model/Event.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/model/PlaybackContext.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/model/PlaybackState.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/network/NetworkTransportHelper.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/OfflineDataSourceFactoryHelper.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/OfflineDrmHelper.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/OfflineExpiredException.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/OfflinePlayDataSourceFactoryHelper.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/OfflinePlayDrmDataSourceFactoryHelper.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/OfflineStorageProvider.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/StorageDataSource.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/StorageException.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/cache/OfflineCacheProvider.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/crypto/CacheKeyAesCipherDataSource.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/offline/crypto/CacheKeyAesCipherDataSourceFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/outputdevice/OutputDeviceManager.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/playbackprivilege/PlaybackPrivilege.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/playbackprivilege/PlaybackPrivilegeProvider.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/CacheProvider.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/ExtendedExoPlayer.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/ExtendedExoPlayerFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/ExtendedExoPlayerState.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/ExtendedExoPlayerStateUpdateRunnable.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/PlayerCache.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/di/ExtendedExoPlayerComponent.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/di/ExtendedExoPlayerModule.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/di/MediaSourcererModule.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/di/ProgressiveModule.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/di/RendererModule.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/renderer/PlayerRenderersFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/renderer/audio/AudioDecodingMode.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/renderer/audio/AudioRendererFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/renderer/audio/fallback/FallbackAudioRendererFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/renderer/audio/flac/LibflacAudioRendererFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/player/renderer/video/MediaCodecVideoRendererFactory.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/quality/AudioQualityRepository.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/quality/VideoQualityRepository.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/util/AudioManagerExtensions.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/util/SynchronousSurfaceHolder.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/util/SynchronousSurfaceHolderCallback.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/view/AspectRatioAdjustingSurfaceView.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/volume/LoudnessNormalizer.kt create mode 100644 player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/volume/VolumeHelper.kt create mode 100644 player/playback-engine/src/test/kotlin/androidx/media3/exoplayer/mediacodec/MediaCodecRendererExtensions.kt create mode 100644 player/playback-engine/src/test/kotlin/androidx/media3/exoplayer/source/BaseMediaSourceExtensions.kt create mode 100644 player/playback-engine/src/test/kotlin/androidx/media3/exoplayer/source/CompositeMediaSourceExtensions.kt create mode 100644 player/playback-engine/src/test/kotlin/androidx/media3/exoplayer/upstream/LoaderErrorActionExtensions.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/ExoPlayerPlaybackEngineTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/PlayerLoadErrorHandlingPolicyTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/StreamingApiRepositoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/bts/BtsManifestFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/cache/DefaultCacheKeyFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/dash/DashManifestFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/dj/DjSessionManagerTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/dj/HlsTags.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/dj/HlsTagsParserTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/drm/DrmSessionManagerFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/drm/TidalMediaDrmCallbackTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/emu/EmuManifestFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/error/ErrorCodeFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/error/ErrorHandlerTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/loudnessnormalization/LoudnessNormalizerTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/MediaSourcererExtensions.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/MediaSourcererTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlaybackInfoMediaSourceExtensions.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlaybackInfoMediaSourceTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerDashMediaSourceFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerDashOfflineMediaSourceFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerDecryptedHeaderProgressiveOfflineMediaSourceFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerHlsMediaSourceFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerProgressiveMediaSourceFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/PlayerProgressiveOfflineMediaSourceFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/TidalMediaSourceCreatorTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/loadable/PlaybackInfoLoadableExtensions.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/loadable/PlaybackInfoLoadableLoaderCallbackTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/loadable/PlaybackInfoLoadableTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/ExplicitStreamingSessionCreatorTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/ExplicitStreamingSessionFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/ImplicitStreamingSessionCreatorTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/ImplicitStreamingSessionFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/StreamingSessionCreatorTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/StreamingSessionFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/UndeterminedPlaybackSessionResolverTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/mediasource/streamingsession/VersionedCdmCalculatorTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/model/ByteAmountTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/model/DelayedMediaProductTransitionTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/network/NetworkTransportHelperTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/offline/OfflinePlayDataSourceFactoryHelperTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/offline/OfflinePlayDrmDataSourceFactoryHelperTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/offline/OfflineStorageProviderTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/outputdevice/OutputDeviceManagerTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/player/ExtendedExoPlayerFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/player/ExtendedExoPlayerStateUpdateRunnableTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/player/ExtendedExoPlayerTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/player/renderer/PlayerRenderersFactoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/quality/AudioQualityRepositoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/quality/VideoQualityRepositoryTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/util/SynchronousSurfaceHolderTest.kt create mode 100644 player/playback-engine/src/test/kotlin/com/tidal/sdk/player/playbackengine/volume/VolumeHelperTest.kt create mode 100644 player/playback-engine/src/testext/kotlin/androidx/media3/exoplayer/hls/HlsManifestExtensions.kt create mode 100644 player/playback-engine/src/testext/kotlin/androidx/media3/exoplayer/hls/playlist/HlsMediaPlaylistExtensions.kt create mode 100644 player/playback-engine/src/testext/kotlin/com/tidal/sdk/player/playbackengine/ExoPlaybackExceptionExtensions.kt create mode 100644 player/playback-engine/src/testext/kotlin/com/tidal/sdk/player/playbackengine/ExoPlayerPlaybackEngineExtensions.kt create mode 100644 player/playback-engine/src/testext/kotlin/com/tidal/sdk/player/playbackengine/PlaybackEngineModuleRootExtensions.kt create mode 100644 player/playback-engine/src/testext/kotlin/com/tidal/sdk/player/playbackengine/SingleHandlerPlaybackEngineExtensions.kt create mode 100644 player/playback-engine/src/testext/kotlin/com/tidal/sdk/player/playbackengine/dash/DashManifestFactoryExtensions.kt create mode 100644 player/playback-engine/src/testext/kotlin/com/tidal/sdk/player/playbackengine/dj/DjSessionManagerExtensions.kt create mode 100644 player/playback-engine/src/testext/kotlin/com/tidal/sdk/player/playbackengine/player/ExtendedExoPlayerStateUpdateRunnableExtensions.kt create mode 100644 player/playback-engine/src/testext/kotlin/com/tidal/sdk/player/playbackengine/util/SynchronousSurfaceHolderExtensions.kt create mode 100644 player/src/androidTest/kotlin/com/tidal/sdk/player/PlayerPlayTest.kt create mode 100644 player/src/androidTest/kotlin/com/tidal/sdk/player/PlayerTest.kt create mode 100644 player/src/androidTest/resources/api-responses/playbackinfo/tracks/get_1_bts.json create mode 100644 player/src/androidTest/resources/raw/test_5sec.m4a create mode 100644 player/src/main/AndroidManifest.xml create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/Player.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/auth/AuthorizationInterceptor.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/auth/DefaultAuthenticator.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/auth/RequestAuthorizationDelegate.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/auth/ShouldAddAuthorizationHeader.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/auth/TokenType.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/di/EventReporterModule.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/di/NetworkModule.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/di/PlaybackEngineModule.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/di/PlayerComponent.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/di/PlayerModule.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/di/StreamingApiModule.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/di/StreamingPrivilegesModule.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/interceptor/NonIntrusiveHttpLoggingInterceptor.kt create mode 100644 player/src/main/kotlin/com/tidal/sdk/player/offlineplay/OfflinePlayProvider.kt create mode 100644 player/src/test/kotlin/com/tidal/sdk/player/auth/AuthorizationInterceptorTest.kt create mode 100644 player/src/test/kotlin/com/tidal/sdk/player/auth/DefaultAuthenticatorTest.kt create mode 100644 player/src/test/kotlin/com/tidal/sdk/player/auth/RequestAuthorizationDelegateTest.kt create mode 100644 player/src/test/kotlin/com/tidal/sdk/player/auth/ShouldAddAuthorizationHeaderTest.kt create mode 100644 player/src/test/kotlin/com/tidal/sdk/player/interceptor/NonIntrusiveHttpLoggingInterceptorTest.kt create mode 100644 player/streaming-api/README.md create mode 100644 player/streaming-api/build.gradle.kts create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/StreamingApi.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/StreamingApiDefault.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/StreamingApiModuleRoot.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/StreamingApiTimeoutConfig.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/di/DrmLicenseModule.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/di/PlaybackInfoModule.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/di/RetrofitModule.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/di/StreamingApiComponent.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/di/StreamingApiModule.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/drm/api/DrmLicenseService.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/drm/model/DrmLicense.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/drm/model/DrmLicenseRequest.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/drm/repository/DrmLicenseRepository.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/drm/repository/DrmLicenseRepositoryDefault.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/offline/Storage.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/api/PlaybackInfoService.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/mapper/ApiErrorMapper.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/model/ManifestMimeType.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/model/PlaybackInfo.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/model/PlaybackMode.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/offline/OfflinePlaybackInfoProvider.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/repository/PlaybackInfoRepository.kt create mode 100644 player/streaming-api/src/main/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/repository/PlaybackInfoRepositoryDefault.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/ApiConstants.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/BroadcastPlaybackInfoFactory.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/DrmLicenseFactory.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/StreamingApiDefaultTest.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/TrackPlaybackInfoFactory.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/UCPlaybackInfoFactory.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/VideoPlaybackInfoFactory.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/drm/api/DrmLicenseServiceStub.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/drm/api/DrmLicenseServiceTest.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/drm/repository/DrmLicenseRepositoryDefaultTest.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/network/model/ApiErrorFactoryBlackBoxTest.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/network/model/ApiErrorFactoryTest.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/offline/OfflinePlaybackInfoProviderStub.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/api/PlaybackInfoServiceStub.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/api/PlaybackInfoServiceTest.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/mapper/ApiErrorMapperTest.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/model/ManifestMimeTypeDeserializerTest.kt create mode 100644 player/streaming-api/src/test/kotlin/com/tidal/sdk/player/streamingapi/playbackinfo/repository/PlaybackInfoRepositoryDefaultTest.kt create mode 100644 player/streaming-api/src/test/resources/api-responses/drm/licenses/widevine/post.json create mode 100644 player/streaming-api/src/test/resources/api-responses/drm/licenses/widevine/post_empty_payload.json create mode 100644 player/streaming-api/src/test/resources/api-responses/drm/licenses/widevine/post_empty_streaming_session_id.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/broadcasts/get_1.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/broadcasts/get_1_replacement_audio_quality.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/broadcasts/get_1_replacement_id.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/broadcasts/get_1_unknown_mime_type.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/tracks/get_1.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/tracks/get_1_empty_streaming_session_id.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/tracks/get_1_offline.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/tracks/get_1_protected.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/tracks/get_1_replacement_audio_quality.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/tracks/get_1_replacement_track_id.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/tracks/get_1_unknown_mime_type.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/videos/get_1.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/videos/get_1_empty_streaming_session_id.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/videos/get_1_offline.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/videos/get_1_protected.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/videos/get_1_replacement_video_id.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/videos/get_1_replacement_video_quality.json create mode 100644 player/streaming-api/src/test/resources/api-responses/playbackinfo/videos/get_1_unknown_mime_type.json create mode 100644 player/streaming-privileges/README.md create mode 100644 player/streaming-privileges/build.gradle.kts create mode 100644 player/streaming-privileges/src/androidTest/kotlin/com/tidal/sdk/player/streamingprivileges/ExampleInstrumentedTest.kt create mode 100644 player/streaming-privileges/src/main/AndroidManifest.xml create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/MutableState.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/RegisterDefaultNetworkCallbackRunnable.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/ReleaseRunnable.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/SetKeepAliveRunnable.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/SetStreamingPrivilegesListenerRunnable.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivileges.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivilegesDefault.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivilegesEventDispatcher.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivilegesListener.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivilegesModuleRoot.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivilegesNetworkCallback.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/acquire/AcquireRunnable.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/CloseReason.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/ConnectRunnable.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/ConnectionMutableState.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/DisconnectRunnable.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/IfRelevantOrCloseRunnable.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/SocketConnectionState.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/StreamingPrivilegesService.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/StreamingPrivilegesWebSocketInfo.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/WebSocketConnectionRequestFactory.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/websocketevents/DumpCallbacksToHandlerWebSocketListener.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/websocketevents/OnWebSocketFailure.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/websocketevents/OnWebSocketMessage.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/connection/websocketevents/OnWebSocketOpen.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/di/StreamingPrivilegesComponent.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/di/StreamingPrivilegesModule.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/messages/WebSocketMessage.kt create mode 100644 player/streaming-privileges/src/main/kotlin/com/tidal/sdk/player/streamingprivileges/messages/incoming/IncomingWebSocketMessageParser.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivilegesDefaultTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivilegesEventDispatcherTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivilegesModuleRootTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivilegesNetworkCallbackTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/acquire/AcquireRunnableTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/connection/ConnectRunnableTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/connection/DisconnectRunnableTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/connection/SocketConnectionStateTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/connection/WebSocketConnectionRequestFactoryTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/connection/websocketevents/DumpCallbacksToHandlerWebSocketListenerTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/connection/websocketevents/OnWebSocketFailureTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/connection/websocketevents/OnWebSocketMessageTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/connection/websocketevents/OnWebSocketOpenTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/messages/WebSocketMessageAcquireTest.kt create mode 100644 player/streaming-privileges/src/test/kotlin/com/tidal/sdk/player/streamingprivileges/messages/incoming/IncomingWebSocketMessageParserTest.kt create mode 100644 player/streaming-privileges/src/testext/kotlin/com/tidal/sdk/player/streamingprivileges/StreamingPrivilegesModuleRootExtensions.kt create mode 100644 player/streaming-privileges/src/testext/kotlin/com/tidal/sdk/player/streamingprivileges/connection/ConnectRunnableExtensions.kt create mode 100644 player/testutil/build.gradle.kts create mode 100644 player/testutil/src/main/kotlin/com/tidal/sdk/player/MockWebServerExtensions.kt create mode 100644 player/testutil/src/main/kotlin/com/tidal/sdk/player/ReflectionUtil.kt create mode 100644 player/testutil/src/main/kotlin/com/tidal/sdk/player/repeatableflakytest/RepeatableFlakyTest.kt create mode 100644 player/testutil/src/main/kotlin/com/tidal/sdk/player/repeatableflakytest/RepeatableFlakyTestRule.kt create mode 100644 player/testutil/src/main/resources/api-responses/errors/401_11003.json create mode 100644 player/testutil/src/main/resources/api-responses/errors/401_4005.json create mode 100644 player/testutil/src/main/resources/api-responses/errors/401_6005.json create mode 100644 player/testutil/src/main/resources/api-responses/errors/500_999.json create mode 100644 player/testutil/src/main/resources/api-responses/errors/empty.json create mode 100644 player/testutil/src/main/resources/api-responses/errors/malformed.json create mode 100644 player/testutil/src/main/resources/api-responses/errors/unsupported.json create mode 100644 renovate.json create mode 100644 settings.gradle.kts create mode 100644 static-analysis/config/detekt-cli-version.txt create mode 100644 static-analysis/config/detekt-rules.yml create mode 100644 static-analysis/config/ktlint-baseline.xml create mode 100644 static-analysis/config/ktlint-cli-version.txt create mode 100644 static-analysis/git-hooks/pre-commit create mode 100755 static-analysis/run-detekt.sh create mode 100755 static-analysis/run-ktlint.sh create mode 100644 template/README.md create mode 100644 template/apps/demo/build.gradle.kts create mode 100644 template/apps/demo/src/main/AndroidManifest.xml create mode 100644 template/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DemoApp.kt create mode 100644 template/apps/demo/src/main/kotlin/com/tidal/sdk/demo/MainActivity.kt create mode 100644 template/apps/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 template/apps/demo/src/main/res/drawable/ic_launcher_background.xml create mode 100644 template/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 template/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 template/apps/demo/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 template/apps/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 template/apps/demo/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 template/apps/demo/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 template/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 template/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 template/apps/demo/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 template/apps/demo/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 template/apps/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 template/apps/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 template/apps/demo/src/main/res/values/colors.xml create mode 100644 template/apps/demo/src/main/res/values/strings.xml create mode 100644 template/apps/demo/src/main/res/xml/backup_rules.xml create mode 100644 template/apps/demo/src/main/res/xml/data_extraction_rules.xml create mode 100644 template/build.gradle.kts create mode 100644 template/consumer-rules.pro create mode 100644 template/gradle.properties create mode 100644 template/src/androidTest/kotlin/com/tidal/template/ExampleInstrumentedTest.kt create mode 100644 template/src/main/AndroidManifest.xml create mode 100644 template/src/main/kotlin/com/tidal/sdk/template/Template.kt create mode 100644 template/src/test/kotlin/com/tidal/template/ExampleUnitTest.kt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..664561df --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +root=true + +[*.{kt,kts}] +end_of_line=lf +insert_final_newline=true +charset=utf-8 +trim_trailing_whitespace=true +max_line_length=100 + +ij_continuation_indent_size=4 +ij_kotlin_keep_blank_lines_before_right_brace=0 +ij_kotlin_keep_blank_lines_in_code=1 +ij_kotlin_keep_blank_lines_in_declarations=1 +ij_kotlin_name_count_to_use_star_import=10000 +ij_kotlin_name_count_to_use_star_import_for_members=10000 +ij_kotlin_allow_trailing_comma_on_call_site=true +ij_kotlin_allow_trailing_comma=true + +ktlint_code_style=android_studio +ktlint_standard_trailing-comma-on-call-site=disabled +ktlint_standard_function-naming=disabled +ktlint_standard_function-signature=disabled +ktlint_function_signature_wrapping_rule_always_with_minimum_parameters=3 + +[*Test.kt] +ktlint_standard_max-line-length=disabled + diff --git a/.fossa.yml b/.fossa.yml new file mode 100644 index 00000000..544f8f27 --- /dev/null +++ b/.fossa.yml @@ -0,0 +1,6 @@ +version: 3 + +targets: + only: + - type: gradle + path: . diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..78c09141 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +/player/ @tidal-music/pace-android diff --git a/.github/scripts/CheckChangelogs.main.kts b/.github/scripts/CheckChangelogs.main.kts new file mode 100644 index 00000000..12161d9c --- /dev/null +++ b/.github/scripts/CheckChangelogs.main.kts @@ -0,0 +1,27 @@ +#!/usr/bin/env kotlin + +@file:DependsOn("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0") + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import java.io.File + +data class Module(val name: String, val version: String) + +val modulesWithVersionsJson = args[0] + +val modules: List = jacksonObjectMapper().readValue(modulesWithVersionsJson) + +for (module in modules) { + val moduleName = module.name + val version = module.version + val pattern = Regex("## \\[$version\\]") + val changelogContent = File("./$moduleName/CHANGELOG.md").readText() + if (pattern.containsMatchIn(changelogContent)) { + println("✅ Version string $version found in CHANGELOG.md for module $moduleName") + } else { + println("⛔️ String $version not found in CHANGELOG.md for module $moduleName") + System.exit(1) + } +} + diff --git a/.github/workflows/check-changelog-files.yml b/.github/workflows/check-changelog-files.yml new file mode 100644 index 00000000..739b3e5c --- /dev/null +++ b/.github/workflows/check-changelog-files.yml @@ -0,0 +1,56 @@ +name: Check version bump and changelog files + +on: + workflow_call + +jobs: + check-version-bump: + runs-on: ubuntu-latest + name: Verify whether any updates were made to the modules' versions, and if they were, examine the changelog files for the respective modules + steps: + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + - run: mkdir workspace && cd workspace + - uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + - uses: gradle/wrapper-validation-action@b231772637bb498f11fdbc86052b6e8a8dc9fc92 + - name: Get base versions + run: | + for module in $(./gradlew -q --console=plain printSdkModules); do + ./gradlew $module:properties | grep "^version: " | awk '{print $2}' > ../$module.base.version + done + - uses: actions/checkout@v4 + with: + ref: ${{ env.GITHUB_REF }} + - name: Get HEAD versions + run: | + for module in $(./gradlew -q --console=plain printSdkModules); do + ./gradlew $module:properties | grep "^version: " | awk '{print $2}' > ../$module.head.version + done + - name: Get modules which version has been increased comparing to the target branch + id: get-modules-with-increased-version + run: | + set +e + module_list=() + for module in $(./gradlew -q --console=plain printSdkModules); do + VERSION_FILE_HEAD=../$module.head.version + if [ ! -f "$VERSION_FILE_HEAD" ]; then + continue + fi + if cmp -s ../$module.base.version $VERSION_FILE_HEAD; then + continue + fi + head_version=$(cat $VERSION_FILE_HEAD) + module_list+=("{\"name\": \"$module\", \"version\": \"$head_version\"}") + done + + jsonString="$(jq --compact-output --null-input '$ARGS.positional' --args -- "${module_list[@]}")" + echo "modules=$jsonString" >> $GITHUB_OUTPUT + + - name: Check changelog files + if: ${{ steps.get-modules-with-increased-version.outputs.modules != '' }} + run: | + kotlinc -script .github/scripts/CheckChangelogs.main.kts ${{ steps.get-modules-with-increased-version.outputs.modules}} \ No newline at end of file diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 00000000..f27ce6da --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,59 @@ +name: Create release + +on: + workflow_call: + inputs: + module: + required: true + type: string + secrets: + pat: + required: true + +jobs: + expose-tag: + name: Expose Tag + runs-on: ubuntu-latest + env: + ORG_GRADLE_PROJECT_githubToken: ${{ secrets.pat }} + outputs: + module-tag: ${{ steps.expose-tag.outputs.tag }} + module-version: ${{ steps.expose-tag.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + - name: Expose tag and version + id: expose-tag + run: | + version="$(./gradlew ${{ inputs.module }}:properties | grep "^version: " | awk '{print $2}')" + tag="${{ inputs.module }}-$version" + echo "version=$version" >> $GITHUB_OUTPUT + echo "tag=$tag" >> $GITHUB_OUTPUT + + create-release: + runs-on: ubuntu-latest + needs: [ expose-tag ] + permissions: + contents: write + env: + name: ${{ inputs.module}} + version: ${{ needs.expose-tag.outputs.module-version}} + tag: ${{ needs.expose-tag.outputs.module-tag}} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + + - name: Generate Release Draft + id: generate-release + uses: ncipollo/release-action@v1 + with: + tag: ${{ env.tag }} + allowUpdates: true + draft: true + body: "This is a release draft for ${{ env.tag }}" diff --git a/.github/workflows/fossa-scan.yml b/.github/workflows/fossa-scan.yml new file mode 100644 index 00000000..185a3632 --- /dev/null +++ b/.github/workflows/fossa-scan.yml @@ -0,0 +1,33 @@ +name: FOSSA Scans + +on: + workflow_call: + secrets: + pat: + required: true + fossaApiKey: + required: true +env: + ORG_GRADLE_PROJECT_githubToken: ${{ secrets.pat }} + +jobs: + fossa-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: gradle/gradle-build-action@v3 + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + + - name: "Run FOSSA Scan" + uses: fossas/fossa-action@v1.3.3 + with: + api-key: ${{secrets.fossaApiKey}} + + - name: "Run FOSSA Tests" + uses: fossas/fossa-action@v1.3.3 + with: + api-key: ${{secrets.fossaApiKey}} + run-tests: true diff --git a/.github/workflows/instrumented-test.yml b/.github/workflows/instrumented-test.yml new file mode 100644 index 00000000..f3270054 --- /dev/null +++ b/.github/workflows/instrumented-test.yml @@ -0,0 +1,31 @@ +name: Instrumented Tests + +on: + workflow_call: + secrets: + pat: + required: true + +jobs: + run-instrumented-tests: + runs-on: ubuntu-latest + env: + ORG_GRADLE_PROJECT_githubToken: ${{ secrets.pat }} + name: Run instrumented tests + steps: + - uses: actions/checkout@v4 + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@b231772637bb498f11fdbc86052b6e8a8dc9fc92 + - uses: reactivecircus/android-emulator-runner@v2.30.1 + with: + api-level: 29 + script: ./gradlew -Dorg.gradle.logging.level=quiet connectedCheck diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..eca38054 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,51 @@ +name: Run lint checks + +on: + workflow_call: + secrets: + pat: + required: true + +env: + ORG_GRADLE_PROJECT_githubToken: ${{ secrets.pat }} + +jobs: + run-ktlint: + runs-on: ubuntu-latest + name: Run ktlint + steps: + - uses: actions/checkout@v4 + - name: Run ktlint + run: static-analysis/run-ktlint.sh + + run-detekt: + runs-on: ubuntu-latest + name: Run detekt + steps: + - uses: actions/checkout@v4 + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + + - name: Run detekt + run: static-analysis/run-detekt.sh + + run-lint: + runs-on: ubuntu-latest + name: Run lint + steps: + - uses: actions/checkout@v4 + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + + - name: Run lint + run: ./gradlew lint + + - name: Print lint report(s) + if: ${{ failure() }} + run: cat **/build/reports/lint-results-*.xml diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml new file mode 100644 index 00000000..c289b86a --- /dev/null +++ b/.github/workflows/post-merge.yml @@ -0,0 +1,73 @@ +name: Post-Merge +on: + push: + branches: 'main' + +concurrency: + group: ${{ github.workflow_ref }}-${{ github.ref }} + +jobs: + + build-module-list: + runs-on: ubuntu-latest + name: Generate candidate SDK module list + outputs: + modules: ${{ steps.build-matrix.outputs.modules}} + env: + REF_BEFORE: ${{ github.event.before }} + ORG_GRADLE_PROJECT_githubToken: ${{ secrets.pat }} + steps: + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + - run: mkdir workspace && cd workspace + - uses: actions/checkout@v4 + with: + ref: ${{ env.REF_BEFORE }} + - uses: gradle/wrapper-validation-action@b231772637bb498f11fdbc86052b6e8a8dc9fc92 + - name: Get base versions + run: | + for module in $(./gradlew -q --console=plain printSdkModules); do + ./gradlew $module:properties | grep "^version: " | awk '{print $2}' > ../$module.base.version + done + - uses: actions/checkout@v4 + with: + ref: ${{ env.GITHUB_REF }} + - name: Get HEAD versions + run: | + for module in $(./gradlew -q --console=plain printSdkModules); do + ./gradlew $module:properties | grep "^version: " | awk '{print $2}' > ../$module.head.version + done + - name: Build matrix input + id: build-matrix + run: | + set +e + module_list=() + for module in $(./gradlew -q --console=plain printSdkModules); do + VERSION_FILE_HEAD=../$module.head.version + if [ ! -f "$VERSION_FILE_HEAD" ]; then + continue + fi + if cmp -s ../$module.base.version $VERSION_FILE_HEAD; then + continue + fi + module_list+=("$module") + done + + jsonString="$(jq --compact-output --null-input '$ARGS.positional' --args -- "${module_list[@]}")" + echo "modules=$jsonString" >> $GITHUB_OUTPUT + + trigger-releases: + name: Trigger releases + needs: [ build-module-list ] + if: ${{ needs.build-module-list.outputs.modules != '' && toJson(fromJson(needs.build-module-list.outputs.modules)) != '[]' }} + permissions: write-all + strategy: + matrix: + module: ${{ fromJSON(needs.build-module-list.outputs.modules) }} + uses: ./.github/workflows/create-release.yml + with: + module: ${{ matrix.module }} + secrets: + pat: ${{ secrets.PACKAGES_PAT }} diff --git a/.github/workflows/publish-pages.yml b/.github/workflows/publish-pages.yml new file mode 100644 index 00000000..da52db1d --- /dev/null +++ b/.github/workflows/publish-pages.yml @@ -0,0 +1,55 @@ +name: Generate and publish documentation + +on: + workflow_call + +concurrency: + group: pages + +jobs: + generate-and-publish: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + env: + ORG_GRADLE_PROJECT_githubToken: ${{ secrets.PACKAGES_PAT }} + PATH_CLONE_WORK: ${{ github.workspace }}/${{ github.repository }} + PATH_CLONE_SUPPORT: ${{ github.workspace }}/support + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ env.PATH_CLONE_WORK }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + path: ${{ env.PATH_CLONE_WORK }} + - name: Copy clone to support path + run: rsync -a $PATH_CLONE_WORK/ $PATH_CLONE_SUPPORT + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + - run: ./gradlew bom:generateMetadataFileForMavenPublication + - name: Extract module versions from BOM + run: | + for module in $(./gradlew -q --console=plain printSdkModules); do + if [ "$module" = "bom" ]; then + continue + fi + echo "$module=$module-$(.scripts/ci/extract_module_version_from_bom_module_json.sh -b bom/build/publications/maven/module.json -m "$module")" >> $GITHUB_WORKSPACE/bom-versions + done + - name: Check out version from the BOM for each module + run: | + while IFS= read -r <&3 line + do + .scripts/ci/checkout_module_version.sh -m $(echo $line | cut -d= -f1) -v $(echo $line | cut -d= -f2) -s $PATH_CLONE_SUPPORT + done 3< $GITHUB_WORKSPACE/bom-versions + - run: ./gradlew :dokkaHtmlMultiModule + - uses: actions/upload-pages-artifact@v3 + with: + path: ${{ env.PATH_CLONE_WORK}}/build/dokka/htmlMultiModule + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 00000000..57f7b5fb --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,52 @@ +name: Publish release + +on: + release: + types: [published] + +env: + ORG_GRADLE_PROJECT_githubToken: ${{ secrets.PACKAGES_PAT }} + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + - uses: gradle/actions/setup-gradle@v3 + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@b231772637bb498f11fdbc86052b6e8a8dc9fc92 + - name: Get module name + id: get-name + run: | + ref="${{ github.ref}}" + name="$( echo ${ref##*/} | cut -d- -f1 )" + echo "name=$name" >> $GITHUB_OUTPUT + - name: Publish package + run: | + ./gradlew publish-sdk-module-${{ env.NAME }} --continue + env: + NAME: ${{ steps.get-name.outputs.name }} + GITHUB_USER: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USER }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.GPG_SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SIGNING_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SIGNING_IN_MEMORY_KEY }} + + deploy-pages: + name: Trigger GH Pages deployment + if: startsWith(github.ref, 'refs/tags/bom-') + permissions: + contents: read # TODO remove when the repository goes public + pages: write + id-token: write + uses: ./.github/workflows/publish-pages.yml + secrets: inherit diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 00000000..441fe57e --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,36 @@ +name: Pull Request + +on: + pull_request: + merge_group: + types: [checks_requested] + +jobs: + trigger-lint: + name: Trigger linters + uses: ./.github/workflows/lint.yml + secrets: + pat: ${{ secrets.PACKAGES_PAT }} + + trigger-unit-test: + name: Trigger Unit Tests + uses: ./.github/workflows/unit-test.yml + secrets: + pat: ${{ secrets.PACKAGES_PAT }} + + trigger-instrumented-test: + name: Trigger Instrumented Tests + uses: ./.github/workflows/instrumented-test.yml + secrets: + pat: ${{ secrets.PACKAGES_PAT }} + + trigger-fossa-scan: + name: Trigger FOSSA Scan + uses: ./.github/workflows/fossa-scan.yml + secrets: + pat: ${{ secrets.PACKAGES_PAT }} + fossaApiKey: ${{ secrets.FOSSAAPIKEY }} + + trigger-changelogs-check: + name: Trigger changelog files check + uses: ./.github/workflows/check-changelog-files.yml diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 00000000..1566f6d3 --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,22 @@ +name: Run unit tests +on: + workflow_call: + secrets: + pat: + required: true + +env: + ORG_GRADLE_PROJECT_githubToken: ${{ secrets.pat }} + +jobs: + run-tests: + runs-on: ubuntu-latest + name: Run unit tests + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + - name: Run unit tests + run: ./gradlew test diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1ea95ede --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.DS_Store +**/.idea +/projectFilesBackup +.gradle +.DS_Store +*.iml +*.ipr +*.iws +**/version.json +**/build/ +**/local.properties +gradle-user-home +profile-out*/ +local.settings.gradle.kts +static-analysis/bin diff --git a/.scripts/ci/checkout_module_version.sh b/.scripts/ci/checkout_module_version.sh new file mode 100755 index 00000000..4783f896 --- /dev/null +++ b/.scripts/ci/checkout_module_version.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -eu + +print_usage() +{ + echo "Usage: $0 -m -v -s " +} + +while getopts ":m:v:s:" OPT; do + case $OPT in + m) MODULE_NAME="$OPTARG" + ;; + v) VERSION_REF="$OPTARG" + ;; + s) PATH_CLONE_SUPPORT="$OPTARG" + ;; + ?) print_usage + exit 1 + ;; + esac +done +if [ -z "${MODULE_NAME+x}" ]; then + print_usage + exit 1 +fi +if [ -z "${VERSION_REF+x}" ]; then + print_usage + exit 1 +fi +if [ -z "${PATH_CLONE_SUPPORT+x}" ]; then + print_usage + exit 1 +fi + +WORKDIR=$(pwd) + +rm -rf "$(./gradlew "$MODULE_NAME":properties | grep "^projectDir: " | awk '{print $2}')" +cd "$PATH_CLONE_SUPPORT" +git checkout "$VERSION_REF" +rsync -a "$(./gradlew "$MODULE_NAME":properties | grep "^projectDir: " | awk '{print $2}')" "$WORKDIR" +cd "$WORKDIR" diff --git a/.scripts/ci/extract_module_version_from_bom_module_json.sh b/.scripts/ci/extract_module_version_from_bom_module_json.sh new file mode 100755 index 00000000..1d9263b4 --- /dev/null +++ b/.scripts/ci/extract_module_version_from_bom_module_json.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -eu + +print_usage() +{ + echo "Usage: $0 -b -m " +} + +while getopts ":b:m:" OPT; do + case $OPT in + b) BOM_MODULE_FILE="$OPTARG" + ;; + m) MODULE_NAME="$OPTARG" + ;; + ?) print_usage + exit 1 + ;; + esac +done +if [ -z "${BOM_MODULE_FILE+x}" ]; then + print_usage + exit 1 +fi +if [ -z "${MODULE_NAME+x}" ]; then + print_usage + exit 1 +fi + +./gradlew "$MODULE_NAME":generateMetadataFileForMavenPublication > /dev/null 2>&1 + +MODULE_FILE=$(./gradlew "$MODULE_NAME":properties | grep "^buildDir: " | awk '{print $2}')/publications/maven/module.json +MODULE_GROUP=$(jq -r ".component.group" "$MODULE_FILE") +MODULE_ARTIFACT=$(jq -r ".component.module" "$MODULE_FILE") + +jq \ +--arg MODULE_GROUP "$MODULE_GROUP" \ +--arg MODULE_ARTIFACT "$MODULE_ARTIFACT" \ +-r \ +'.variants[0] | .dependencyConstraints[] | select(.group == $MODULE_GROUP) | select(.module == $MODULE_ARTIFACT) | .version.requires' "$BOM_MODULE_FILE" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..c83043d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Block, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 00000000..e6221732 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# TIDAL Android SDK + +This is the repository for TIDAL Android SDK modules. + +## Available modules + +- [auth](./auth/README.md) +- [common](./common/README.md) +- [eventproducer](./eventproducer/README.md) +- [player](./player/README.md) + +## Working in this repository + +### First time setup +It is strongly recommended to run `local-setup.sh` right after cloning the repository. This will install the pre-commit git hook to run lint checks for your code. CI will also run these checks, but it's best to prevent CI failures by running the checks locally. +### Creating a new module +1. Run the `generate-module.sh` script. It will prompt you to enter a module name using [PascalCase](https://pl.wikipedia.org/wiki/PascalCase). +After confirming the name, a new directory will be created with the basic module setup. +2. Commit the generated code and create a pull request. +3. After that pull request is merged, start working on your module. + +### Creating a module release +1. Bump your module's version to the desired value in your module's `gradle.properties` file. You'll find an entry looking like this: + ``` + # Current Version + version=1.0.0 + ``` + Change `version` to the new value. This follows [Semantic Versioning](https://semver.org/). Also, you cannot downgrade - the CI/CD pipeline will refuse to work with downgrades. + +2. Open a Pull Request with your version bump, get it approved and merge it. A release draft will be created for the module you changed. + +3. Find your draft in the [releases list](https://github.com/tidal-music/tidal-sdk-android/releases) and add some meaningful sentences about the release, changelog style (Note: This paragraph is temporary, as we will automate and regulate changelog creation, but for now, you are free to type) + +4. Check in with your teammates, lead, the module's owner etc. to make sure the release is ready to go. + +5. Click `Publish` at the bottom of your draft release. This will trigger a workflow to publish a package of the new version + +6. Find your newly published package [here](https://github.com/orgs/tidal-music/packages?repo_name=tidal-sdk-android). diff --git a/auth/CHANGELOG.md b/auth/CHANGELOG.md new file mode 100644 index 00000000..0d03c7d8 --- /dev/null +++ b/auth/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.9.0] - 2024-05-14 +### Changed +- Improve tidalAuthServiceBaseUrl and tidalLoginServiceBaseUrl usage in AuthConfig + +## [0.8.0] - 2024-05-07 +### Added +- Internal modifier to required classes +- Return values to both login finalisation functions +### Removed +- Traces of scope strings + +## [0.7.0] - 2024-04-30 +### Removed +- Regex validation of scopes +- Depreciate Scope + +## [0.6.0] - 2024-04-29 +### Fixed +- Several small issues +### Added +- Add documentation comments +### Changed +- Check for ApiErrorSubStatus.value diff --git a/auth/README.md b/auth/README.md new file mode 100644 index 00000000..43893121 --- /dev/null +++ b/auth/README.md @@ -0,0 +1,98 @@ +# Module auth + +Auth module handles Authentication and Authorization when interacting with the TIDAL API or other TIDAL SDK modules. +It provides easy to use authentication to interact with TIDAL's oAuth2 server endpoints. + +## Features +* User Login and Sign up handling (through login.tidal.com) +* Automatic session refresh (refreshing oAuthTokens) +* Secure and encrypted storage of your tokens + +## Documentation +* Read the [documentation](https://github.com/tidal-music/tidal-sdk/blob/main/Auth.md) for a detailed overview of the auth functionality. +* Check the [API documentation](https://tidal-music.github.io/tidal-sdk-android/auth/index.html) for the module classes and methods. +* Visit our [TIDAL Developer Platform](https://developer.tidal.com/) for more information and getting started. + +## Usage + +### Installation +Add the dependency to your `build.gradle.kts` file. +```kotlin +dependencies { + implementation("com.tidal.sdk:auth:") +} +``` + +### Client Credentials + +This authentication method uses `clientId` and `clientSecret`, e.g. when utilizing the [TIDAL API](https://developer.tidal.com/documentation/api/api-overview). Follow these steps in order to get an oAuth token. + +1. Initiate the process by initialising [TidalAuth](. + /auth/src/main/kotlin/com/tidal/sdk/auth/TidalAuth. + kt) by providing an [AuthConfig](./auth/src/main/kotlin/com/tidal/sdk/auth/model/AuthConfig.kt) with `clientId` and `clientSecret`. +```kotlin + val authConfig = AuthConfig( + clientId = "YOUR_CLIENT_ID", + clientSecret = "YOUR_CLIENT_SECRET", + credentialsKey = "storage", + enableCertificatePinning = true, + logLevel = NetworkLogLevel.BODY, + ) + + val tidalAuth = TidalAuth.getInstance(authConfig, context) +``` +2. Get access to the [CredentialsProvider](./auth/src/main/kotlin/com/tidal/sdk/auth/CredentialsProvider.kt) which is responsible for getting [Credentials](./auth/src/main/kotlin/com/tidal/sdk/auth/model/Credentials.kt) and any updates sent through a message bus. +```kotlin + val credentialsProvider = tidalAuth.credentialsProvider +``` + +3. Obtain credentials by calling `credentialsProvider.getCredentials`, which when successfully executed, returns credentials containing a `token`. +```kotlin + suspend fun getTidalToken(): String? { + val result = credentialsProvider.getCredentials() + return result.successData?.token + } +``` + +4. Make API calls to your desired endpoint and include `Authentication: Bearer YOUR_TOKEN` as a header. +5. _(Optional)_ Listen to credentials update messages. +```kotlin + suspend fun logCredentialsUpdates() { + credentialsProvider.bus.collectLatest { + Log.d(TAG, "message=$it") + } + } +``` + + +### Authorization Code Flow (user login) +(Only available for TIDAL internally developed applications for now) + +To implement the login redirect flow, follow these steps or refer to our [Demo app](https://github.com/tidal-music/tidal-sdk-android/tree/main/auth/apps/demo) implementation. + +1. Initiate the process by initialising [Auth](./auth/src/main/kotlin/com/tidal/sdk/auth/TidalAuth. + kt). +2. For the first login: + * Acquire `Auth` from your `TidalAuth` instance. Call its `initializeLogin` function, which + returns the login URL. Open this URL in a webview, where the user can log in using their username/password. + * A successful login will return a `RedirectUri`. + * After redirection to your app, follow up with a call to `finalizeLogin`, passing in the returned `RedirectUri`. + * Once logged in, you can use `credentialsProvider.getCredentials` to obtain `Credentials` for activities like API calls. +3. For subsequent logins, when the user returns to your app, simply call `credentialsProvider.getCredentials`. This is sufficient unless the user actively logs out or a token is revoked (e.g., due to a password change). + +> ⚠️ Ensure to invoke `credentialsProvider.getCredentials` each time you need a token and avoid storing it. This approach enables the SDK to manage timeouts, upgrades, or automatic retries seamlessly. + +### Device Login +(Only available for TIDAL internally developed applications for now) + +For devices with limited input capabilities, such as TVs, an alternative login method is provided. Follow these steps or refer to our [Demo app](https://github.com/tidal-music/tidal-sdk-android/tree/main/auth/apps/demo) implementation. + +1. Initiate the process by initialising [TidalAuth](. + /auth/src/main/kotlin/com/tidal/sdk/auth/TidalAuth.kt). +2. Use `initializeDeviceLogin` and await the response. +3. The response will contain a `userCode` and a `verificationUri`; display these to the user. +4. Instruct the user to visit `link.tidal.com`, log in, and enter the displayed code. +5. Subsequently, call `finalizeDeviceLogin`, which will continually poll the backend until the user successfully enters the code. Upon a successful promise return, you are ready to proceed. +6. Retrieve a token by calling `.credentialsProvider.getCredentials`. + +> 💡 Many modern apps feature a QR-Code for scanning, which you can also generate. Ensure it includes `verificationUriComplete`, as provided in the response. diff --git a/auth/apps/demo/build.gradle.kts b/auth/apps/demo/build.gradle.kts new file mode 100644 index 00000000..8d3df795 --- /dev/null +++ b/auth/apps/demo/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.tidal.android.application) +} + +android { + namespace = "com.tidal.sdk.auth.demo" + + defaultConfig { + applicationId = "com.tidal.sdk.auth.demo" + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + debug {} + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + } + packagingOptions { + resources.excludes.apply { + add("META-INF/LICENSE.md") + add("META-INF/LICENSE-notice.md") + } + } +} + +dependencies { + implementation(project(":auth")) + + implementation(libs.bundles.compose) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime) + + implementation("org.slf4j:slf4j-api:2.0.7") + implementation("com.github.tony19:logback-android:3.0.0") +} diff --git a/auth/apps/demo/src/main/AndroidManifest.xml b/auth/apps/demo/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0c39f73f --- /dev/null +++ b/auth/apps/demo/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/auth/apps/demo/src/main/assets/logback.xml b/auth/apps/demo/src/main/assets/logback.xml new file mode 100644 index 00000000..b1ee0bb0 --- /dev/null +++ b/auth/apps/demo/src/main/assets/logback.xml @@ -0,0 +1,18 @@ + + + + %logger{12} + + + [%thread] %-5level %logger{36}: %msg%n + + + + + + + \ No newline at end of file diff --git a/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DemoApp.kt b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DemoApp.kt new file mode 100644 index 00000000..e7594656 --- /dev/null +++ b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DemoApp.kt @@ -0,0 +1,5 @@ +package com.tidal.sdk.demo + +import android.app.Application + +class DemoApp : Application() diff --git a/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DeviceLoginScreen.kt b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DeviceLoginScreen.kt new file mode 100644 index 00000000..eb4ba2ee --- /dev/null +++ b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DeviceLoginScreen.kt @@ -0,0 +1,92 @@ +package com.tidal.sdk.demo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.tidal.sdk.auth.model.DeviceAuthorizationResponse +import com.tidal.sdk.common.d +import com.tidal.sdk.common.getLoggerByName +import kotlinx.coroutines.launch + +private sealed class State { + data object Init : State() + class Link(val response: DeviceAuthorizationResponse) : State() + data object Done : State() +} + +@Composable +fun DeviceLoginScreen() { + val activity = LocalContext.current.findActivity() as MainActivity + val scope = rememberCoroutineScope() + val state = remember { mutableStateOf(State.Init) } + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + when (state.value) { + is State.Init -> { + InitUI { + scope.launch { + activity.initializeDeviceLogin().successData?.let { + state.value = State.Link(it) + } + ?: getLoggerByName( + "DeviceLoginScreen" + ).d { "error initiating device login" } + } + } + } + + is State.Link -> { + with((state.value as State.Link).response) { + LinkUI(this.verificationUri, this.userCode) + val deviceCode = this.deviceCode + scope.launch { + activity.finalizeDeviceLogin(deviceCode) + } + } + } + + is State.Done -> { + } + } + } +} + +@Composable +fun InitUI(onClick: () -> Unit) { + Column { + Button( + onClick = onClick, + ) { + Text(text = "Start Device Login") + } + } +} + +@Composable +fun LinkUI(uri: String, code: String) { + Column { + Text( + text = "$code", + color = Color.White, + ) + Text( + text = "$uri", + color = Color.White, + ) + } +} diff --git a/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/LoginScreen.kt b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/LoginScreen.kt new file mode 100644 index 00000000..796caca9 --- /dev/null +++ b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/LoginScreen.kt @@ -0,0 +1,35 @@ +package com.tidal.sdk.demo + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.tidal.sdk.demo.MainActivity.Companion.LOGIN_URI +import com.tidal.sdk.demo.webview.ComposeWebView + +@Composable +fun LoginScreen() { + val activity = LocalContext.current.findActivity() as MainActivity + + @Composable + fun getLoginUri(): Uri { + val context = LocalContext.current + val activity = (context.findActivity() as MainActivity) + return activity.auth.initializeLogin( + LOGIN_URI, + activity.loginConfig, + ) + } + + val url = getLoginUri().toString() + + Box( + modifier = Modifier.fillMaxSize().background(Color.Black), + ) { + ComposeWebView(activity = activity, url = url) + } +} diff --git a/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/MainActivity.kt b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/MainActivity.kt new file mode 100644 index 00000000..b2875400 --- /dev/null +++ b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/MainActivity.kt @@ -0,0 +1,142 @@ +package com.tidal.sdk.demo + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.tidal.sdk.auth.Auth +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.auth.TidalAuth +import com.tidal.sdk.auth.demo.BuildConfig +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.DeviceAuthorizationResponse +import com.tidal.sdk.auth.model.LoginConfig +import com.tidal.sdk.auth.model.QueryParameter +import com.tidal.sdk.auth.network.NetworkLogLevel +import com.tidal.sdk.common.d +import com.tidal.sdk.common.logger +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + + private lateinit var navController: NavController + + lateinit var auth: Auth + lateinit var credentialsProvider: CredentialsProvider + + val loginConfig = LoginConfig( + customParams = setOf( + QueryParameter( + key = "appMode", + value = "android", + ), + ), // Client has to inform the module about the appMode + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val authConfig = AuthConfig( + // Make sure to provide your clientId and required credentials in local.properties + clientId = BuildConfig.TIDAL_CLIENT_ID, + clientSecret = BuildConfig.TIDAL_CLIENT_SECRET, + credentialsKey = STORAGE_KEY, + scopes = setOf("YOUR_SCOPES"), + enableCertificatePinning = true, + logLevel = NetworkLogLevel.BODY, + ) + initAuthModule(authConfig) + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colors.background, + ) { + val navController = rememberNavController().also { + this@MainActivity.navController = it + } + NavigationHost(navController = navController) + } + } + } + } + + private fun initAuthModule(authConfig: AuthConfig) { + with( + TidalAuth.getInstance( + authConfig, + this, + ), + ) { + this@MainActivity.auth = this.auth + this@MainActivity.credentialsProvider = this.credentialsProvider + + // This watches the bus and prints all received messages to our log + lifecycleScope.launch { + credentialsProvider.bus.collectLatest { + logger.d { it.toString() } + } + } + lifecycleScope.launch { + val token = credentialsProvider.getCredentials() + logger.d { "token: $token" } + } + } + } + + @Suppress("UnusedPrivateMember") + fun onRedirectUriReceived(uri: Uri) { + lifecycleScope.launch { + auth.finalizeLogin(uri.toString()) + }.invokeOnCompletion { + if (navController.currentBackStackEntry.toString().contains("login")) { + navController.popBackStack() + } + } + } + + suspend fun initializeDeviceLogin(): AuthResult { + return auth.initializeDeviceLogin() + } + + suspend fun finalizeDeviceLogin(deviceCode: String) { + with(auth.finalizeDeviceLogin(deviceCode)) { + if (this.isSuccess) { + navController.popBackStack() + } + } + } + + fun logout() { + lifecycleScope.launch { + auth.logout() + } + } + + companion object { + + private const val STORAGE_KEY = "storage" + const val LOGIN_URI = "https://tidal.com/android/login/auth" + } +} + +internal fun Context.findActivity(): Activity { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + error("Permissions should be called in the context of an Activity") +} diff --git a/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/NavigationHost.kt b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/NavigationHost.kt new file mode 100644 index 00000000..cd2db909 --- /dev/null +++ b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/NavigationHost.kt @@ -0,0 +1,31 @@ +package com.tidal.sdk.demo + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController + +@Composable +fun NavigationHost( + modifier: Modifier = Modifier, + navController: NavHostController = rememberNavController(), + startDestination: String = "start", +) { + NavHost( + modifier = modifier, + navController = navController, + startDestination = startDestination, + ) { + composable("start") { + StartScreen(navController) + } + composable("login") { + LoginScreen() + } + composable("deviceLogin") { + DeviceLoginScreen() + } + } +} diff --git a/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/StartScreen.kt b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/StartScreen.kt new file mode 100644 index 00000000..a08a6cfc --- /dev/null +++ b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/StartScreen.kt @@ -0,0 +1,76 @@ +package com.tidal.sdk.demo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavController + +@Composable +fun StartScreen(navController: NavController) { + val activity = LocalContext.current.findActivity() as MainActivity + val isLoggedIn = remember { mutableStateOf(activity.credentialsProvider.isUserLoggedIn()) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + if (isLoggedIn.value) { + LoggedInUI { + activity.logout() + isLoggedIn.value = activity.credentialsProvider.isUserLoggedIn() + } + } else { + NotLoggedInUI( + { navController.navigate("login") }, + { navController.navigate("deviceLogin") } + ) + } + } +} + +@Composable +private fun LoggedInUI(onClick: () -> Unit) { + Column { + Text( + text = "You are logged in", + color = Color.White, + ) + Button( + onClick = onClick, + ) { + Text(text = "Log out") + } + } +} + +@Composable +private fun NotLoggedInUI(onLoginClicked: () -> Unit, onDeviceLoginClicked: () -> Unit) { + Column { + Text( + text = "You are not logged in", + color = Color.White, + ) + Button( + onClick = onLoginClicked, + ) { + Text(text = "Open Login Screen") + } + Button( + onClick = onDeviceLoginClicked, + ) { + Text(text = "Device Login") + } + } +} diff --git a/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ComposeWebView.kt b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ComposeWebView.kt new file mode 100644 index 00000000..878e1f54 --- /dev/null +++ b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ComposeWebView.kt @@ -0,0 +1,35 @@ +package com.tidal.sdk.demo.webview + +import android.graphics.Color +import android.webkit.WebSettings +import android.webkit.WebView +import androidx.compose.runtime.Composable +import androidx.compose.ui.viewinterop.AndroidView +import com.tidal.sdk.demo.MainActivity + +@Composable +fun ComposeWebView(activity: MainActivity, url: String) { + val javaScriptInterface = JavaScriptInterface() + val extendedWebViewClient = + ExtendedWebClient { activity.onRedirectUriReceived(it) } + + AndroidView( + factory = { context -> + WebView.setWebContentsDebuggingEnabled(true) + WebView(context).apply { + setBackgroundColor(Color.TRANSPARENT) + settings.apply { + loadWithOverviewMode = true + useWideViewPort = true + javaScriptEnabled = true + cacheMode = WebSettings.LOAD_NO_CACHE + } + + webChromeClient = ExtendedChromeClient() + webViewClient = extendedWebViewClient + addJavascriptInterface(javaScriptInterface, "javascriptObject") + loadUrl(url) + } + }, + ) +} diff --git a/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ExtendedChromeClient.kt b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ExtendedChromeClient.kt new file mode 100644 index 00000000..c9f36303 --- /dev/null +++ b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ExtendedChromeClient.kt @@ -0,0 +1,21 @@ +package com.tidal.sdk.demo.webview + +import android.net.Uri +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebView + +class ExtendedChromeClient : WebChromeClient() { + + override fun onShowFileChooser( + webView: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams?, + ): Boolean { + return if (filePathCallback != null && fileChooserParams != null) { + true + } else { + super.onShowFileChooser(webView, filePathCallback, fileChooserParams) + } + } +} diff --git a/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ExtendedWebClient.kt b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ExtendedWebClient.kt new file mode 100644 index 00000000..8b4a0b07 --- /dev/null +++ b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/ExtendedWebClient.kt @@ -0,0 +1,62 @@ +package com.tidal.sdk.demo.webview + +import android.annotation.TargetApi +import android.net.Uri +import android.os.Build +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import com.tidal.sdk.demo.MainActivity.Companion.LOGIN_URI + +class ExtendedWebClient( + private val onRedirectUriReceived: (Uri) -> Unit, +) : WebViewClient() { + + override fun onPageCommitVisible(view: WebView?, url: String?) { + super.onPageCommitVisible(view, url) + } + + @TargetApi(Build.VERSION_CODES.N) + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return hasRedirectUri(request.url) + } + + @SuppressWarnings("deprecation") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + val uri = Uri.parse(url) + return hasRedirectUri(uri) + } + + private fun hasRedirectUri(uri: Uri): Boolean { + return if (uri.toString().startsWith(LOGIN_URI)) { + onRedirectUriReceived(uri) + true + } else { + false + } + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + onReceivedError( + view, + error.errorCode, + error.description.toString(), + request.url.toString(), + ) + } + + @SuppressWarnings("deprecation") + override fun onReceivedError( + view: WebView, + errorCode: Int, + description: String, + failingUrl: String, + ) { + // TODO handle error + } +} diff --git a/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/JavaScriptInterface.kt b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/JavaScriptInterface.kt new file mode 100644 index 00000000..36a5c345 --- /dev/null +++ b/auth/apps/demo/src/main/kotlin/com/tidal/sdk/demo/webview/JavaScriptInterface.kt @@ -0,0 +1,32 @@ +package com.tidal.sdk.demo.webview + +import android.webkit.JavascriptInterface +import androidx.annotation.Keep + +@Suppress("EmptyFunctionBlock") +class JavaScriptInterface { + + @Keep + @Suppress("unused") + @JavascriptInterface + fun triggerFacebookSDKLogin() { + } + + @Keep + @Suppress("unused") + @JavascriptInterface + fun triggerTwitterSDKLogin() { + } + + @Keep + @Suppress("unused") + @JavascriptInterface + fun triggerResetPassword(url: String) { + } + + @Keep + @Suppress("unused") + @JavascriptInterface + fun openInExternalBrowser(url: String, closeWebView: Boolean) { + } +} diff --git a/auth/apps/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml b/auth/apps/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..da1dcd94 --- /dev/null +++ b/auth/apps/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/auth/apps/demo/src/main/res/drawable/ic_launcher_background.xml b/auth/apps/demo/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..ca3826a4 --- /dev/null +++ b/auth/apps/demo/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/auth/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/auth/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..bbd3e021 --- /dev/null +++ b/auth/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/auth/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/auth/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..bbd3e021 --- /dev/null +++ b/auth/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/auth/apps/demo/src/main/res/mipmap-hdpi/ic_launcher.webp b/auth/apps/demo/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/auth/apps/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/auth/apps/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/auth/apps/demo/src/main/res/mipmap-mdpi/ic_launcher.webp b/auth/apps/demo/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/auth/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp b/auth/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/auth/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/auth/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/auth/apps/demo/src/main/res/values/colors.xml b/auth/apps/demo/src/main/res/values/colors.xml new file mode 100644 index 00000000..59c82d82 --- /dev/null +++ b/auth/apps/demo/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/auth/apps/demo/src/main/res/values/strings.xml b/auth/apps/demo/src/main/res/values/strings.xml new file mode 100644 index 00000000..1357a649 --- /dev/null +++ b/auth/apps/demo/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Auth Demo + diff --git a/auth/apps/demo/src/main/res/xml/backup_rules.xml b/auth/apps/demo/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..926eedef --- /dev/null +++ b/auth/apps/demo/src/main/res/xml/backup_rules.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/auth/apps/demo/src/main/res/xml/data_extraction_rules.xml b/auth/apps/demo/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..a73ffe12 --- /dev/null +++ b/auth/apps/demo/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts new file mode 100644 index 00000000..02fde7d8 --- /dev/null +++ b/auth/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + alias(libs.plugins.tidal.android.library) + alias(libs.plugins.android.junit5) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.kotlin.plugin.serialization) +} + +android { + namespace = "com.tidal.sdk.auth" +} + +dependencies { + api(libs.tidal.sdk.common) + + implementation(libs.androidx.security.crypto) + implementation(libs.dagger) + implementation(libs.kotlinxCoroutinesAndroid) + implementation(libs.kotlinxCoroutinesCore) + implementation(libs.kotlinx.coroutines.test) + api(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.retrofit.converter) + implementation(libs.okhttp.loggingInterceptor) + implementation(libs.retrofit) + + ksp(libs.dagger.compiler) + + testImplementation(libs.test.junit5Api) + testImplementation(libs.test.mockk) + testImplementation(libs.test.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.test.fluidtime) + + testRuntimeOnly(libs.test.junit5Engine) + + kspTest(libs.dagger.compiler) + + testApi(libs.test.androidx.junit) + testApi(libs.test.junit5Api) + testApi(libs.test.junit5Engine) + testApi(libs.test.turbine) + + androidTestImplementation(libs.test.androidx.junit) + androidTestImplementation(libs.test.androidx.espresso.core) +} diff --git a/auth/consumer-rules.pro b/auth/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/auth/gradle.properties b/auth/gradle.properties new file mode 100644 index 00000000..e31ef903 --- /dev/null +++ b/auth/gradle.properties @@ -0,0 +1,2 @@ +projectDescription=The Auth module manages app authentication, authorization, and token handling, simplifying OAuth processes, ensuring secure user access, and offering robust error handling. +version=0.9.1 diff --git a/auth/src/androidTest/kotlin/com/tidal/auth/ExampleInstrumentedTest.kt b/auth/src/androidTest/kotlin/com/tidal/auth/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..d58bd08f --- /dev/null +++ b/auth/src/androidTest/kotlin/com/tidal/auth/ExampleInstrumentedTest.kt @@ -0,0 +1,20 @@ +package com.tidal.auth + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +internal class ExampleInstrumentedTest { + + @Test + fun comparePackageNames() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + val instrumentationContext = InstrumentationRegistry.getInstrumentation().context + assertEquals(instrumentationContext.packageName, appContext.packageName) + } +} diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/auth/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/Auth.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/Auth.kt new file mode 100644 index 00000000..7a4ae4d2 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/Auth.kt @@ -0,0 +1,99 @@ +package com.tidal.sdk.auth + +import android.net.Uri +import com.tidal.sdk.auth.login.LoginRepository +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.auth.model.DeviceAuthorizationResponse +import com.tidal.sdk.auth.model.LoginConfig +import com.tidal.sdk.auth.model.TokenResponseError +import com.tidal.sdk.auth.model.failure +import com.tidal.sdk.auth.model.success +import com.tidal.sdk.common.NetworkError +import com.tidal.sdk.common.d +import com.tidal.sdk.common.logger + +/** + * Main entry point for authentication and authorization operations. This class provides functions + * to initialize and finalize login processes, handle device logins, and manage user credentials. + */ +class Auth internal constructor( + private val loginRepository: LoginRepository, +) { + + /** + * Begins the login process by generating a URI to the login service. + * @param redirectUri The URI to redirect to after successful login. + * @param loginConfig Optional configuration for customizing the login process. + * @return A URI to direct the user to for login. + */ + fun initializeLogin(redirectUri: String, loginConfig: LoginConfig?): Uri { + val uriString = loginRepository.getLoginUri(redirectUri, loginConfig) + return Uri.parse(uriString).also { + logger.d { + "initializeLogin: redirectUri: $redirectUri, " + + "loginConfig: $loginConfig, returned uri: $it" + } + } + } + + /** + * Initializes a device login flow, providing the necessary information for user verification. + * @return A response containing device and user verification information. + * @throws NetworkError If a network error occurs during the process. + */ + suspend fun finalizeLogin(loginResponseUri: String): AuthResult { + with(loginRepository.getCredentialsFromLoginCode(loginResponseUri)) { + return if (this is AuthResult.Failure) { + failure(this.message) + } else { + success(null) + }.also { + logger.d { + "finalizeLogin: loginResponseUri: $loginResponseUri, result: $this" + } + } + } + } + + suspend fun setCredentials(credentials: Credentials, refreshToken: String? = null) { + logger.d { + "setCredentials: credentials: $credentials, refreshToken: $refreshToken" + } + loginRepository.setCredentials(credentials, refreshToken) + } + + suspend fun logout() { + logger.d { "logout" } + loginRepository.logout() + } + + /** + * Initializes a device login flow, providing the necessary information for user verification. + * @return [AuthResult] containing either a [DeviceAuthorizationResponse], or, if the operation + * was unsuccessful, a [NetworkError]. + */ + suspend fun initializeDeviceLogin(): AuthResult { + return loginRepository.initializeDeviceLogin().also { + logger.d { "initializeDeviceLogin: result: $it" } + } + } + + /** + * Finalizes the device login flow. Polls until either a valid access token is received, + * or an unrecoverable error occurs. + * + * @returns [AuthResult] containing either [Nothing] when successful or a + * [TokenResponseError] in case the operation failed due to an unrecoverable error occurring + * when requesting the access token. + */ + suspend fun finalizeDeviceLogin(deviceCode: String): AuthResult { + with(loginRepository.pollForDeviceLoginResponse(deviceCode)) { + return if (this is AuthResult.Failure) { + failure(this.message) + } else { + success(null) + } + } + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/CredentialsProvider.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/CredentialsProvider.kt new file mode 100644 index 00000000..76d83eb4 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/CredentialsProvider.kt @@ -0,0 +1,38 @@ +package com.tidal.sdk.auth + +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.auth.model.CredentialsUpdatedMessage +import com.tidal.sdk.common.IllegalConfigurationError +import com.tidal.sdk.common.RetryableError +import com.tidal.sdk.common.TidalMessage +import kotlinx.coroutines.flow.Flow + +/** + * Provides functionality to retrieve and manage user credentials. This interface defines + * the contract for credential operations within the library. + */ +interface CredentialsProvider { + + /** + * The default bus is used for all asynchronous communication by the Auth module. + * Subscribe to this [Flow] to receive [CredentialsUpdatedMessage] + */ + val bus: Flow + + /** + * Retrieves the current user's credentials, ensuring they are valid and up to date. + * @param apiErrorSubStatus Optional parameter indicating a TIDAL-specific error condition. + * @return [AuthResult] containing either [Credentials] or one of the following errors: + * [RetryableError]: If updating credentials fails but can be retried. + * [IllegalConfigurationError] If the configuration prevents creating valid credentials. + */ + suspend fun getCredentials(apiErrorSubStatus: String? = null): AuthResult + + /** + * Convenience function to quickly check if a user is logged in. + * + * @return `true` if a user is logged in, `false` otherwise. + */ + fun isUserLoggedIn(): Boolean +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/DefaultCredentialsProvider.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/DefaultCredentialsProvider.kt new file mode 100644 index 00000000..1357d057 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/DefaultCredentialsProvider.kt @@ -0,0 +1,26 @@ +package com.tidal.sdk.auth + +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.common.TidalMessage +import com.tidal.sdk.common.d +import com.tidal.sdk.common.logger +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +internal class DefaultCredentialsProvider internal constructor( + authBus: MutableSharedFlow, + private val tokenRepository: TokenRepository, +) : CredentialsProvider { + + override val bus: SharedFlow = authBus.asSharedFlow() + + override suspend fun getCredentials(apiErrorSubStatus: String?): AuthResult { + return tokenRepository.getCredentials(apiErrorSubStatus).also { + logger.d { "getCredentials called, apiErrorSubStatus: $apiErrorSubStatus, result $it" } + } + } + + override fun isUserLoggedIn() = tokenRepository.getLatestTokens()?.credentials?.userId != null +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/TidalAuth.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/TidalAuth.kt new file mode 100644 index 00000000..1ef875a1 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/TidalAuth.kt @@ -0,0 +1,59 @@ +package com.tidal.sdk.auth + +import android.content.Context +import com.tidal.sdk.auth.di.AuthComponent +import com.tidal.sdk.auth.di.DaggerAuthComponent +import com.tidal.sdk.auth.model.AuthConfig +import javax.inject.Inject + +/** + * The `TidalAuth` class encapsulates the authentication and authorization logic, providing a + * streamlined interface for managing user sessions and handling OAuth flows. + * + * This class provides instances of both [Auth] (for login) and [CredentialsProvider] (providing + * OAuth tokens). It is designed as a singleton to ensure a unified access point for authentication + * operations with a consistent state. + * + * @constructor Private to prevent direct instantiation. Use [getInstance] to obtain the singleton + * instance. + */ +class TidalAuth private constructor() { + + @Inject + lateinit var auth: Auth + + @Inject + lateinit var credentialsProvider: CredentialsProvider + + companion object { + + @Volatile + private var instance: TidalAuth? = null + + /** + * Provides a global access point to the [TidalAuth] instance, ensuring that only one + * instance is created and used throughout the application lifecycle. + * If the instance has not been created yet, it initializes the [TidalAuth] instance + * with the provided configuration parameters. + + * @return The single instance of [TidalAuth] that can be used to perform authentication + * and authorization operations. + */ + fun getInstance( + config: AuthConfig, + context: Context, + authComponent: AuthComponent? = null, + ): TidalAuth { + return instance ?: synchronized(this) { + TidalAuth().also { + val component = authComponent ?: DaggerAuthComponent.factory().create( + context = context, + config = config, + ) + component.inject(it) + instance = it + } + } + } + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/TokenRepository.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/TokenRepository.kt new file mode 100644 index 00000000..976bd422 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/TokenRepository.kt @@ -0,0 +1,233 @@ +package com.tidal.sdk.auth + +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.auth.model.CredentialsUpdatedMessage +import com.tidal.sdk.auth.model.RefreshResponse +import com.tidal.sdk.auth.model.Tokens +import com.tidal.sdk.auth.model.failure +import com.tidal.sdk.auth.model.success +import com.tidal.sdk.auth.storage.TokensStore +import com.tidal.sdk.auth.token.TokenService +import com.tidal.sdk.auth.util.RetryPolicy +import com.tidal.sdk.auth.util.TimeProvider +import com.tidal.sdk.auth.util.retryWithPolicy +import com.tidal.sdk.auth.util.shouldRefreshToken +import com.tidal.sdk.auth.util.toScopesString +import com.tidal.sdk.common.RetryableError +import com.tidal.sdk.common.TidalMessage +import com.tidal.sdk.common.UnexpectedError +import com.tidal.sdk.common.d +import com.tidal.sdk.common.logger +import java.net.HttpURLConnection +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class TokenRepository( + private val authConfig: AuthConfig, + private val timeProvider: TimeProvider, + private val tokensStore: TokensStore, + private val tokenService: TokenService, + private val defaultBackoffPolicy: RetryPolicy, + private val upgradeBackoffPolicy: RetryPolicy, + private val bus: MutableSharedFlow, +) { + + /** + * Mutex to ensure that only one thread at a time can update/upgrade the token. + */ + private val tokenMutex = Mutex() + + private fun needsCredentialsUpgrade(): Boolean { + val storedCredentials = getLatestTokens()?.credentials + return with(authConfig) { + when { + storedCredentials == null -> { + false + } + // level USER indicates we have a refreshToken, a precondition for this flow + storedCredentials.level == Credentials.Level.USER -> { + clientId != storedCredentials.clientId + } + + else -> { + false + } + } + } + } + + internal fun getLatestTokens(): Tokens? { + return tokensStore.getLatestTokens(authConfig.credentialsKey) + } + + @Suppress("UnusedPrivateMember") + suspend fun getCredentials(apiErrorSubStatus: String?): AuthResult { + var upgradedRefreshToken: String? = null + val credentials = getLatestTokens() + logger.d { "Received subStatus: $apiErrorSubStatus" } + return if (credentials != null && needsCredentialsUpgrade()) { + logger.d { "Upgrading credentials" } + val upgradeCredentials = upgradeTokens(credentials) + upgradeCredentials.successData?.let { + upgradedRefreshToken = it.refreshToken + success(it.credentials) + } ?: upgradeCredentials as AuthResult.Failure + } else { + logger.d { "Updating credentials" } + updateCredentials(credentials, apiErrorSubStatus) + }.also { + it.successData?.let { token -> + saveTokensAndNotify(token, upgradedRefreshToken, credentials) + } + } + } + + private suspend fun updateCredentials( + storedTokens: Tokens?, + apiErrorSubStatus: String?, + ): AuthResult { + return tokenMutex.withLock { + when { + storedTokens?.credentials?.isExpired(timeProvider) == false && + apiErrorSubStatus.shouldRefreshToken().not() -> { + success(storedTokens.credentials) + } + // if a refreshToken is available, we'll use it + storedTokens?.refreshToken != null -> { + val refreshToken = storedTokens.refreshToken + refreshCredentials { refreshUserCredentials(refreshToken) } + } + + // if nothing is stored, we will try and refresh using a client secret + authConfig.clientSecret != null -> { + refreshCredentials { getClientCredentials(authConfig.clientSecret) } + } + + // as a last resort we return a token-less Credentials, we're logged out + else -> logout() + } + } + } + + private suspend fun upgradeTokens(storedTokens: Tokens): AuthResult { + val response = tokenMutex.withLock { + retryWithPolicy(upgradeBackoffPolicy) { + with(storedTokens) { + tokenService.upgradeToken( + refreshToken = requireNotNull(this.refreshToken), + clientUniqueKey = requireNotNull(authConfig.clientUniqueKey), + clientId = authConfig.clientId, + clientSecret = authConfig.clientSecret, + scopes = authConfig.scopes.toScopesString(), + grantType = GRANT_TYPE_UPGRADE, + ) + } + } + } + + return response.successData?.let { + val token = Credentials.create( + authConfig, + timeProvider, + expiresIn = it.expiresIn, + userId = it.userId.toString(), + token = it.accessToken, + ) + success(Tokens(token, it.refreshToken)) + } ?: failure(RetryableError("1")) + } + + private fun logout(): AuthResult { + logger.d { "Logging out" } + return success( + Credentials.create( + authConfig, + timeProvider, + grantedScopes = setOf(), + expiresIn = 0, + userId = null, + token = null, + ), + ) + } + + private suspend fun refreshCredentials( + apiCall: suspend () -> AuthResult, + ): AuthResult { + return when (val result = apiCall()) { + is AuthResult.Success -> { + val response = result.data + if (response != null) { + success(Credentials.create(authConfig, timeProvider, response)) + } else { + failure(null) + } + } + + is AuthResult.Failure -> { + var refreshResult: AuthResult = failure(result.message) + val errorCode = (result.message as? UnexpectedError)?.code + errorCode?.let { code -> + if (code.toInt() <= HttpURLConnection.HTTP_UNAUTHORIZED) { + refreshResult = success( + // if code 400, 401, the user is effectively logged out + // and we return a lower level token + Credentials.createBasic(authConfig), + ) + } + } + return refreshResult + } + } + } + + private suspend fun saveTokensAndNotify( + credentials: Credentials, + refreshToken: String? = null, + storedTokens: Tokens?, + ) { + if (credentials != storedTokens?.credentials) { + val tokens = Tokens( + credentials, + refreshToken ?: storedTokens?.refreshToken, + ) + tokensStore.saveTokens(tokens) + logger.d { "Credentials updated and saved: $credentials" } + bus.emit(CredentialsUpdatedMessage(tokens.credentials)) + } + } + + private suspend fun refreshUserCredentials(refreshToken: String): AuthResult { + logger.d { "Refreshing user credentials, scopes: ${authConfig.scopes.toScopesString()}" } + return retryWithPolicy(defaultBackoffPolicy) { + tokenService.getTokenFromRefreshToken( + authConfig.clientId, + authConfig.clientSecret, + refreshToken, + GRANT_TYPE_REFRESH_TOKEN, + authConfig.scopes.toScopesString(), + ) + } + } + + private suspend fun getClientCredentials(clientSecret: String): AuthResult { + return retryWithPolicy(defaultBackoffPolicy) { + tokenService.getTokenFromClientSecret( + authConfig.clientId, + clientSecret, + GRANT_TYPE_CLIENT_CREDENTIALS, + authConfig.scopes.toScopesString(), + ) + } + } + + companion object { + + private const val GRANT_TYPE_REFRESH_TOKEN = "refresh_token" + private const val GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" + private const val GRANT_TYPE_UPGRADE = "update_client" + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/di/AuthComponent.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/di/AuthComponent.kt new file mode 100644 index 00000000..75773d38 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/di/AuthComponent.kt @@ -0,0 +1,32 @@ +package com.tidal.sdk.auth.di + +import android.content.Context +import com.tidal.sdk.auth.TidalAuth +import com.tidal.sdk.auth.model.AuthConfig +import dagger.BindsInstance +import dagger.Component +import javax.inject.Singleton + +@Singleton +@Component( + modules = [ + AuthModule::class, + StorageModule::class, + LoginModule::class, + NetworkModule::class, + CredentialsModule::class, + ], +) +interface AuthComponent { + + fun inject(tidalAuth: TidalAuth) + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance context: Context, + @BindsInstance config: AuthConfig, + ): AuthComponent + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/di/AuthModule.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/di/AuthModule.kt new file mode 100644 index 00000000..14ff6199 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/di/AuthModule.kt @@ -0,0 +1,40 @@ +package com.tidal.sdk.auth.di + +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.auth.DefaultCredentialsProvider +import com.tidal.sdk.auth.TokenRepository +import com.tidal.sdk.auth.util.DefaultRetryPolicy +import com.tidal.sdk.auth.util.DefaultTimeProvider +import com.tidal.sdk.auth.util.RetryPolicy +import com.tidal.sdk.auth.util.TimeProvider +import com.tidal.sdk.common.TidalMessage +import dagger.Module +import dagger.Provides +import javax.inject.Named +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableSharedFlow + +@Module +internal class AuthModule { + + @Provides + @Singleton + @Named("default") + fun provideRetryPolicy(): RetryPolicy = DefaultRetryPolicy() + + @Provides + @Singleton + fun provideTimeProvider(): TimeProvider = DefaultTimeProvider() + + @Provides + @Singleton + fun provideCredentialsBus(): MutableSharedFlow = MutableSharedFlow() + + @Provides + @Singleton + @JvmSuppressWildcards + fun provideCredentialsProvider( + bus: MutableSharedFlow, + tokenRepository: TokenRepository, + ): CredentialsProvider = DefaultCredentialsProvider(bus, tokenRepository) +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/di/CredentialsModule.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/di/CredentialsModule.kt new file mode 100644 index 00000000..c341be70 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/di/CredentialsModule.kt @@ -0,0 +1,51 @@ +package com.tidal.sdk.auth.di + +import com.tidal.sdk.auth.TokenRepository +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.storage.TokensStore +import com.tidal.sdk.auth.token.TokenService +import com.tidal.sdk.auth.util.RetryPolicy +import com.tidal.sdk.auth.util.TimeProvider +import com.tidal.sdk.auth.util.UpgradeTokenRetryPolicy +import com.tidal.sdk.common.TidalMessage +import dagger.Module +import dagger.Provides +import javax.inject.Named +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableSharedFlow +import retrofit2.Retrofit + +@Module +internal class CredentialsModule { + + @Provides + @Singleton + @Named("upgrade") + fun provideUpgradeRetryPolicy(): RetryPolicy = UpgradeTokenRetryPolicy() + + @Provides + @Singleton + fun provideTokenService(retrofit: Retrofit): TokenService { + return retrofit.create(TokenService::class.java) + } + + @Singleton + @Provides + fun provideTokenRepository( + authConfig: AuthConfig, + timeProvider: TimeProvider, + tokensStore: TokensStore, + tokenService: TokenService, + @Named("default") defaultBackoffPolicy: RetryPolicy, + @Named("upgrade") upgradeBackoffPolicy: RetryPolicy, + bus: MutableSharedFlow, + ) = TokenRepository( + authConfig, + timeProvider, + tokensStore, + tokenService, + defaultBackoffPolicy, + upgradeBackoffPolicy, + bus, + ) +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/di/LoginModule.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/di/LoginModule.kt new file mode 100644 index 00000000..4a06b01d --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/di/LoginModule.kt @@ -0,0 +1,73 @@ +package com.tidal.sdk.auth.di + +import com.tidal.sdk.auth.Auth +import com.tidal.sdk.auth.login.CodeChallengeBuilder +import com.tidal.sdk.auth.login.LoginRepository +import com.tidal.sdk.auth.login.LoginUriBuilder +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.network.LoginService +import com.tidal.sdk.auth.storage.TokensStore +import com.tidal.sdk.auth.util.RetryPolicy +import com.tidal.sdk.auth.util.TimeProvider +import com.tidal.sdk.common.TidalMessage +import dagger.Module +import dagger.Provides +import dagger.Reusable +import javax.inject.Named +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableSharedFlow +import retrofit2.Retrofit + +@Module +internal class LoginModule { + + @Provides + @Singleton + fun provideLoginAssistant( + loginRepository: LoginRepository, + ) = Auth(loginRepository) + + @Provides + @Reusable + fun provideCodeChallengeBuilder() = CodeChallengeBuilder() + + @Provides + @Singleton + fun provideLoginUriBuilder(authConfig: AuthConfig): LoginUriBuilder { + return LoginUriBuilder( + authConfig.clientId, + authConfig.clientUniqueKey, + authConfig.tidalLoginServiceBaseUrl, + authConfig.scopes, + ) + } + + @Provides + @Singleton + fun provideLoginService(retrofit: Retrofit): LoginService { + return retrofit.create(LoginService::class.java) + } + + @Provides + @Singleton + @Suppress("LongParameterList") + fun provideLoginRepository( + authConfig: AuthConfig, + timeProvider: TimeProvider, + codeChallengeBuilder: CodeChallengeBuilder, + loginUriBuilder: LoginUriBuilder, + loginService: LoginService, + tokensStore: TokensStore, + @Named("default") retryPolicy: RetryPolicy, + bus: MutableSharedFlow, + ): LoginRepository = LoginRepository( + authConfig, + timeProvider, + codeChallengeBuilder, + loginUriBuilder, + loginService, + tokensStore, + retryPolicy, + bus, + ) +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/di/NetworkModule.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/di/NetworkModule.kt new file mode 100644 index 00000000..d0793dec --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/di/NetworkModule.kt @@ -0,0 +1,82 @@ +package com.tidal.sdk.auth.di + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.network.NetworkLogLevel +import com.tidal.sdk.auth.util.AuthHttp +import com.tidal.sdk.common.d +import com.tidal.sdk.common.logger +import dagger.Module +import dagger.Provides +import javax.inject.Singleton +import kotlinx.serialization.json.Json +import okhttp3.CertificatePinner +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit + +@Module +internal class NetworkModule { + + private val authHttp by lazy { + AuthHttp() + } + + @Provides + @Singleton + fun provideOkHttpClient(config: AuthConfig): OkHttpClient { + val builder = OkHttpClient.Builder() + with(config) { + if (logLevel != NetworkLogLevel.NONE) { + logger.d { "Adding logging interceptor with level $logLevel" } + builder.addInterceptor(getLoggingInterceptor(logLevel)) + } + if (enableCertificatePinning) { + builder.certificatePinner(getCertificatePinner(config)) + } + } + return builder.build() + } + + @Provides + @Singleton + fun provideRetrofit(config: AuthConfig, okHttpClient: OkHttpClient): Retrofit { + val contentType = "application/json".toMediaType() + val jsonConverter = Json { + ignoreUnknownKeys = true + }.asConverterFactory(contentType) + return Retrofit.Builder() + .baseUrl(config.tidalAuthServiceBaseUrl) + .client(okHttpClient) + .addConverterFactory(jsonConverter) + .build() + } + + private fun getLoggingInterceptor(level: NetworkLogLevel): HttpLoggingInterceptor { + val okhttpLevel = HttpLoggingInterceptor.Level.entries.first { it.name == level.name } + val logging = HttpLoggingInterceptor.Logger { authHttp.log(it) } + return HttpLoggingInterceptor(logging).apply { + setLevel(okhttpLevel) + } + } + + private fun getCertificatePinner(config: AuthConfig): CertificatePinner { + val authHost = config.tidalAuthServiceBaseUrl.toHttpUrl().host + return CertificatePinner.Builder().apply { + CERTIFICATE_PINS.forEach { + add(authHost, it) + } + }.build() + } + + companion object { + private val CERTIFICATE_PINS = arrayOf( + "sha256/++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=", + "sha256/f0KW/FtqTjs108NpYj42SrGvOB2PpxIVM8nWxjPqJGE=", + "sha256/NqvDJlas/GRcYbcWE8S/IceH9cq77kg0jVhZeAPXq8k=", + "sha256/9+ze1cZgR9KO1kZrVDxA4HQ6voHRCSVNz4RdTCx4U8U=", + ) + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/di/StorageModule.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/di/StorageModule.kt new file mode 100644 index 00000000..02d6e59d --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/di/StorageModule.kt @@ -0,0 +1,47 @@ +package com.tidal.sdk.auth.di + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.storage.DefaultTokensStore +import com.tidal.sdk.auth.storage.TokensStore +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +internal class StorageModule { + + private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + + @Provides + @Singleton + fun provideCredentialsStore( + authConfig: AuthConfig, + sharedPreferences: SharedPreferences, + ): TokensStore = + DefaultTokensStore(authConfig.credentialsKey, sharedPreferences) + + @Provides + @Singleton + fun provideEncryptedSharedPreferences( + context: Context, + authConfig: AuthConfig, + + ): SharedPreferences { + return EncryptedSharedPreferences.create( + "${authConfig.credentialsKey}_$PREFS_FILE_NAME", + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + companion object { + + private const val PREFS_FILE_NAME = "tidal_auth_prefs" + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/login/CodeChallengeBuilder.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/login/CodeChallengeBuilder.kt new file mode 100644 index 00000000..ad081850 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/login/CodeChallengeBuilder.kt @@ -0,0 +1,33 @@ +package com.tidal.sdk.auth.login + +import android.util.Base64 +import java.security.MessageDigest +import java.security.SecureRandom + +internal class CodeChallengeBuilder { + + fun createCodeVerifier(): String { + val code = ByteArray(CODE_VERIFIER_BYTE_ARRAY_SIZE) + SecureRandom().nextBytes(code) + return encodeToBase64String(code) + } + + fun createCodeChallenge(codeVerifier: String): String { + val digest = MessageDigest.getInstance(DIGEST_ALGORITHM) + val value = digest.digest(codeVerifier.toByteArray(Charsets.US_ASCII)) + return encodeToBase64String(value) + } + + private fun encodeToBase64String(value: ByteArray): String { + return Base64.encodeToString( + value, + Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING, + ) + } + + companion object { + + private const val DIGEST_ALGORITHM = "SHA-256" + private const val CODE_VERIFIER_BYTE_ARRAY_SIZE = 32 + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/login/DeviceLoginPollHelper.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/login/DeviceLoginPollHelper.kt new file mode 100644 index 00000000..8c075865 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/login/DeviceLoginPollHelper.kt @@ -0,0 +1,139 @@ +package com.tidal.sdk.auth.login + +import com.tidal.sdk.auth.model.ApiErrorSubStatus +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.ErrorResponse +import com.tidal.sdk.auth.model.LoginResponse +import com.tidal.sdk.auth.model.TokenResponseError +import com.tidal.sdk.auth.model.failure +import com.tidal.sdk.auth.network.LoginService +import com.tidal.sdk.auth.util.AuthErrorPolicy +import com.tidal.sdk.auth.util.RetryPolicy +import com.tidal.sdk.auth.util.isClientError +import com.tidal.sdk.auth.util.isServerError +import com.tidal.sdk.auth.util.isSubStatus +import com.tidal.sdk.auth.util.retryWithPolicy +import com.tidal.sdk.auth.util.retryWithPolicyUnwrapped +import com.tidal.sdk.auth.util.toMilliseconds +import com.tidal.sdk.auth.util.toScopesString +import com.tidal.sdk.common.NetworkError +import com.tidal.sdk.common.RetryableError +import com.tidal.sdk.common.TidalError +import com.tidal.sdk.common.UnexpectedError +import java.net.HttpURLConnection +import retrofit2.HttpException + +internal class DeviceLoginPollHelper(private val loginService: LoginService) { + private lateinit var pollRetryPolicy: RetryPolicy + + fun prepareForPoll(interval: Int, maxDuration: Int) { + pollRetryPolicy = object : RetryPolicy { + override val numberOfRetries = maxDuration / interval + override val delayMillis = interval.toMilliseconds() + override val delayFactor = 1 + + override fun shouldRetry( + errorResponse: ErrorResponse?, + throwable: Throwable?, + attempt: Int, + ): Boolean { + val isTokenExpired = errorResponse?.subStatus + .toString() + .isSubStatus(ApiErrorSubStatus.ExpiredAccessToken) + return attempt < numberOfRetries && !isTokenExpired + } + } + } + + suspend fun poll( + authConfig: AuthConfig, + deviceCode: String, + grantType: String, + retryPolicy: RetryPolicy, + ): AuthResult { + return retryWithPolicy(pollRetryPolicy, PollErrorPolicy()) { + retryWithPolicyUnwrapped(retryPolicy) { + loginService.getTokenFromDeviceCode( + clientId = authConfig.clientId, + clientSecret = authConfig.clientSecret, + grantType = grantType, + deviceCode = deviceCode, + scopes = authConfig.scopes.toScopesString(), + clientUniqueKey = authConfig.clientUniqueKey, + ) + } + } + } + + private class PollErrorPolicy : AuthErrorPolicy { + override fun handleError( + errorResponse: ErrorResponse?, + throwable: Throwable?, + ): AuthResult { + return when (throwable) { + is HttpException -> { + val subStatus = ApiErrorSubStatus + .entries + .firstOrNull { it.value == errorResponse?.subStatus.toString() } + when { + throwable.isServerError() -> failure( + RetryableError( + throwable.code().toString(), + subStatus?.value?.toInt(), + throwable, + ), + ) + + throwable.isClientError() -> failure( + if (throwable.code() > HttpURLConnection.HTTP_UNAUTHORIZED) { + TokenResponseError( + throwable.code().toString(), + subStatus?.value?.toInt(), + throwable, + ) + } else { + handleSubStatus(subStatus, throwable) + }, + ) + + else -> failure(NetworkError("1", throwable)) + } + } + + else -> failure(NetworkError("0", throwable)) + } + } + + private fun handleSubStatus( + subStatus: ApiErrorSubStatus?, + exception: HttpException, + ): TidalError { + return when (subStatus) { + ApiErrorSubStatus.ExpiredAccessToken -> { + TokenResponseError( + exception.code().toString(), + subStatus.value.toInt(), + exception, + ) + } + + ApiErrorSubStatus.AuthorizationPending -> { + RetryableError( + exception.code().toString(), + subStatus.value.toInt(), + exception, + ) + } + + else -> { + UnexpectedError( + exception.code().toString(), + subStatus?.value?.toInt(), + exception, + ) + } + } + } + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/login/LoginRepository.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/login/LoginRepository.kt new file mode 100644 index 00000000..e057242c --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/login/LoginRepository.kt @@ -0,0 +1,157 @@ +package com.tidal.sdk.auth.login + +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.AuthorizationError +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.auth.model.CredentialsUpdatedMessage +import com.tidal.sdk.auth.model.DeviceAuthorizationResponse +import com.tidal.sdk.auth.model.LoginConfig +import com.tidal.sdk.auth.model.LoginResponse +import com.tidal.sdk.auth.model.Tokens +import com.tidal.sdk.auth.model.failure +import com.tidal.sdk.auth.network.LoginService +import com.tidal.sdk.auth.storage.TokensStore +import com.tidal.sdk.auth.util.RetryPolicy +import com.tidal.sdk.auth.util.TimeProvider +import com.tidal.sdk.auth.util.retryWithPolicy +import com.tidal.sdk.auth.util.toScopesString +import com.tidal.sdk.common.TidalMessage +import com.tidal.sdk.common.d +import com.tidal.sdk.common.logger +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.datetime.Instant + +internal class LoginRepository constructor( + private val authConfig: AuthConfig, + private val timeProvider: TimeProvider, + private val codeChallengeBuilder: CodeChallengeBuilder, + private val loginUriBuilder: LoginUriBuilder, + private val loginService: LoginService, + private val tokensStore: TokensStore, + private val exponentialBackoffPolicy: RetryPolicy, + private val bus: MutableSharedFlow, +) { + + private var codeVerifier: String? = null + private val deviceLoginPollHelper: DeviceLoginPollHelper by lazy { + DeviceLoginPollHelper(loginService) + } + + fun getLoginUri( + redirectUri: String, + loginConfig: LoginConfig?, + ): String { + val codeChallenge = with(codeChallengeBuilder) { + codeVerifier = createCodeVerifier() + createCodeChallenge(codeVerifier!!) + } + return loginUriBuilder.getLoginUri( + redirectUri, + loginConfig, + codeChallenge, + ) + } + + @Suppress("MagicNumber") + suspend fun getCredentialsFromLoginCode(uri: String): AuthResult { + val redirectUri = RedirectUri.fromUriString(uri) + if (redirectUri is RedirectUri.Failure || codeVerifier == null) { + return failure(AuthorizationError("0")) + } + with(redirectUri as RedirectUri.Success) { + return retryWithPolicy(exponentialBackoffPolicy) { + loginService.getTokenWithCodeVerifier( + code = code, + clientId = authConfig.clientId, + grantType = GRANT_TYPE_AUTHORIZATION_CODE, + redirectUri = url, + scopes = authConfig.scopes.toScopesString(), + codeVerifier = requireNotNull(codeVerifier), + clientUniqueKey = authConfig.clientUniqueKey, + ) + }.also { + it.successData?.let { response -> + saveTokensAndNotifyBus(response) + } + } + } + } + + suspend fun setCredentials(credentials: Credentials, refreshToken: String? = null) { + val storedTokens = tokensStore.getLatestTokens(authConfig.credentialsKey) + if (credentials != storedTokens?.credentials) { + val tokens = Tokens( + credentials, + refreshToken ?: storedTokens?.refreshToken, + ) + tokensStore.saveTokens(tokens) + bus.emit(CredentialsUpdatedMessage(tokens.credentials)) + } + } + + private suspend fun saveTokensAndNotifyBus(response: LoginResponse) { + val tokens = Tokens( + credentials = Credentials( + authConfig.clientId, + authConfig.scopes, + authConfig.clientUniqueKey, + response.scopesString.split(", ").toSet(), + response.userId?.toString(), + Instant.fromEpochSeconds( + timeProvider.now.epochSeconds + response.expiresIn.toLong(), + ), + response.accessToken, + ), + refreshToken = response.refreshToken, + ) + tokensStore.saveTokens(tokens) + bus.emit(CredentialsUpdatedMessage(tokens.credentials)) + } + + suspend fun logout() { + tokensStore.eraseTokens() + bus.emit(CredentialsUpdatedMessage()) + } + + suspend fun initializeDeviceLogin(): AuthResult { + return retryWithPolicy(retryPolicy = exponentialBackoffPolicy) { + loginService.getDeviceAuthorization( + authConfig.clientId, + authConfig.scopes.toScopesString(), + ).also { + deviceLoginPollHelper.prepareForPoll(it.interval, it.expiresIn) + } + } + } + + suspend fun pollForDeviceLoginResponse(deviceCode: String): AuthResult { + val pollResult = deviceLoginPollHelper.poll( + authConfig, + deviceCode, + GRANT_TYPE_DEVICE_CODE, + exponentialBackoffPolicy, + ).also { + it.successData?.let { response -> + saveTokensAndNotifyBus(response) + } + } + return when (pollResult) { + is AuthResult.Success -> { + AuthResult.Success(null) + } + + is AuthResult.Failure -> { + AuthResult.Failure(pollResult.message) + } + }.also { + logger.d { "finalizeDeviceLogin: deviceCode: $deviceCode, result: $it" } + } + } + + companion object { + + private const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" + private const val GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/login/LoginUriBuilder.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/login/LoginUriBuilder.kt new file mode 100644 index 00000000..fe3b91cf --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/login/LoginUriBuilder.kt @@ -0,0 +1,88 @@ +package com.tidal.sdk.auth.login + +import com.tidal.sdk.auth.model.LoginConfig +import com.tidal.sdk.auth.model.QueryParameter +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +internal class LoginUriBuilder( + private val clientId: String, + private val clientUniqueKey: String?, + private val loginUri: String, + private val scopes: Set, +) { + + fun getLoginUri(redirectUri: String, loginConfig: LoginConfig?, codeChallenge: String): String { + var builder = loginUri.toHttpUrl() + .newBuilder() + .addPathSegment(AUTH_PATH) + + with(builder) { + addQueryParameter(QueryKeys.REDIRECT_URI_KEY, redirectUri) + addQueryParameter(QueryKeys.SCOPES_KEY, scopes.joinToString(" ")) + buildBaseParameters(clientId, clientUniqueKey, codeChallenge).forEach { + addQueryParameter( + it.key, + it.value, + ) + } + } + + loginConfig?.let { + builder = evaluateLoginConfig(builder, it) + } + return builder.build().toString() + } + + private fun evaluateLoginConfig( + builder: HttpUrl.Builder, + config: LoginConfig, + ): HttpUrl.Builder { + with(builder) { + addQueryParameter(QueryKeys.LANGUAGE_KEY, config.locale.toString()) + config.email?.let { + addQueryParameter(QueryKeys.EMAIL_KEY, it) + } + config.customParams.forEach { + addQueryParameter(it.key, it.value) + } + } + return builder + } + + private fun buildBaseParameters( + clientId: String, + clientUniqueKey: String?, + codeChallenge: String, + ): Set { + return mutableSetOf( + QueryParameter(QueryKeys.CLIENT_ID_KEY, clientId), + + QueryParameter( + QueryKeys.CODE_CHALLENGE_METHOD_KEY, + CODE_CHALLENGE_METHOD, + ), + QueryParameter(QueryKeys.CODE_CHALLENGE_KEY, codeChallenge), + ).apply { + clientUniqueKey?.let { + this.add(QueryParameter(QueryKeys.CLIENT_UNIQUE_KEY, it)) + } + } + } + + object QueryKeys { + const val CLIENT_ID_KEY = "client_id" + const val CLIENT_UNIQUE_KEY = "client_unique_key" + const val CODE_CHALLENGE_KEY = "code_challenge" + const val CODE_CHALLENGE_METHOD_KEY = "code_challenge_method" + const val LANGUAGE_KEY = "lang" + const val EMAIL_KEY = "email" + const val REDIRECT_URI_KEY = "redirect_uri" + const val SCOPES_KEY = "scope" + } + + companion object { + private const val AUTH_PATH = "authorize" + private const val CODE_CHALLENGE_METHOD = "S256" + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/login/RedirectUri.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/login/RedirectUri.kt new file mode 100644 index 00000000..fc00d8f2 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/login/RedirectUri.kt @@ -0,0 +1,46 @@ +package com.tidal.sdk.auth.login + +internal sealed class RedirectUri { + + companion object { + private const val CODE_PARAMETER_KEY = "code" + private const val ERROR_MESSAGE_PARAMETER_KEY = "error_description" + private const val ERROR_PARAMETER_KEY = "error" + + fun fromUriString(uri: String): RedirectUri { + return with(uri) { + if (hasError() || getCode().isBlank()) { + Failure(getErrorMessage()) + } else { + Success(getUrl(), getCode()) + } + } + } + + private fun String.hasError(): Boolean { + val errorRegex = "(&|\\?)$ERROR_PARAMETER_KEY=(.+)".toRegex() + return errorRegex.containsMatchIn(this) + } + + private fun String.getUrl(): String { + return substringBefore("?") + } + + private fun String.getCode(): String { + return substringAfter("$CODE_PARAMETER_KEY=", "").substringBefore("&") + } + + private fun String.getErrorMessage(): String { + return substringAfter("$ERROR_MESSAGE_PARAMETER_KEY=").substringBefore("&") + } + } + + data class Success( + val url: String, + val code: String, + ) : RedirectUri() + + data class Failure( + val errorMessage: String?, + ) : RedirectUri() +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/ApiErrorSubStatus.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/ApiErrorSubStatus.kt new file mode 100644 index 00000000..cb886570 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/ApiErrorSubStatus.kt @@ -0,0 +1,15 @@ +package com.tidal.sdk.auth.model + +/** + * Represents a TIDAL-specific error condition that can be used to trigger specific behavior. + * @param value The error code returned by the API. + * @param shouldTriggerRefresh Whether the error should trigger a backend call to refresh + * the user's credentials. + */ +internal enum class ApiErrorSubStatus(val value: String, val shouldTriggerRefresh: Boolean) { + AuthorizationPending("1002", false), + SessionDoesNotExist("6001", true), + TemporaryAuthServerError("11001", true), + InvalidAccessToken("11002", true), + ExpiredAccessToken("11003", true), +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/AuthConfig.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/AuthConfig.kt new file mode 100644 index 00000000..8437ba24 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/AuthConfig.kt @@ -0,0 +1,35 @@ +package com.tidal.sdk.auth.model + +import com.tidal.sdk.auth.network.NetworkLogLevel + +/** + * Configuration object for the Auth module. + * @param clientId The client ID of the application. + * @param clientUniqueKey The unique key of the application. + * @param clientSecret The client secret of the application. + * @param credentialsKey The key used to encrypt and store store credentials on the device. + * @param scopes The scopes that the application requests. + * @param tidalLoginServiceBaseUrl The base URL of the TIDAL login service. Only pass in a value if + * you want to override the default value. + * @param tidalAuthServiceBaseUrl The base URL of the TIDAL auth service. Only pass in a value if + * you want to override the default value. + * @param enableCertificatePinning Whether certificate pinning is enabled. + * @param logLevel The [NetworkLogLevel] for the network layer. Default is [NetworkLogLevel.NONE], + * which means no logging. + */ +data class AuthConfig( + val clientId: String, + val clientUniqueKey: String? = null, + val clientSecret: String? = null, + val credentialsKey: String, + val scopes: Set = setOf(), + val tidalLoginServiceBaseUrl: String = DEFAULT_LOGIN_SERVICE_BASE_URL, + val tidalAuthServiceBaseUrl: String = DEFAULT_AUTH_SERVICE_BASE_URL, + val enableCertificatePinning: Boolean = true, + val logLevel: NetworkLogLevel = NetworkLogLevel.NONE, +) { + companion object { + private const val DEFAULT_LOGIN_SERVICE_BASE_URL = "https://login.tidal.com/" + private const val DEFAULT_AUTH_SERVICE_BASE_URL = "https://auth.tidal.com/v1/" + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/AuthResult.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/AuthResult.kt new file mode 100644 index 00000000..3b122a9a --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/AuthResult.kt @@ -0,0 +1,40 @@ +package com.tidal.sdk.auth.model + +import com.tidal.sdk.common.TidalMessage + +sealed class AuthResult { + + data class Success(val data: T?) : AuthResult() + + // deliberately using message here atm, might change later + data class Failure(val message: TidalMessage?) : AuthResult() + + /** + * Convenience function to quickly check if this result is a [AuthResult.Success] + */ + val isSuccess get() = this is Success<*> + + /** + * Convenience function to quickly check if this result is a [AuthResult.Failure] + */ + val isFailure get() = this is Failure + + /** + * Helper property to directly access the data payload without having to + * safequard it in every call + */ + val successData: T? + get() { + return if (this is Success) this.data else null + } +} + +/** + * Creates a [AuthResult.Success] with data payload + */ +fun success(data: T?) = AuthResult.Success(data) + +/** + * Creates a [AuthResult.Failure] with all fields + */ +fun failure(message: TidalMessage? = null) = AuthResult.Failure(message) diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/Credentials.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/Credentials.kt new file mode 100644 index 00000000..a84662c0 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/Credentials.kt @@ -0,0 +1,119 @@ +package com.tidal.sdk.auth.model + +import com.tidal.sdk.auth.util.TimeProvider +import com.tidal.sdk.common.d +import com.tidal.sdk.common.logger +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +/** + * Represents the credentials of a user or client. + */ +@Serializable +data class Credentials( + val clientId: String, + val requestedScopes: Set, + val clientUniqueKey: String?, + val grantedScopes: Set, + val userId: String?, + val expires: Instant?, + val token: String?, +) { + + /** + * The level of the credentials. + * @see [Level] + */ + val level: Level + get() { + return when { + !userId.isNullOrBlank() && !token.isNullOrBlank() -> Level.USER + userId.isNullOrBlank() && !token.isNullOrBlank() -> Level.CLIENT + else -> Level.BASIC + } + } + + /** + * Checks if the token is expired. + */ + internal fun isExpired(timeProvider: TimeProvider): Boolean { + val now = timeProvider.now.epochSeconds + val validUntil = expires?.epochSeconds ?: null + logger.d { + "Checking if token is expired: Now: $now, valid until: $validUntil, " + + "limit: $EXPIRY_LIMIT_SECONDS" + } + val isExpired = validUntil?.let { + now > validUntil - EXPIRY_LIMIT_SECONDS + } ?: true + return isExpired.also { + logger.d { "Token is expired: $it" } + } + } + + /** + * Represents the level of the credentials. + * @property BASIC credentials represent valid, but unauthorized credentials. They contain + * neither a user ID nor a token. + * @property USER credentials represent valid, authorized credentials without a user ID. + * They will grant access to certain features that don't require an user account. + * @property CLIENT credentials represent valid, authorized credentials for a specific user. + * They contain a token and user ID, and will grant full access to the TIDAL API. + */ + enum class Level { + BASIC, + CLIENT, + USER, + } + + companion object { + const val EXPIRY_LIMIT_SECONDS = 60 + + internal fun createBasic( + authConfig: AuthConfig, + ): Credentials { + return Credentials( + clientId = authConfig.clientId, + requestedScopes = authConfig.scopes, + clientUniqueKey = authConfig.clientUniqueKey, + grantedScopes = setOf(), + userId = null, + expires = null, + token = null, + ) + } + + internal fun create( + authConfig: AuthConfig, + timeProvider: TimeProvider, + response: RefreshResponse, + ) = create( + authConfig = authConfig, + timeProvider = timeProvider, + grantedScopes = response.scopesString.split(", ").toSet(), + userId = response.userId?.toString(), + expiresIn = response.expiresIn, + token = response.accessToken, + ) + + @Suppress("LongParameterList") + internal fun create( + authConfig: AuthConfig, + timeProvider: TimeProvider, + grantedScopes: Set? = null, + userId: String?, + token: String?, + expiresIn: Int?, + ) = Credentials( + clientId = authConfig.clientId, + requestedScopes = authConfig.scopes, + clientUniqueKey = authConfig.clientUniqueKey, + grantedScopes = grantedScopes ?: setOf(), + userId = userId, + expires = expiresIn?.let { + Instant.fromEpochSeconds(timeProvider.now.epochSeconds + it.toLong()) + }, + token = token, + ) + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/CredentialsUpdatedMessage.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/CredentialsUpdatedMessage.kt new file mode 100644 index 00000000..987110ab --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/CredentialsUpdatedMessage.kt @@ -0,0 +1,11 @@ +package com.tidal.sdk.auth.model + +import com.tidal.sdk.common.TidalMessage + +/** + * Message sent on the [CredentialsProvider.bus] when credentials are updated. + * @param credentials The updated credentials, or `null` if the credentials were removed. + */ +data class CredentialsUpdatedMessage( + val credentials: Credentials? = null, +) : TidalMessage diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/DeviceAuthorizationResponse.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/DeviceAuthorizationResponse.kt new file mode 100644 index 00000000..9764b955 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/DeviceAuthorizationResponse.kt @@ -0,0 +1,13 @@ +package com.tidal.sdk.auth.model + +import kotlinx.serialization.Serializable + +@Serializable +data class DeviceAuthorizationResponse( + val deviceCode: String, + val userCode: String, + val verificationUri: String, + val verificationUriComplete: String?, + val expiresIn: Int, + val interval: Int, +) diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/ErrorResponse.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/ErrorResponse.kt new file mode 100644 index 00000000..3ae8b701 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/ErrorResponse.kt @@ -0,0 +1,12 @@ +package com.tidal.sdk.auth.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +internal data class ErrorResponse( + val status: Int, + val error: String, + @JsonNames("sub_status") val subStatus: Int, + @JsonNames("error_description") val errorDescription: String, +) diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/Errors.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/Errors.kt new file mode 100644 index 00000000..e8196b24 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/Errors.kt @@ -0,0 +1,29 @@ +package com.tidal.sdk.auth.model + +import com.tidal.sdk.common.TidalError + +/** + * Error indicating that the user did not correctly authenticate themselves at the TIDAL login + * service. Raised when the URI made to the redirect URI indicates that the authentication was + * not succesful. + * + * @param code The error code returned by the API. + * @param subStatus The TIDAL-specific error code returned by the API. + */ +class AuthorizationError( + override val code: String, + val subStatus: Int? = null, + val throwable: Throwable? = null, +) : TidalError + +/** + * Error used to indicate that an access token could not be retrieved. + * + * @param code The error code returned by the API. + * @param subStatus The TIDAL-specific error code returned by the API. + */ +class TokenResponseError( + override val code: String, + val subStatus: Int? = null, + val throwable: Throwable? = null, +) : TidalError diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/LoginConfig.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/LoginConfig.kt new file mode 100644 index 00000000..7811a692 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/LoginConfig.kt @@ -0,0 +1,18 @@ +package com.tidal.sdk.auth.model + +import java.util.Locale + +/** + * Configuration parameters for the TIDAL login service. + * @param locale (ISO 639-1 e.g. en/de/it) is used to set the language for the TIDAL login service. + * If left blank the browser language will be used. + * @param email Optional email address to be pre-filled on the login screen. + * @param customParams Key value map used to add custom parameters to be passed through to the + * TIDAL login service. If set to null, a default configuration of TIDAL login service will be + * applied (@see [QueryParameter]. + */ +data class LoginConfig( + val locale: Locale? = Locale.getDefault(), + val email: String? = null, + val customParams: Set = setOf(), +) diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/LoginResponse.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/LoginResponse.kt new file mode 100644 index 00000000..ecde0ee1 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/LoginResponse.kt @@ -0,0 +1,21 @@ +package com.tidal.sdk.auth.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +internal data class LoginResponse( + @JsonNames("access_token") + val accessToken: String, + val clientName: String? = null, + @JsonNames("expires_in") + val expiresIn: Int, + @JsonNames("refresh_token") + val refreshToken: String, + @JsonNames("token_type") + val tokenType: String, + @JsonNames("scope") + val scopesString: String, + @JsonNames("user_id") + val userId: Int?, +) diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/QueryParameter.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/QueryParameter.kt new file mode 100644 index 00000000..4d57b209 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/QueryParameter.kt @@ -0,0 +1,11 @@ +package com.tidal.sdk.auth.model + +/** + * Represents a query parameter. + * @param key The key of the parameter. + * @param value The value of the parameter. + */ +data class QueryParameter( + val key: String, + val value: String, +) diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/RefreshResponse.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/RefreshResponse.kt new file mode 100644 index 00000000..feb25f8c --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/RefreshResponse.kt @@ -0,0 +1,19 @@ +package com.tidal.sdk.auth.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +internal data class RefreshResponse( + @JsonNames("access_token") + val accessToken: String, + val clientName: String? = null, + @JsonNames("expires_in") + val expiresIn: Int, + @JsonNames("token_type") + val tokenType: String, + @JsonNames("scope") + val scopesString: String, + @JsonNames("user_id") + val userId: Int? = null, +) diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/Tokens.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/Tokens.kt new file mode 100644 index 00000000..f233e25a --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/Tokens.kt @@ -0,0 +1,9 @@ +package com.tidal.sdk.auth.model + +import kotlinx.serialization.Serializable + +@Serializable +internal data class Tokens( + val credentials: Credentials, + val refreshToken: String? = null, +) diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/model/UpgradeResponse.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/model/UpgradeResponse.kt new file mode 100644 index 00000000..9ea87c23 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/model/UpgradeResponse.kt @@ -0,0 +1,18 @@ +package com.tidal.sdk.auth.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +internal data class UpgradeResponse( + @JsonNames("access_token") + val accessToken: String?, + @JsonNames("refresh_token") + val refreshToken: String?, + @JsonNames("token_type") + val tokenType: String?, + @JsonNames("expires_in") + val expiresIn: Int? = 0, + @JsonNames("user_id") + val userId: Int? = null, +) diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/network/LoginService.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/network/LoginService.kt new file mode 100644 index 00000000..ab73baa9 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/network/LoginService.kt @@ -0,0 +1,40 @@ +package com.tidal.sdk.auth.network + +import com.tidal.sdk.auth.model.DeviceAuthorizationResponse +import com.tidal.sdk.auth.model.LoginResponse +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +internal interface LoginService { + + @FormUrlEncoded + @POST("oauth2/token") + suspend fun getTokenWithCodeVerifier( + @Field("code") code: String, + @Field("client_id") clientId: String, + @Field("grant_type") grantType: String, + @Field("redirect_uri") redirectUri: String, + @Field("scope") scopes: String, + @Field("code_verifier") codeVerifier: String, + @Field("client_unique_key") clientUniqueKey: String?, + ): LoginResponse + + @FormUrlEncoded + @POST("oauth2/device_authorization") + suspend fun getDeviceAuthorization( + @Field("client_id") clientId: String, + @Field("scope") scope: String, + ): DeviceAuthorizationResponse + + @FormUrlEncoded + @POST("oauth2/token") + suspend fun getTokenFromDeviceCode( + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String?, + @Field("device_code") deviceCode: String, + @Field("grant_type") grantType: String, + @Field("scope") scopes: String, + @Field("client_unique_key") clientUniqueKey: String?, + ): LoginResponse +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/network/NetworkLogLevel.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/network/NetworkLogLevel.kt new file mode 100644 index 00000000..270d43f8 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/network/NetworkLogLevel.kt @@ -0,0 +1,11 @@ +package com.tidal.sdk.auth.network + +/** + * The level of logging that should be used for network requests. + */ +enum class NetworkLogLevel { + NONE, + BASIC, + HEADERS, + BODY, +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/storage/DefaultTokensStore.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/storage/DefaultTokensStore.kt new file mode 100644 index 00000000..1eb4a528 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/storage/DefaultTokensStore.kt @@ -0,0 +1,71 @@ +package com.tidal.sdk.auth.storage + +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import com.tidal.sdk.auth.model.Tokens +import com.tidal.sdk.common.logger +import com.tidal.sdk.common.w +import javax.inject.Inject +import kotlinx.serialization.decodeFromString as decode +import kotlinx.serialization.encodeToString as encode +import kotlinx.serialization.json.Json + +/** + * This class uses [EncryptedSharedPreferences] to securely store credentials. + * Pass in a [SharedPreferences] instance to use a custom one, by default + * we inject an [EncryptedSharedPreferences] instance. + */ +internal class DefaultTokensStore @Inject constructor( + private val credentialsKey: String, + private val sharedPreferences: SharedPreferences, +) : TokensStore { + + private var latestTokens: Tokens? = null + get() { + return field ?: loadTokens() + } + + private val encryptedSharedPreferences: SharedPreferences by lazy { + sharedPreferences + } + + override fun getLatestTokens(key: String): Tokens? { + if (key != credentialsKey) return null + return latestTokens + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun loadTokens(): Tokens? { + return encryptedSharedPreferences.getString(credentialsKey, null)?.let { + try { + Json.decode(it) + } catch (e: Exception) { + logger.w { " Failed to decode tokens. Attempting to decode legacy tokens" } + decodeLegacyTokens(it).also { convertedLegacyTokens -> + saveTokens(convertedLegacyTokens) + } + } + } + } + + /** + * This method is used to decode tokens that were stored in the old format, using [Scopes]. + * This is used to ensure backwards compatibility. + */ + private fun decodeLegacyTokens(jsonString: String): Tokens { + return Json.decode(jsonString).toTokens() + } + + override fun saveTokens(tokens: Tokens) { + val stringToSave = Json.encode(tokens) + encryptedSharedPreferences.edit().putString(credentialsKey, stringToSave).apply().also { + latestTokens = tokens + } + } + + override fun eraseTokens() { + encryptedSharedPreferences.edit().clear().apply().also { + latestTokens = null + } + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/storage/LegacyCredentials.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/storage/LegacyCredentials.kt new file mode 100644 index 00000000..23c160a7 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/storage/LegacyCredentials.kt @@ -0,0 +1,32 @@ +package com.tidal.sdk.auth.storage + +import com.tidal.sdk.auth.model.Credentials +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +/** + * Represents the credentials of a user or client. + */ +@Deprecated("Use [Credentials] instead.") +@Serializable +internal data class LegacyCredentials( + val clientId: String, + val requestedScopes: Scopes, + val clientUniqueKey: String?, + val grantedScopes: Scopes, + val userId: String?, + val expires: Instant?, + val token: String?, +) { + fun toCredentials(): Credentials { + return Credentials( + clientId, + requestedScopes.scopes, + clientUniqueKey, + grantedScopes.scopes, + userId, + expires, + token, + ) + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/storage/LegacyTokens.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/storage/LegacyTokens.kt new file mode 100644 index 00000000..6f0d70ec --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/storage/LegacyTokens.kt @@ -0,0 +1,17 @@ +package com.tidal.sdk.auth.storage + +import com.tidal.sdk.auth.model.Tokens +import kotlinx.serialization.Serializable + +@Serializable +internal data class LegacyTokens( + val credentials: LegacyCredentials, + val refreshToken: String? = null, +) { + fun toTokens(): Tokens { + return Tokens( + credentials.toCredentials(), + refreshToken, + ) + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/storage/Scopes.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/storage/Scopes.kt new file mode 100644 index 00000000..50fd8c6a --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/storage/Scopes.kt @@ -0,0 +1,32 @@ +package com.tidal.sdk.auth.storage + +import kotlinx.serialization.Serializable + +/** + * Represents a set of scopes. Scopes are used to define the capabilities of a TIDAL client. + * @param scopes The set of scopes. + */ +@Deprecated("Use [String] instead.") +@Serializable +data class Scopes(val scopes: Set) { + + /** + * Returns a string representation of the scopes that is readable by the TIDAL API backend. + * @return The string representation of the scopes. + */ + override fun toString(): String { + return scopes.joinToString(" ") + } + + companion object { + + /** + * Creates a Scopes object from a string representation of the scopes. + * @param joinedString The string representation of the scopes. + * @return The Scopes object. + */ + fun fromString(joinedString: String): Scopes { + return Scopes(joinedString.split(" ").toSet()) + } + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/storage/TokensStore.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/storage/TokensStore.kt new file mode 100644 index 00000000..959375d1 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/storage/TokensStore.kt @@ -0,0 +1,10 @@ +package com.tidal.sdk.auth.storage + +import com.tidal.sdk.auth.model.Tokens + +internal interface TokensStore { + + fun getLatestTokens(key: String): Tokens? + fun saveTokens(tokens: Tokens) + fun eraseTokens() +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/token/TokenService.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/token/TokenService.kt new file mode 100644 index 00000000..bbe59f20 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/token/TokenService.kt @@ -0,0 +1,40 @@ +package com.tidal.sdk.auth.token + +import com.tidal.sdk.auth.model.RefreshResponse +import com.tidal.sdk.auth.model.UpgradeResponse +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +internal interface TokenService { + + @FormUrlEncoded + @POST("oauth2/token") + suspend fun getTokenFromRefreshToken( + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String?, + @Field("refresh_token") refreshToken: String, + @Field("grant_type") grantType: String, + @Field("scope") scope: String, + ): RefreshResponse + + @FormUrlEncoded + @POST("oauth2/token") + suspend fun getTokenFromClientSecret( + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String, + @Field("grant_type") grantType: String, + @Field("scope") scope: String, + ): RefreshResponse + + @FormUrlEncoded + @POST("oauth2/token") + suspend fun upgradeToken( + @Field("refresh_token") refreshToken: String, + @Field("client_unique_key") clientUniqueKey: String?, + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String?, + @Field("scope") scopes: String, + @Field("grant_type") grantType: String, + ): UpgradeResponse +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/util/AuthErrorPolicy.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/util/AuthErrorPolicy.kt new file mode 100644 index 00000000..b1f93184 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/util/AuthErrorPolicy.kt @@ -0,0 +1,58 @@ +package com.tidal.sdk.auth.util + +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.ErrorResponse +import com.tidal.sdk.auth.model.failure +import com.tidal.sdk.common.NetworkError +import com.tidal.sdk.common.RetryableError +import com.tidal.sdk.common.UnexpectedError +import retrofit2.HttpException + +/** + * Implement this interface to create a handler to transform [Throwable] or [ErrorResponse] + * thrown while executing [retryWithPolicy] into an [AuthResult] + */ +internal interface AuthErrorPolicy { + + fun handleError( + errorResponse: ErrorResponse?, + throwable: Throwable?, + ): AuthResult +} + +internal class DefaultAuthErrorPolicy : AuthErrorPolicy { + + override fun handleError( + errorResponse: ErrorResponse?, + throwable: Throwable?, + ): AuthResult.Failure { + with(throwable) { + return when (this) { + is HttpException -> { + val subStatus = getErrorResponse()?.subStatus + when { + isClientError() -> failure( + UnexpectedError( + code().toString(), + subStatus, + this, + ), + ) + + isServerError() -> failure( + RetryableError( + code().toString(), + subStatus, + this, + ), + ) + + else -> failure(NetworkError("1", this)) + } + } + + else -> failure(NetworkError("0", this)) + } + } + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/util/AuthHttp.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/util/AuthHttp.kt new file mode 100644 index 00000000..8dfaffa7 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/util/AuthHttp.kt @@ -0,0 +1,10 @@ +package com.tidal.sdk.auth.util + +import com.tidal.sdk.common.d +import com.tidal.sdk.common.logger + +internal class AuthHttp { + fun log(string: String) { + logger.d { string } + } +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/util/DefaultTimeProvider.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/util/DefaultTimeProvider.kt new file mode 100644 index 00000000..1e3aa212 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/util/DefaultTimeProvider.kt @@ -0,0 +1,9 @@ +package com.tidal.sdk.auth.util + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +internal class DefaultTimeProvider : TimeProvider { + override val now: Instant + get() = Clock.System.now() +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/util/Extensions.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/util/Extensions.kt new file mode 100644 index 00000000..17a1a71f --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/util/Extensions.kt @@ -0,0 +1,10 @@ +package com.tidal.sdk.auth.util + +import com.tidal.sdk.auth.model.Credentials + +/** + * Convenience function to check if the user is logged in. + */ +fun Credentials.isLoggedIn(): Boolean { + return level == Credentials.Level.USER +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/util/InternalExtensions.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/util/InternalExtensions.kt new file mode 100644 index 00000000..6eef05fd --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/util/InternalExtensions.kt @@ -0,0 +1,60 @@ +package com.tidal.sdk.auth.util + +import com.tidal.sdk.auth.model.ApiErrorSubStatus +import com.tidal.sdk.auth.model.ErrorResponse +import com.tidal.sdk.common.d +import com.tidal.sdk.common.logger +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import retrofit2.HttpException + +private const val HTTP_CLIENT_ERROR_STATUS_START = 400 +private const val HTTP_SERVER_ERROR_STATUS_START = 500 +private const val HTTP_STATUS_END = 600 +private const val MILLIS_MULTIPLIER = 1000 + +internal fun Int.toMilliseconds() = this * MILLIS_MULTIPLIER + +internal fun HttpException.isClientError(): Boolean { + return (HTTP_CLIENT_ERROR_STATUS_START until HTTP_SERVER_ERROR_STATUS_START).contains( + this.code(), + ) +} + +internal fun HttpException.isServerError(): Boolean { + return (HTTP_SERVER_ERROR_STATUS_START until HTTP_STATUS_END).contains( + this.code(), + ) +} + +internal fun Int.isServerError(): Boolean { + return (HTTP_SERVER_ERROR_STATUS_START until HTTP_STATUS_END).contains( + this, + ) +} + +@Suppress("SwallowedException") +internal fun HttpException.getErrorResponse(): ErrorResponse? { + val response = response() + + return response?.errorBody()?.string()?.let { + try { + Json.decodeFromString(it) + } catch (e: SerializationException) { + logger.d { "Error parsing error response from HttpException: $it" } + null + } + } +} + +internal fun String?.shouldRefreshToken(): Boolean { + return ApiErrorSubStatus.entries.firstOrNull { it.value == this }?.shouldTriggerRefresh == true +} + +internal fun String?.isSubStatus(status: ApiErrorSubStatus): Boolean { + return this == status.value +} + +internal fun Set.toScopesString(): String { + return joinToString(" ") +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/util/RetryPolicy.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/util/RetryPolicy.kt new file mode 100644 index 00000000..99830abf --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/util/RetryPolicy.kt @@ -0,0 +1,71 @@ +package com.tidal.sdk.auth.util + +import com.tidal.sdk.auth.model.ErrorResponse +import retrofit2.HttpException + +/** + * Policy defining how a network call should be retried + */ +internal interface RetryPolicy { + /** + * The maximum number of attempts after the first call + */ + val numberOfRetries: Int + + /** + * The base delay to wait between retries + */ + val delayMillis: Int + + /** + * The factor by which [delayMillis] gets multiplied for each following attempt + */ + val delayFactor: Int + + /** + * The default implementation for retry-on-exception evaluation will return true only on + * server errors (5xx)This is the default because it is the behaviour + * needed for most calls in the authentication context. + * Override for different behaviour. + */ + fun shouldRetryOnException(throwable: Throwable): Boolean { + return ((throwable as? HttpException)?.isServerError() ?: false) + } + + fun shouldRetryOnErrorResponse(errorResponse: ErrorResponse?): Boolean { + return errorResponse?.status?.isServerError() == true + } + + /** + * The default implementation for retry returns true if the submitted throwable is null + * or [shouldRetryOnException] evaluates to true, and the attempt count submitted is + * below the [numberOfRetries] threshold. Override for different behaviour. + */ + fun shouldRetry(errorResponse: ErrorResponse?, throwable: Throwable?, attempt: Int): Boolean { + return ( + throwable == null || + shouldRetryOnErrorResponse(errorResponse) || + shouldRetryOnException( + throwable, + ) + ) && + attempt <= numberOfRetries + } +} + +@Suppress("MagicNumber") +internal class DefaultRetryPolicy : RetryPolicy { + override val numberOfRetries = 5 + override val delayMillis = 1000 + override val delayFactor = 2 +} + +@Suppress("MagicNumber") +internal class UpgradeTokenRetryPolicy : RetryPolicy { + override val numberOfRetries = 5 + override val delayMillis = 1000 + override val delayFactor = 2 + + override fun shouldRetryOnException(throwable: Throwable) = true + override fun shouldRetryOnErrorResponse(errorResponse: ErrorResponse?) = true +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/util/TimeProvider.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/util/TimeProvider.kt new file mode 100644 index 00000000..80eb0100 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/util/TimeProvider.kt @@ -0,0 +1,7 @@ +package com.tidal.sdk.auth.util + +import kotlinx.datetime.Instant + +internal interface TimeProvider { + val now: Instant +} diff --git a/auth/src/main/kotlin/com/tidal/sdk/auth/util/Utils.kt b/auth/src/main/kotlin/com/tidal/sdk/auth/util/Utils.kt new file mode 100644 index 00000000..81b8e619 --- /dev/null +++ b/auth/src/main/kotlin/com/tidal/sdk/auth/util/Utils.kt @@ -0,0 +1,72 @@ +package com.tidal.sdk.auth.util + +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.ErrorResponse +import com.tidal.sdk.auth.model.success +import com.tidal.sdk.common.NetworkError +import com.tidal.sdk.common.i +import com.tidal.sdk.common.logger +import java.io.IOException +import kotlinx.coroutines.delay +import retrofit2.HttpException + +/** + * Lets a suspend fun retry on [IOException], following a [RetryPolicy] to define the + * number and frequency of retries. + * This means, when returning network calls , they can be retried automatically using + * this function. + * This function returns an [AuthResult], this means no exceptions are thrown. + * [HttpExceptions] are handled acoording to specs, all other potentially caught exceptions + * will lead to a [NetworkError] at this point. + */ +@Suppress("TooGenericExceptionCaught") +internal suspend fun retryWithPolicy( + retryPolicy: RetryPolicy, + authErrorPolicy: AuthErrorPolicy? = null, + block: suspend () -> T, +): AuthResult { + var currentDelay = retryPolicy.delayMillis.toLong() + var attempts = 0 + var throwable: Throwable? = null + var errorResponse: ErrorResponse? = null + while (retryPolicy.shouldRetry(errorResponse, throwable, attempts)) { + try { + return success(block()) + } catch (t: Throwable) { + retryPolicy.logger.i { + "Retrying network call. Attempt #$attempts, exception: ${t.message}" + } + + throwable = t + errorResponse = (throwable as? HttpException)?.getErrorResponse() + } + attempts += 1 + delay(currentDelay) + currentDelay = (currentDelay * retryPolicy.delayFactor) + } + return (authErrorPolicy ?: DefaultAuthErrorPolicy()).handleError(errorResponse, throwable) +} + +@Suppress("TooGenericExceptionCaught") +internal suspend fun retryWithPolicyUnwrapped( + retryPolicy: RetryPolicy, + block: suspend () -> T, +): T { + var currentDelay = retryPolicy.delayMillis.toLong() + var attempts = 0 + var throwable: Throwable? = null + while (retryPolicy.shouldRetry(null, throwable, attempts)) { + try { + return block() + } catch (t: Throwable) { + retryPolicy.logger.i { + "Retrying network call. Attempt #$attempts, exception: ${t.message}" + } + throwable = t + } + attempts += 1 + delay(currentDelay) + currentDelay = (currentDelay * retryPolicy.delayFactor) + } + throw (throwable!!) +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/auth/FakeLoginService.kt b/auth/src/test/kotlin/com/tidal/sdk/auth/FakeLoginService.kt new file mode 100644 index 00000000..e40123d3 --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/auth/FakeLoginService.kt @@ -0,0 +1,152 @@ +package com.tidal.sdk.auth + +import com.tidal.sdk.auth.model.ApiErrorSubStatus +import com.tidal.sdk.auth.model.DeviceAuthorizationResponse +import com.tidal.sdk.auth.model.ErrorResponse +import com.tidal.sdk.auth.model.LoginResponse +import com.tidal.sdk.auth.network.LoginService +import com.tidal.sdk.auth.util.TimeProvider +import com.tidal.sdk.auth.util.buildTestHttpException +import com.tidal.sdk.util.CoroutineTestTimeProvider +import java.net.UnknownHostException + +internal class FakeLoginService( + private val errorResponse: ErrorResponse? = null, + private val shouldThrowUnknownHostException: Boolean = false, +) : LoginService { + + val fakeLoginResponse = LoginResponse( + "credentials", + "clientName", + 0, + "refreshToken", + "tokenType", + "", + userId = 999, + ) + + /** + * Set the login behaviour before testing the device login flow + */ + lateinit var deviceLoginBehaviour: DeviceLoginBehaviour + + private var deviceLoginPendingHelper: DeviceLoginPendingHelper? = null + var calls = mutableListOf() + + private lateinit var deviceAuthorizationResponse: DeviceAuthorizationResponse + + override suspend fun getTokenWithCodeVerifier( + code: String, + clientId: String, + grantType: String, + redirectUri: String, + scopes: String, + codeVerifier: String, + clientUniqueKey: String?, + ): LoginResponse { + calls.add(CallType.GET_TOKEN_WITH_CODE_VERIFIER) + return returnLoginResponseOrThrow() + } + + override suspend fun getDeviceAuthorization( + clientId: String, + scope: String, + ): DeviceAuthorizationResponse { + calls.add(CallType.GET_DEVICE_AUTHORIZATION) + errorResponse?.let { + throw buildTestHttpException(it) + } + return makeDeviceAuthorizationResponse().also { + deviceAuthorizationResponse = it + } + } + + override suspend fun getTokenFromDeviceCode( + clientId: String, + clientSecret: String?, + deviceCode: String, + grantType: String, + scopes: String, + clientUniqueKey: String?, + ): LoginResponse { + calls.add(CallType.GET_TOKEN_FROM_DEVICE_CODE) + if (shouldThrowUnknownHostException) { + throw UnknownHostException() + } + deviceLoginPendingHelper?.let { helper -> + if (helper.isPending()) { + throw buildTestHttpException(deviceLoginBehaviour.errorResponseWhilePending) + } else { + deviceLoginBehaviour.errorResponseToFinishWith?.let { + throw buildTestHttpException(it) + } + } + } + + return deviceLoginBehaviour.errorResponseToFinishWith?.let { + throw buildTestHttpException(it) + } ?: returnLoginResponseOrThrow() + } + + private fun makeDeviceAuthorizationResponse(): DeviceAuthorizationResponse { + deviceLoginPendingHelper = DeviceLoginPendingHelper( + deviceLoginBehaviour.timeProvider, + deviceLoginBehaviour.loginPendingSeconds, + ) + return DeviceAuthorizationResponse( + "deviceCode", + "userCode", + "verificationUri", + "verificationUriComplete", + deviceLoginBehaviour.authorizationResponseExpirationSeconds, + 2, + ) + } + + private fun returnLoginResponseOrThrow(): LoginResponse { + if (shouldThrowUnknownHostException) { + throw UnknownHostException() + } + errorResponse?.let { + throw buildTestHttpException(errorResponse) + } ?: run { + return fakeLoginResponse + } + } + + enum class CallType { + GET_TOKEN_WITH_CODE_VERIFIER, + GET_TOKEN_FROM_DEVICE_CODE, + GET_DEVICE_AUTHORIZATION, + } + + class DeviceLoginPendingHelper( + private val timeProvider: TimeProvider, + private val pendingForSeconds: Int, + ) { + + private val startTime = timeProvider.now + + fun isPending(): Boolean { + return startTime.epochSeconds + pendingForSeconds > timeProvider.now.epochSeconds + } + } +} + +/** + * Use this class to define [FakeLoginService]'s behaviour when using the + * device login flow. Note that you need to supply a [CoroutineTestTimeProvider] + * and start it before making calls. + */ +internal data class DeviceLoginBehaviour( + val authorizationResponseExpirationSeconds: Int, + val loginPendingSeconds: Int, + val errorResponseWhilePending: ErrorResponse = ErrorResponse( + 400, + "authorization_pending", + ApiErrorSubStatus.AuthorizationPending.value.toInt(), + "", + ), + val errorResponseToFinishWith: ErrorResponse? = null, + val timeProvider: CoroutineTestTimeProvider, +) diff --git a/auth/src/test/kotlin/com/tidal/sdk/auth/FakeTokenService.kt b/auth/src/test/kotlin/com/tidal/sdk/auth/FakeTokenService.kt new file mode 100644 index 00000000..83a399a1 --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/auth/FakeTokenService.kt @@ -0,0 +1,82 @@ +package com.tidal.sdk.auth + +import com.tidal.sdk.auth.model.RefreshResponse +import com.tidal.sdk.auth.model.UpgradeResponse +import com.tidal.sdk.auth.token.TokenService +import kotlinx.coroutines.delay + +internal class FakeTokenService : TokenService { + + var calls = mutableListOf() + var throwableToThrow: Throwable? = null + + override suspend fun getTokenFromRefreshToken( + clientId: String, + clientSecret: String?, + refreshToken: String, + grantType: String, + scope: String, + ): RefreshResponse { + calls.add(CallType.Refresh) + delay(10) + throwableToThrow?.let { + throw it + } ?: run { + return RefreshResponse( + "credentials", + "clientName", + 5000, + "tokenType", + "", + 999, + + ) + } + } + + override suspend fun getTokenFromClientSecret( + clientId: String, + clientSecret: String, + grantType: String, + scope: String, + ): RefreshResponse { + calls.add(CallType.Secret) + delay(10) + throwableToThrow?.let { + throw it + } ?: run { + return RefreshResponse( + "credentials", + "clientName", + 5000, + "tokenType", + "", + 999, + ) + } + } + + override suspend fun upgradeToken( + refreshToken: String, + clientUniqueKey: String?, + clientId: String, + clientSecret: String?, + scopes: String, + grantType: String, + ): UpgradeResponse { + calls.add(CallType.Upgrade) + delay(10) + throwableToThrow?.let { + throw it + } ?: run { + return UpgradeResponse( + accessToken = "upgradeCredentials", + refreshToken = "upgradeRefreshToken", + tokenType = "Bearer", + expiresIn = 5000, + ) + } + } + + enum class CallType { Refresh, Secret, Upgrade } +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/auth/LoginRepositoryTest.kt b/auth/src/test/kotlin/com/tidal/sdk/auth/LoginRepositoryTest.kt new file mode 100644 index 00000000..eb7882eb --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/auth/LoginRepositoryTest.kt @@ -0,0 +1,835 @@ +package com.tidal.sdk.auth + +import app.cash.turbine.test +import com.tidal.sdk.auth.FakeLoginService.CallType +import com.tidal.sdk.auth.login.CodeChallengeBuilder +import com.tidal.sdk.auth.login.FakeTokensStore +import com.tidal.sdk.auth.login.LoginRepository +import com.tidal.sdk.auth.login.LoginUriBuilder +import com.tidal.sdk.auth.login.LoginUriBuilder.QueryKeys.CLIENT_ID_KEY +import com.tidal.sdk.auth.login.LoginUriBuilder.QueryKeys.CLIENT_UNIQUE_KEY +import com.tidal.sdk.auth.login.LoginUriBuilder.QueryKeys.CODE_CHALLENGE_KEY +import com.tidal.sdk.auth.login.LoginUriBuilder.QueryKeys.CODE_CHALLENGE_METHOD_KEY +import com.tidal.sdk.auth.login.LoginUriBuilder.QueryKeys.LANGUAGE_KEY +import com.tidal.sdk.auth.login.LoginUriBuilder.QueryKeys.REDIRECT_URI_KEY +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.AuthorizationError +import com.tidal.sdk.auth.model.CredentialsUpdatedMessage +import com.tidal.sdk.auth.model.ErrorResponse +import com.tidal.sdk.auth.model.LoginConfig +import com.tidal.sdk.auth.model.LoginResponse +import com.tidal.sdk.auth.model.QueryParameter +import com.tidal.sdk.auth.model.TokenResponseError +import com.tidal.sdk.auth.model.Tokens +import com.tidal.sdk.auth.util.RetryPolicy +import com.tidal.sdk.auth.util.TimeProvider +import com.tidal.sdk.common.NetworkError +import com.tidal.sdk.common.RetryableError +import com.tidal.sdk.common.TidalMessage +import com.tidal.sdk.common.UnexpectedError +import com.tidal.sdk.util.CoroutineTestTimeProvider +import com.tidal.sdk.util.TEST_CLIENT_ID +import com.tidal.sdk.util.TEST_CLIENT_UNIQUE_KEY +import com.tidal.sdk.util.TEST_TIME_PROVIDER +import com.tidal.sdk.util.makeCredentials +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import java.util.Locale +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class LoginRepositoryTest { + + private lateinit var timeProvider: TimeProvider + private val authConfig = AuthConfig( + clientId = TEST_CLIENT_ID, + clientUniqueKey = TEST_CLIENT_UNIQUE_KEY, + credentialsKey = "credentialsKey", + scopes = setOf(), + enableCertificatePinning = false, + ) + private val dummyEncodedString = "encoded_string" + private val loginUri = "https://tidal.com/android/login/auth" + private val messageBus: MutableSharedFlow = MutableSharedFlow() + private lateinit var fakeTokensStore: FakeTokensStore + private lateinit var fakeLoginService: FakeLoginService + private lateinit var loginRepository: LoginRepository + private val testRetryPolicy: RetryPolicy = object : RetryPolicy { + override val numberOfRetries = 3 + override val delayMillis = 1 + override val delayFactor = 1 + } + + private fun createLoginRepository( + loginService: FakeLoginService, + tokensStore: FakeTokensStore = FakeTokensStore(""), + retryPolicy: RetryPolicy = testRetryPolicy, + bus: MutableSharedFlow = messageBus, + loginBaseUrl: String = "https://login.tidal.com/", + ) { + fakeLoginService = loginService + fakeTokensStore = tokensStore + loginRepository = LoginRepository( + authConfig, + timeProvider, + CodeChallengeBuilder(), + LoginUriBuilder(TEST_CLIENT_ID, TEST_CLIENT_UNIQUE_KEY, loginBaseUrl, authConfig.scopes), + loginService, + tokensStore, + retryPolicy, + bus, + ) + } + + @BeforeAll + fun setup() { + mockkStatic(android.util.Base64::class) + mockk { + every { + android.util.Base64.encodeToString(any(), any()) + } answers { dummyEncodedString } + } + timeProvider = TEST_TIME_PROVIDER + } + + @Test + fun `getLoginUri uses the correct base url`() { + // given + val loginBaseUrl = "https://imaginary.address.tidal.com/" + createLoginRepository(loginService = FakeLoginService(), loginBaseUrl = loginBaseUrl) + // when + val actualUrl = loginRepository.getLoginUri(loginUri, null) + + // then + assert(actualUrl.startsWith(loginBaseUrl)) { + "getLoginUri() should use the correct base url" + } + } + + @Test + fun `getLoginUri appends the submitted base query arguments`() { + // given + timeProvider = TEST_TIME_PROVIDER + createLoginRepository(FakeLoginService()) + val clientIdMatcher = "$QUERY_PREFIX$CLIENT_ID_KEY=$TEST_CLIENT_ID".toRegex() + val clientUniqueKeyMatcher = + "$QUERY_PREFIX$CLIENT_UNIQUE_KEY=$TEST_CLIENT_UNIQUE_KEY".toRegex() + val codeChallengeMethodMatcher = "$QUERY_PREFIX$CODE_CHALLENGE_METHOD_KEY=S256".toRegex() + val codeChallengeMatcher = "$QUERY_PREFIX$CODE_CHALLENGE_KEY=$dummyEncodedString".toRegex() + + // when + val generatedUri = loginRepository.getLoginUri(loginUri, null) + + // then + assert(clientIdMatcher.containsMatchIn(generatedUri)) { + "getLoginUri() should append the $CLIENT_ID_KEY" + } + assert(clientUniqueKeyMatcher.containsMatchIn(generatedUri)) { + "getLoginUri() should append the $CLIENT_UNIQUE_KEY" + } + assert(codeChallengeMethodMatcher.containsMatchIn(generatedUri)) { + "getLoginUri() should append the $$CODE_CHALLENGE_METHOD_KEY" + } + assert(codeChallengeMatcher.containsMatchIn(generatedUri)) { + "getLoginUri() should append the $$CODE_CHALLENGE_KEY" + } + } + + @Test + fun `getLoginUri appends the RedirectUri properly`() { + // given + createLoginRepository(FakeLoginService()) + val fakeUri = "fakeUri" + + val redirectUriMatcher = "$QUERY_PREFIX$REDIRECT_URI_KEY=$fakeUri".toRegex() + + // when + val generatedUri = loginRepository.getLoginUri(fakeUri, null) + + // then + assert(redirectUriMatcher.containsMatchIn(generatedUri)) { + "getLoginUri() should append the $$REDIRECT_URI_KEY" + } + } + + @Test + fun `getLoginUri returns a uri containing all custom query arguments`() { + // given + createLoginRepository(FakeLoginService()) + val loginConfig = LoginConfig( + customParams = setOf( + QueryParameter("key1", "value1"), + QueryParameter("key2", "value2"), + QueryParameter("key3", "value3"), + ), + ) + + // when + val generatedUri = loginRepository.getLoginUri(loginUri, loginConfig) + + // then + loginConfig.customParams.forEach { + val matcher = "$QUERY_PREFIX${it.key}=${it.value}".toRegex() + assert(matcher.containsMatchIn(generatedUri)) { + "getLoginUrl() should append all custom query parameters" + } + } + } + + @Test + fun `getLoginUri appends the locale properly`() { + // given + createLoginRepository(FakeLoginService()) + val locale = Locale.ENGLISH + val localeString = locale.toString() + val loginConfig = LoginConfig( + locale = Locale.ENGLISH, + ) + + // when + val generatedUri = loginRepository.getLoginUri(loginUri, loginConfig) + + // then + val localeMatcher = "$QUERY_PREFIX$LANGUAGE_KEY=$localeString".toRegex() + assert(localeMatcher.containsMatchIn(generatedUri)) { + "getLoginUrl() should append the locale properly" + } + } + + @Test + fun `do not get a token with an incorrect login response`() = runTest { + // given + createLoginRepository(FakeLoginService()) + val incorrectUriString = "https://www.google.com" + + // when + loginRepository.getLoginUri(loginUri, null) + val result = loginRepository.getCredentialsFromLoginCode(incorrectUriString) + // then + assert( + fakeLoginService.calls.none { + it == CallType.GET_TOKEN_WITH_CODE_VERIFIER + }, + ) { + "If the submitted uri is incorrect, the service should not get called" + } + assert((result as AuthResult.Failure).message is AuthorizationError) { + "If the submitted uri is incorrect, a Failure containing an AuthorizationError should be returned." + } + } + + @Test + fun `get a token with a correct login response`() = runTest { + // given + createLoginRepository(FakeLoginService()) + val correctUriString = "https://tidal.com/android/login/auth&code=123&appMode=android" + + // when + loginRepository.getLoginUri(loginUri, null) + val result = loginRepository.getCredentialsFromLoginCode(correctUriString) + + // then + assert( + fakeLoginService.calls.filter { + it == CallType.GET_TOKEN_WITH_CODE_VERIFIER + }.size == 1, + ) { + "If the submitted uri is correct, the service should get called" + } + assert(result.successData is LoginResponse) { + "If the submitted uri is correct, a Failure containing an AuthorizationError should be returned." + } + } + + @Test + fun `getTokenFromLoginCode fails with an AuthorizationError on wrong redirect uris`() = + runTest { + // given + createLoginRepository(FakeLoginService()) + val firstWrongUri = "https://tidal.org/my-nonexistent-service/code=abcde" + val secondWrongUri = + "https://tidal.com/android/login/auth?error=someError&code=somecode" + val thirdWrongUri = "https://tidal.com/android/login/auth?someotherArg=someValue" + + // when + val firstResult = + loginRepository.getCredentialsFromLoginCode(firstWrongUri) as AuthResult.Failure + val secondResult = + loginRepository.getCredentialsFromLoginCode(secondWrongUri) as AuthResult.Failure + val thirdResult = + loginRepository.getCredentialsFromLoginCode(thirdWrongUri) as AuthResult.Failure + + // then + assert(firstResult.message is AuthorizationError) { + "A Uri pointing to the wrong domain should trigger the emission of an AuthorizationError" + } + assert(secondResult.message is AuthorizationError) { + "A Uri with an error parameter should trigger the emission of an AuthorizationError" + } + assert(thirdResult.message is AuthorizationError) { + "A Uri with no code parameter should trigger the emission of an AuthorizationError" + } + } + + @Test + fun `getTokenFromLoginCode returns an AuthResult Success if service returns a TokenResponse`() = + runTest { + // given + createLoginRepository(FakeLoginService()) + + // when + loginRepository.getLoginUri(loginUri, null) + val result = loginRepository.getCredentialsFromLoginCode(VALID_URI) + + // then + assert(result is AuthResult.Success) { + "If a LoginResponse is returned from LoginService, getTokenFromLoginCode should return a successful AuthResult" + } + } + + @Test + fun `getTokenFromLoginCode retries as many times as specified in policy on 5xx HttpError`() = + runTest { + // given + val retryPolicy = object : RetryPolicy { + override val numberOfRetries = 3 + override val delayMillis = 5 + override val delayFactor = 1 + } + val fakeErrorResponse = ErrorResponse(503, "message", 0, "") + + createLoginRepository( + loginService = FakeLoginService(fakeErrorResponse), + retryPolicy = retryPolicy, + ) + + // when + loginRepository.getLoginUri(loginUri, null) + loginRepository.getCredentialsFromLoginCode(VALID_URI) + + // then + assert( + fakeLoginService.calls.filter { + it == CallType.GET_TOKEN_WITH_CODE_VERIFIER + }.size == retryPolicy.numberOfRetries + 1, + ) { + "getTokenFromLoginCode should retry as many times as specified, resulting in a total number of attempts that is equal to the policy's specified retries + 1" + } + } + + @Test + fun `getTokenFromLoginCode returns a Failure with UnexpectedError on Http 4xx Error`() = + runTest { + // given + val retryPolicy = object : RetryPolicy { + override val numberOfRetries = 3 + override val delayMillis = 5 + override val delayFactor = 1 + } + val exceptionCode = 400 + val fakeErrorResponse = ErrorResponse(exceptionCode, "message", 0, "") + + createLoginRepository( + loginService = FakeLoginService(fakeErrorResponse), + retryPolicy = retryPolicy, + ) + + // when + loginRepository.getLoginUri(loginUri, null) + val result = loginRepository.getCredentialsFromLoginCode(VALID_URI) + + // then + assert( + fakeLoginService.calls.filter { + it == CallType.GET_TOKEN_WITH_CODE_VERIFIER + }.size == 1, + ) { + "getTokenFromLoginCode should not retry on 4xx errors" + } + assert((result as AuthResult.Failure).message is UnexpectedError) { + "Http 4xx errors should result in a Failure containing an UnexpectedError" + } + assert(((result).message as UnexpectedError).code == exceptionCode.toString()) { + "The returned result should contain the exception's code" + } + } + + @Test + fun `getTokenFromLoginCode retries as specified and returns a Failure with RetryableError on Http 5xx Error`() = + runTest { + // given + val retryPolicy = object : RetryPolicy { + override val numberOfRetries = 3 + override val delayMillis = 5 + override val delayFactor = 1 + } + val exceptionCode = 503 + val fakeErrorResponse = ErrorResponse(exceptionCode, "message", 0, "") + + createLoginRepository( + loginService = FakeLoginService(fakeErrorResponse), + retryPolicy = retryPolicy, + ) + + // when + loginRepository.getLoginUri(loginUri, null) + val result = loginRepository.getCredentialsFromLoginCode(VALID_URI) + + // then + assert( + fakeLoginService.calls.filter { + it == CallType.GET_TOKEN_WITH_CODE_VERIFIER + }.size == retryPolicy.numberOfRetries + 1, + ) { + "The network call should be retried the specified number of times" + } + assert((result as AuthResult.Failure).message is RetryableError) { + "Http 4xx errors should result in a Failure containing an RetryableError" + } + assert(((result).message as RetryableError).code == exceptionCode.toString()) { + "The returned result should contain the exception's code" + } + } + + @Test + fun `getCredentialsFromLoginCode does not retry and returns a Failure with NetworkError on any non-Http Exception`() = + runTest { + // given + val dispatcher = StandardTestDispatcher() + val coroutineTimeProvider = CoroutineTestTimeProvider(dispatcher).also { + timeProvider = it + } + + val retryPolicy = object : RetryPolicy { + override val numberOfRetries = 3 + override val delayMillis = 5 + override val delayFactor = 1 + } + createLoginRepository( + loginService = FakeLoginService(shouldThrowUnknownHostException = true), + retryPolicy = retryPolicy, + ) + fakeLoginService.deviceLoginBehaviour = DeviceLoginBehaviour( + authorizationResponseExpirationSeconds = 10, + loginPendingSeconds = 9, + timeProvider = coroutineTimeProvider, + ) + + // when + coroutineTimeProvider.startTimeFor(this, 10) + loginRepository.getLoginUri(loginUri, null) + val result = loginRepository.getCredentialsFromLoginCode(VALID_URI) + + // then + assert((result as AuthResult.Failure).message is NetworkError) { + "Http 4xx errors should result in a Failure containing an NetworkError" + } + assert( + fakeLoginService.calls.filter { + it == CallType.GET_TOKEN_WITH_CODE_VERIFIER + }.size == 1, + ) { + "getTokenFromLoginCode should not retry on non-5xx errors" + } + } + + @Test + fun `credentials are saved upon successful regular login token retrieval`() = runTest { + // given + createLoginRepository(FakeLoginService()) + + // when + loginRepository.getLoginUri(loginUri, null) + loginRepository.getCredentialsFromLoginCode(VALID_URI) + + // then + assert(fakeTokensStore.saves == 1) { + "TokensStore should have been called once to save after successful token retrieval" + } + } + + @Test + fun `credentials are saved upon successful device login token retrieval`() = runTest { + // given + val dispatcher = StandardTestDispatcher() + val coroutineTimeProvider = CoroutineTestTimeProvider(dispatcher).also { + timeProvider = it + } + + createLoginRepository(FakeLoginService()) + fakeLoginService.deviceLoginBehaviour = DeviceLoginBehaviour( + authorizationResponseExpirationSeconds = 10, + loginPendingSeconds = 1, + timeProvider = coroutineTimeProvider, + ) + + // when + coroutineTimeProvider.startTimeFor(this, 2) + val authorizationResponse = loginRepository.initializeDeviceLogin() + loginRepository.pollForDeviceLoginResponse( + authorizationResponse.successData!!.deviceCode, + ) + // then + assert(fakeTokensStore.saves == 1) { + "TokensStore should have been called once to save after successful token retrieval" + } + } + + @Test + fun `initializeDeviceLogin retries the specified number of times on 5xx errors and returns a RetryableError`() = + runTest { + // given + val testErrorCode = 503 + createLoginRepository(FakeLoginService(ErrorResponse(testErrorCode, "message", 0, ""))) + + // when + val result = loginRepository.initializeDeviceLogin() + val expectedNumberOfCalls = testRetryPolicy.numberOfRetries + 1 + + // then + + assert( + fakeLoginService.calls.filter { + it == CallType.GET_DEVICE_AUTHORIZATION + }.size == expectedNumberOfCalls, + ) { + "In case of 5xx returns, initializeDeviceLogin should trigger retries as defined by the retryPolicy" + } + assert( + ((result as AuthResult.Failure).message as RetryableError).code == testErrorCode.toString(), + ) { + "When finished retrying, a RetryableError should be returned that cointains the correct error code." + } + } + + @Test + fun `pollForDeviceLoginResponse retries the specified number of times on 5xx errors`() = + runTest { + // given + val dispatcher = StandardTestDispatcher() + val coroutineTimeProvider = CoroutineTestTimeProvider(dispatcher).also { + timeProvider = it + } + createLoginRepository(FakeLoginService()) + fakeLoginService.deviceLoginBehaviour = DeviceLoginBehaviour( + authorizationResponseExpirationSeconds = 10, + loginPendingSeconds = 9, + errorResponseWhilePending = ErrorResponse(503, "message", 0, ""), + timeProvider = coroutineTimeProvider, + ) + + // when + coroutineTimeProvider.startTimeFor(this, 60) + val authorizationResponse = loginRepository.initializeDeviceLogin() + val expectedPollCalls = + fakeLoginService.deviceLoginBehaviour.authorizationResponseExpirationSeconds / + authorizationResponse.successData!!.interval + val result = loginRepository.pollForDeviceLoginResponse( + authorizationResponse.successData!!.deviceCode, + ) + + // then + + // the number of calls to expect are defined by the expiration and the interval + // in the DeviceAuthorizationResponse as well as the retryPolicy's numberOfRetries + val expectedTotalCalls = + expectedPollCalls + expectedPollCalls * testRetryPolicy.numberOfRetries + + assert( + fakeLoginService.calls.filter { + it == CallType.GET_TOKEN_FROM_DEVICE_CODE + }.size == expectedTotalCalls, + ) { + "In case of 5xx returns, each poll attempt should trigger retries as defined by the retryPolicy" + } + assert((result as AuthResult.Failure).message is RetryableError) { + "When finished retrying, a RetryableError should be returned" + } + } + + @Test + fun `pollForDeviceLoginResponse returns a success if a response is available before the authorization expires`() = + runTest { + // given + val dispatcher = StandardTestDispatcher() + val coroutineTimeProvider = CoroutineTestTimeProvider(dispatcher).also { + timeProvider = it + } + createLoginRepository(FakeLoginService()) + fakeLoginService.deviceLoginBehaviour = DeviceLoginBehaviour( + authorizationResponseExpirationSeconds = 12, + loginPendingSeconds = 9, + timeProvider = coroutineTimeProvider, + ) + + // when + coroutineTimeProvider.startTimeFor(this, 60) + val authorizationResponse = loginRepository.initializeDeviceLogin() + val result = loginRepository.pollForDeviceLoginResponse( + authorizationResponse.successData!!.deviceCode, + ) + + // then + assert(result.isSuccess) { + "If a response is returned before the expiration time, the call to finalizeDeviceLogin() should succeed" + } + } + + @Test + fun `pollForDeviceLoginResponse should keep polling on 400 and 401 errors, but not do individual retries`() = + runTest { + // given + val dispatcher = StandardTestDispatcher() + val coroutineTimeProvider = CoroutineTestTimeProvider(dispatcher).also { + timeProvider = it + } + createLoginRepository(FakeLoginService()) + fakeLoginService.deviceLoginBehaviour = DeviceLoginBehaviour( + authorizationResponseExpirationSeconds = 10, + loginPendingSeconds = 9, + timeProvider = coroutineTimeProvider, + ) + val expectedPollCalls = + fakeLoginService.deviceLoginBehaviour.authorizationResponseExpirationSeconds / 2 + + // when + coroutineTimeProvider.startTimeFor(this, 60) + val authorizationResponse = loginRepository.initializeDeviceLogin() + loginRepository.pollForDeviceLoginResponse( + authorizationResponse.successData!!.deviceCode, + ) + // then + + assert( + fakeLoginService.calls.filter { + it == CallType.GET_TOKEN_FROM_DEVICE_CODE + }.size == expectedPollCalls, + ) { + "Given an expiration of ${fakeLoginService.deviceLoginBehaviour.authorizationResponseExpirationSeconds} and a poll interval of ${authorizationResponse.successData!!.interval}, login service should have been called $expectedPollCalls" + } + } + + @Test + fun `pollForDeviceLoginResponse should return a TokenResponseError on 4xx that are not 400 or 401`() = + runTest { + // given + val dispatcher = StandardTestDispatcher() + val coroutineTimeProvider = CoroutineTestTimeProvider(dispatcher).also { + timeProvider = it + } + createLoginRepository(FakeLoginService()) + fakeLoginService.deviceLoginBehaviour = DeviceLoginBehaviour( + authorizationResponseExpirationSeconds = 10, + loginPendingSeconds = 9, + errorResponseWhilePending = ErrorResponse(403, "message", 0, ""), + timeProvider = coroutineTimeProvider, + ) + + // when + coroutineTimeProvider.startTimeFor(this, 60) + val authorizationResponse = loginRepository.initializeDeviceLogin() + val result = loginRepository.pollForDeviceLoginResponse( + authorizationResponse.successData!!.deviceCode, + ) + // then + assert((result as AuthResult.Failure).message!! is TokenResponseError) { + "On 4xx errors that aren't 400 or 401, a TokenResponseError should be returned" + } + } + + @Test + fun `pollForDeviceLogin should return a TokenResponseError if the token used for polling has expired`() { + runTest { + // given + val dispatcher = StandardTestDispatcher() + val coroutineTimeProvider = CoroutineTestTimeProvider(dispatcher).also { + timeProvider = it + } + createLoginRepository(FakeLoginService()) + fakeLoginService.deviceLoginBehaviour = DeviceLoginBehaviour( + authorizationResponseExpirationSeconds = 10, + loginPendingSeconds = 5, + errorResponseToFinishWith = ErrorResponse(401, "token expired", 11003, ""), + timeProvider = coroutineTimeProvider, + ) + + // when + coroutineTimeProvider.startTimeFor(this, 60) + val authorizationResponse = loginRepository.initializeDeviceLogin() + val result = loginRepository.pollForDeviceLoginResponse( + authorizationResponse.successData!!.deviceCode, + ) + + // then + assert((result as AuthResult.Failure).message!! is TokenResponseError) { + "If the token used for polling has expired, a TokenResponseError should be returned" + } + } + } + + @Test + fun `pollForDeviceLoginResponse should return a RetryableError after failing with a 5xx Http error`() = + runTest { + // given + val dispatcher = StandardTestDispatcher() + val coroutineTimeProvider = CoroutineTestTimeProvider(dispatcher).also { + timeProvider = it + } + createLoginRepository(FakeLoginService()) + fakeLoginService.deviceLoginBehaviour = DeviceLoginBehaviour( + authorizationResponseExpirationSeconds = 10, + loginPendingSeconds = 9, + errorResponseWhilePending = ErrorResponse(503, "message", 0, ""), + timeProvider = coroutineTimeProvider, + ) + + // when + coroutineTimeProvider.startTimeFor(this, 60) + val authorizationResponse = loginRepository.initializeDeviceLogin() + val result = loginRepository.pollForDeviceLoginResponse( + authorizationResponse.successData!!.deviceCode, + ) + // then + assert((result as AuthResult.Failure).message!! is RetryableError) { + "On 5xx errors, a RetryableError should be returned" + } + } + + @Test + fun `logout clears stored credentials`() = runTest { + // given + createLoginRepository(FakeLoginService()) + + // when + loginRepository.getLoginUri(loginUri, null) + loginRepository.getCredentialsFromLoginCode(VALID_URI) + + // then + assert(fakeTokensStore.last() != null) { + "After retrieving a token we should have a saved one" + } + // when + loginRepository.logout() + + // then + assert(fakeTokensStore.last() == null) { + "After logout, there should not be stored credentials" + } + } + + @Test + fun `after login and logout, a CredentialsUpdatedMessage is sent`() = runTest { + messageBus.test { + // given + createLoginRepository(FakeLoginService()) + + // when + loginRepository.getLoginUri(loginUri, null) + loginRepository.getCredentialsFromLoginCode(VALID_URI) + + // then + assert(awaitItem() is CredentialsUpdatedMessage) { + "After saving a token, a CredentialsUpdatedMessage should be sent" + } + + // when + loginRepository.logout() + + // then + assert(awaitItem() is CredentialsUpdatedMessage) { + "After saving a token, a CredentialsUpdatedMessage should be sent" + } + } + } + + @Test + fun `setCredentials stores the submitted values in the TokenStore and overwrites any existing ones`() = + runTest { + // given + val oldUserId = "old" + val newUserId = "new" + val oldRefreshToken = "oldRefreshToken" + val newRefreshToken = "newRefreshToken" + + val oldTokens = Tokens( + makeCredentials( + userId = oldUserId, + isExpired = true, + ), + oldRefreshToken, + ) + val newTokens = Tokens( + makeCredentials( + userId = newUserId, + isExpired = true, + ), + newRefreshToken, + ) + createLoginRepository(FakeLoginService()) + fakeTokensStore.saveTokens(oldTokens) + + // when + loginRepository.setCredentials(newTokens.credentials, newRefreshToken) + + // then + assert(fakeTokensStore.saves == 2) { + "Two tokens should have been saved" + } + assert(fakeTokensStore.last()!!.credentials.userId == newUserId) { + "The last saved token should have the new userId" + } + assert(fakeTokensStore.last()!!.refreshToken == newRefreshToken) { + "The last saved token should have the new refreshToken" + } + } + + @Test + fun `credentials sent via setCredentials trigger a CredentialsUpdatedMessage`() = + runTest { + messageBus.test { + // given + val oldUserId = "old" + val newUserId = "new" + val oldRefreshToken = "oldRefreshToken" + val newRefreshToken = "newRefreshToken" + + val oldTokens = Tokens( + makeCredentials( + userId = oldUserId, + isExpired = true, + ), + oldRefreshToken, + ) + val newTokens = Tokens( + makeCredentials( + userId = newUserId, + isExpired = true, + ), + newRefreshToken, + ) + createLoginRepository(FakeLoginService()) + fakeTokensStore.saveTokens(oldTokens) + + // when + loginRepository.setCredentials(newTokens.credentials, newRefreshToken) + + // then + assert(awaitItem() is CredentialsUpdatedMessage) { + "A CredentialsUpdatedMessage should have been sent after saving" + } + } + } + + companion object { + private const val QUERY_PREFIX = "(&|\\?)" + private const val VALID_URI = + "https://tidal.com/android/login/auth&code=123&appMode=android" + } +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/auth/TokenRepositoryTest.kt b/auth/src/test/kotlin/com/tidal/sdk/auth/TokenRepositoryTest.kt new file mode 100644 index 00000000..e36c2aba --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/auth/TokenRepositoryTest.kt @@ -0,0 +1,758 @@ +package com.tidal.sdk.auth + +import app.cash.turbine.test +import com.tidal.sdk.auth.FakeTokenService.CallType +import com.tidal.sdk.auth.login.FakeTokensStore +import com.tidal.sdk.auth.model.ApiErrorSubStatus +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.CredentialsUpdatedMessage +import com.tidal.sdk.auth.model.Tokens +import com.tidal.sdk.auth.util.RetryPolicy +import com.tidal.sdk.auth.util.UpgradeTokenRetryPolicy +import com.tidal.sdk.auth.util.buildTestHttpException +import com.tidal.sdk.common.RetryableError +import com.tidal.sdk.common.TidalMessage +import com.tidal.sdk.util.CoroutineTestTimeProvider +import com.tidal.sdk.util.TEST_CLIENT_ID +import com.tidal.sdk.util.TEST_CLIENT_UNIQUE_KEY +import com.tidal.sdk.util.TEST_TIME_PROVIDER +import com.tidal.sdk.util.makeCredentials +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okio.IOException +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class TokenRepositoryTest { + + private val messageBus: MutableSharedFlow = MutableSharedFlow() + private val testRetryPolicy = object : RetryPolicy { + override val numberOfRetries = 1 + override val delayMillis = 1 + override val delayFactor = 1 + } + + private lateinit var authConfig: AuthConfig + private lateinit var fakeTokensStore: FakeTokensStore + private lateinit var fakeTokenService: FakeTokenService + private lateinit var tokenRepository: TokenRepository + + private fun createTokenRepository( + tokenService: FakeTokenService, + tokensStore: FakeTokensStore = FakeTokensStore(authConfig.credentialsKey), + defaultRetrypolicy: RetryPolicy = testRetryPolicy, + upgradeRetryPolicy: RetryPolicy = testRetryPolicy, + bus: MutableSharedFlow = messageBus, + ) { + fakeTokenService = tokenService + fakeTokensStore = tokensStore + tokenRepository = TokenRepository( + authConfig, + TEST_TIME_PROVIDER, + tokensStore, + tokenService, + defaultRetrypolicy, + upgradeRetryPolicy, + bus, + ) + } + + private fun createAuthConfig( + clientId: String = TEST_CLIENT_ID, + clientUniqueKey: String = TEST_CLIENT_UNIQUE_KEY, + scopes: Set = setOf(), + secret: String? = null, + ) { + authConfig = AuthConfig( + clientId = clientId, + clientUniqueKey = clientUniqueKey, + clientSecret = secret, + credentialsKey = "credentialsKey", + scopes = scopes, + enableCertificatePinning = false, + ) + } + + @BeforeEach + fun setup() { + createAuthConfig() + } + + @Test + fun `getCredentials returns a stored token if it is not yet expired`() = runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = false, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + + // when + val result = tokenRepository.getCredentials(null) + + // then + assert(fakeTokensStore.gets == 2) { + "There should have been two calls to the TokensStore" + } + assert(result.successData!! == tokens.credentials) { + "Returned Credentials should be the same that was last stored via Tokens" + } + } + + @Test + fun `getCredentials returns Credentials without token if called when logged out and no clientSecret is set`() = + runTest { + // given + createTokenRepository(FakeTokenService()) + + // when + val result = tokenRepository.getCredentials(null) + + // then + assert(result.successData!!.token == null) { + "If logged out, calling getCredentials() should return an Credentials with token = null" + } + } + + @Test + fun `getCredentials gets a new one from backend and returns it if stored token is expired`() = + runTest { + // given + val credentials = makeCredentials( + userId = "invalid", + isExpired = true, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + + // when + val result = tokenRepository.getCredentials(null) + + // then + assert(fakeTokenService.calls.any { it == CallType.Refresh }) { + "If the stored token is about to expire, a call to TokenService should be made" + } + assert(result.successData!!.isExpired(TEST_TIME_PROVIDER).not()) { + "The returned token should be the one retrieved from the service" + } + } + + @Test + fun `getCredentials refreshes the token if the subStatus should trigger a refresh`() = runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = false, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + ApiErrorSubStatus.entries.filter { it.shouldTriggerRefresh }.forEach { status -> + // when + tokenRepository.getCredentials(status.value) + // then + assert(fakeTokenService.calls.any { it == CallType.Refresh }) { + "If the subStatus triggers a refresh, a call to TokenService should be made" + } + } + } + + @Test + fun `getCredentials does not refresh the token if the subStatus does not trigger a refresh`() = + runTest { + // given + val imaginarySubStatus = "50123" + val credentials = makeCredentials( + userId = "valid", + isExpired = false, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + + // when + tokenRepository.getCredentials(imaginarySubStatus) + // then + assert(fakeTokenService.calls.none { it == CallType.Refresh }) { + "If the subStatus is not on the list, no call to TokenService should be made" + } + } + + @Test + fun `getCredentials retries and finally returns a RetryableError if refreshing from backend fails`() = + runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = true, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + val service = FakeTokenService().apply { + throwableToThrow = buildTestHttpException(503) + } + val expectedCalls = testRetryPolicy.numberOfRetries + 1 + createTokenRepository(service) + fakeTokensStore.saveTokens(tokens) + + // when + val result = tokenRepository.getCredentials(null) + + // then + assert( + fakeTokenService.calls.filter { + it == CallType.Refresh + }.size == expectedCalls, + ) { + "If the stored token is invalid, the correct number of call attempts to TokenService should be made" + } + assert((result as AuthResult.Failure).message is RetryableError) { + "If the backend call fails, a RetryableError should be returned" + } + } + + @Test + fun `getCredentials returns a lover level token if backend fails with a 400 error`() = runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = true, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + val service = FakeTokenService().apply { + throwableToThrow = buildTestHttpException(400) + } + createTokenRepository(service) + fakeTokensStore.saveTokens(tokens) + + // when + val result = tokenRepository.getCredentials(null) + + // then + assert( + fakeTokenService.calls.filter { + it == CallType.Refresh + }.size == 1, + ) { + "On 400 errors, no retries should be made" + } + assert(result.successData!!.userId == null) { + "When a 400 error is received" + } + assert(fakeTokensStore.last()!!.credentials == result.successData) { + "The lower privileges token should have been saved in the store" + } + } + + @Test + fun `getCredentials returns a lover level token if backend fails with a 401 error`() = runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = true, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + val service = FakeTokenService().apply { + throwableToThrow = buildTestHttpException(401) + } + createTokenRepository(service) + fakeTokensStore.saveTokens(tokens) + + // when + val result = tokenRepository.getCredentials(null) + + // then + assert( + fakeTokenService.calls.filter { + it == CallType.Refresh + }.size == 1, + ) { + "On 401 errors, no retries should be made" + } + assert(result.successData!!.userId == null) { + "When a 401 error is received" + } + assert(fakeTokensStore.last()!!.credentials == result.successData) { + "The lower privileges token should have been saved in the store" + } + } + + @Test + fun `getCredentials stores a freshly retrieved token in CredentialsStore `() = runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = true, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + + // when + tokenRepository.getCredentials(null) + + // then + assert(fakeTokensStore.saves == 2) { + "The freshly retrieved token should have benn saved" + } + } + + @Test + fun `getCredentials storing a freshly retrieved token the store triggers the emission of a CredentialsUpdatedMessage `() = + runTest { + messageBus.test { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = true, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + + // when + tokenRepository.getCredentials(null) + + // then + assert(awaitItem() is CredentialsUpdatedMessage) { + "A CredentialsUpdatedMessage should have been sent after saving" + } + } + } + + @Test + fun `when no refresh token is present and the access token is expired, getCredentials gets a token using the client secret when present`() = + runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = true, + ) + val tokens = Tokens( + credentials, + null, + ) + val secret = "myLittleSecret" + createAuthConfig(secret = secret) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + + // when + tokenRepository.getCredentials(null) + val result = fakeTokensStore.last() + + // then + assert(fakeTokenService.calls.any { it == CallType.Secret }) { + "If the stored token is expired, a call to TokenService.getTokenFromClientSecret should be made" + } + assert(fakeTokensStore.saves == 2) { + "If a new token is retrieved, it is stored" + } + assert(result!!.refreshToken == null) { + "Retrieved credentials should have no refreshToken" + } + } + + @Test + fun `getCredentials returns a client Credentials when secret is available but no credentials are stored`() = + runTest { + // given + val secret = "myLittleSecret" + createAuthConfig(secret = secret) + createTokenRepository(FakeTokenService()) + + // when + tokenRepository.getCredentials(null) + val result = fakeTokensStore.last() + + // then + assert(fakeTokenService.calls.any { it == CallType.Secret }) { + "If the stored token is about to expire, a call to TokenService.getTokenFromClientSecret should be made" + } + assert(fakeTokensStore.saves == 1) { + "If a new token is retrieved, it is stored" + } + assert(result!!.refreshToken == null) { + "Retrieved credentials should have no refreshToken" + } + } + + @Test + fun `getCredentials returns a user Credentials when secret is available but refreshToken is present`() = + runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = true, + ) + val tokens = Tokens( + credentials, + "refreshToken is present", + ) + val secret = "myLittleSecret" + createAuthConfig(secret = secret) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + + // when + tokenRepository.getCredentials(null) + val result = fakeTokensStore.last() + + // then + assert(fakeTokenService.calls.size == 1) { + "A call to the service should have been made" + } + assert(fakeTokensStore.saves == 2) { + "If a new token is retrieved, it is stored" + } + assert(result!!.refreshToken != null) { + "Retrieved credentials should have a refreshToken" + } + } + + @Test + fun `if a refreshToken is available, it is still in store after refresh`() = runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = true, + ) + val refreshToken = "refreshToken is present" + val tokens = Tokens( + credentials, + refreshToken, + ) + val secret = "myLittleSecret" + createAuthConfig(secret = secret) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + + // when + tokenRepository.getCredentials(null) + + // then + assert(fakeTokensStore.saves == 2) { + "A refreshed token should have been saved" + } + assert(fakeTokensStore.last()!!.refreshToken == refreshToken) { + "An existing refreshToken should not disappear during refresh" + } + + // when + tokenRepository.getCredentials(null) + + // then + assert(fakeTokensStore.saves == 2) { + "No further token should have been saved since the current one is still valid" + } + assert(fakeTokensStore.last()!!.refreshToken == refreshToken) { + "An existing refreshToken should not disappear during refresh" + } + } + + @Test + fun `On getCredentials call, an upgrade request is issued if clientId has changed`() = runTest { + // given + val credentials = makeCredentials( + userId = "userId", + isExpired = true, + ) + val refreshToken = "refreshToken" + val tokens = Tokens( + credentials, + refreshToken, + ) + val secret = "myLittleSecret" + createAuthConfig(clientId = "someClientId", secret = secret) + fakeTokensStore = FakeTokensStore(authConfig.credentialsKey).apply { + saveTokens(tokens) + } + + // when + createAuthConfig(clientId = "anotherClientId", secret = secret) + createTokenRepository( + tokenService = FakeTokenService(), + tokensStore = fakeTokensStore, + ) + + val result = tokenRepository.getCredentials(null) + + // then + assert(fakeTokenService.calls.filter { it == CallType.Upgrade }.size == 1) { + "TokenService should have been called once" + } + assert(result.successData!!.token == "upgradeCredentials") { + "The returned result should contain the token received in the upgrade resonse" + } + assert(fakeTokensStore.last()!!.refreshToken!! == "upgradeRefreshToken") { + "The new credentials saved in the store should contain the upgraded refreshToken" + } + } + + @Test + fun `upgradeToken calls should be retried as specified in UpgradePolicy`() = runTest { + // given + val credentials = makeCredentials( + userId = "userId", + isExpired = true, + ) + val refreshToken = "refreshToken" + val tokens = Tokens( + credentials, + refreshToken, + ) + val secret = "myLittleSecret" + val upgradeRetryPolicy = UpgradeTokenRetryPolicy() + createAuthConfig(clientId = "someClientId", secret = secret) + fakeTokensStore = FakeTokensStore(authConfig.credentialsKey).apply { + saveTokens(tokens) + } + + // when + createAuthConfig(clientId = "anotherClientId", secret = secret) + createTokenRepository( + tokenService = FakeTokenService().apply { + throwableToThrow = buildTestHttpException(503) + }, + tokensStore = fakeTokensStore, + upgradeRetryPolicy = UpgradeTokenRetryPolicy(), + ) + val expectedRetries = upgradeRetryPolicy.numberOfRetries + 1 + tokenRepository.getCredentials(null) + + // then + assert(fakeTokenService.calls.filter { it == CallType.Upgrade }.size == expectedRetries) { + "TokenService should have been called $expectedRetries times" + } + + // when + fakeTokenService.calls.clear() + createAuthConfig(clientId = "anotherClientId", secret = secret) + createTokenRepository( + tokenService = FakeTokenService().apply { + throwableToThrow = buildTestHttpException(401) + }, + tokensStore = fakeTokensStore, + upgradeRetryPolicy = UpgradeTokenRetryPolicy(), + ) + tokenRepository.getCredentials(null) + + // then + assert(fakeTokenService.calls.filter { it == CallType.Upgrade }.size == expectedRetries) { + "TokenService should have been called $expectedRetries times" + } + + // when + fakeTokenService.calls.clear() + createAuthConfig(clientId = "anotherClientId", secret = secret) + createTokenRepository( + tokenService = FakeTokenService().apply { + throwableToThrow = IOException() + }, + tokensStore = fakeTokensStore, + upgradeRetryPolicy = UpgradeTokenRetryPolicy(), + ) + tokenRepository.getCredentials(null) + + // then + assert(fakeTokenService.calls.filter { it == CallType.Upgrade }.size == expectedRetries) { + "TokenService should have been called $expectedRetries times" + } + } + + @Test + fun `Failed upgradeToken calls should return a RetryableError`() = runTest { + // given + val credentials = makeCredentials( + userId = "userId", + isExpired = true, + ) + val refreshToken = "refreshToken" + val tokens = Tokens( + credentials, + refreshToken, + ) + val secret = "myLittleSecret" + createAuthConfig(clientId = "someClientId", secret = secret) + fakeTokensStore = FakeTokensStore(authConfig.credentialsKey).apply { + saveTokens(tokens) + } + + // when + createAuthConfig(clientId = "anotherClientId", secret = secret) + createTokenRepository( + tokenService = FakeTokenService().apply { + throwableToThrow = buildTestHttpException(503) + }, + tokensStore = fakeTokensStore, + upgradeRetryPolicy = UpgradeTokenRetryPolicy(), + ) + val result1 = tokenRepository.getCredentials(null) + + // when + createTokenRepository( + tokenService = FakeTokenService().apply { + throwableToThrow = buildTestHttpException(503) + }, + tokensStore = fakeTokensStore, + upgradeRetryPolicy = UpgradeTokenRetryPolicy(), + ) + fakeTokenService.throwableToThrow = buildTestHttpException(401) + val result2 = tokenRepository.getCredentials(null) + + // when + createTokenRepository( + tokenService = FakeTokenService().apply { + throwableToThrow = buildTestHttpException(503) + }, + tokensStore = fakeTokensStore, + upgradeRetryPolicy = UpgradeTokenRetryPolicy(), + ) + fakeTokenService.throwableToThrow = buildTestHttpException(503) + val result3 = tokenRepository.getCredentials(null) + + // then + setOf(result1, result2, result3).forEach { + assert((it as AuthResult.Failure).message!! is RetryableError) { + "Every fauled token upgrade should result in a RetryableError" + } + } + } + + @Test + fun `A call to getCredentials should not trigger a backend call if one is already in progress`() = + runTest { + // This test calls getCredentials 100 times in a row, to check if the synchronization + // works as intended, resulting in just a single call ever made to the backend. + + // given + val dispatcher = StandardTestDispatcher() + val coroutineTimeProvider = + CoroutineTestTimeProvider(dispatcher, CoroutineTestTimeProvider.Mode.MILLISECONDS) + val credentials = makeCredentials( + userId = "valid", + isExpired = true, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + val secret = "myLittleSecret" + createAuthConfig(secret = secret) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + + // when + coroutineTimeProvider.startTimeFor(this, 100) + launch { + repeat(100) { + tokenRepository.getCredentials(null) + } + } + advanceUntilIdle() + + // then + assert(fakeTokenService.calls.filter { it == CallType.Refresh }.size == 1) { + "Only one call to the backend should have been made" + } + } + + @Test + fun `getLatestTokens returns tokens from memory if possible`() = runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = false, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + createTokenRepository(FakeTokenService()) + fakeTokensStore.saveTokens(tokens) + + // when + val result = tokenRepository.getLatestTokens() + + // then + assert(result == tokens) { + "The returned credentials should be the same as the ones held in the token store" + } + assert(fakeTokensStore.gets == 1) { + "The token store should have been queried once" + } + assert(fakeTokensStore.loads == 0) { + "The token store should have never been queried" + } + assert(fakeTokenService.calls.isEmpty()) { + "No calls to the backend should have been made" + } + } + + @Test + fun `getLatestTokens returns tokens from storage if possible`() = runTest { + // given + val credentials = makeCredentials( + userId = "valid", + isExpired = false, + ) + val tokens = Tokens( + credentials, + "refreshToken", + ) + createTokenRepository( + FakeTokenService(), + FakeTokensStore(authConfig.credentialsKey, tokens), + ) + + // when + val result = tokenRepository.getLatestTokens() + + // then + assert(result == tokens) { + "The returned credentials should be the same as the ones held in the token store" + } + assert(fakeTokensStore.gets == 1) { + "The token store should have been queried once" + } + assert(fakeTokensStore.loads == 1) { + "The token store should have been queried once" + } + assert(fakeTokenService.calls.isEmpty()) { + "No calls to the backend should have been made" + } + } +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/auth/login/FakeTokensStore.kt b/auth/src/test/kotlin/com/tidal/sdk/auth/login/FakeTokensStore.kt new file mode 100644 index 00000000..2cd3e30e --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/auth/login/FakeTokensStore.kt @@ -0,0 +1,46 @@ +package com.tidal.sdk.auth.login + +import com.tidal.sdk.auth.model.Tokens +import com.tidal.sdk.auth.storage.TokensStore + +internal class FakeTokensStore( + val credentialsKey: String, + val storedTokens: Tokens? = null, +) : TokensStore { + + var saves = 0 + var gets = 0 + var loads = 0 + + var tokensList = mutableListOf() + + /** + * Please be aware that this mimicks the behaviour of [DefaultTokensStore] without + * having an actual database. So if the implementation changes, this should be updated. + * We aim to improve this in the near future though. Until then, you are aware now. + */ + override fun getLatestTokens(key: String): Tokens? { + gets += 1 + if (key != credentialsKey) return null + val lastTokens = last() + return if (lastTokens != null) { + lastTokens + } else { + loads += 1 + storedTokens + } + } + + override fun saveTokens(tokens: Tokens) { + saves += 1 + tokensList.add(tokens) + } + + override fun eraseTokens() { + tokensList.clear() + } + + fun last(): Tokens? { + return tokensList.lastOrNull() + } +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/auth/login/RedirectUriTest.kt b/auth/src/test/kotlin/com/tidal/sdk/auth/login/RedirectUriTest.kt new file mode 100644 index 00000000..4cce018e --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/auth/login/RedirectUriTest.kt @@ -0,0 +1,56 @@ +package com.tidal.sdk.auth.login + +import org.junit.jupiter.api.Test + +class RedirectUriTest { + + @Test + fun `instance creation with no code in the submitted string should return Error subclass`() { + // given + val wrongUri = "https://tidal.com/android/login/auth?state=na&appMode=android&lang=en" + + // when + val redirectUri = RedirectUri.fromUriString(wrongUri) + + // then + assert(redirectUri is RedirectUri.Failure) { + "Creating a RedirectUri from a string missing a 'code' parameter should result in a RedirectUri.Failure type" + } + } + + @Test + fun `instance creation with error in the submitted string should return Error subclass`() { + // given + val wrongUri = + "https://tidal.com/android/login/auth?error=someMadeUpError?error_description=helloWorld&lang=en" + + // when + val redirectUri = RedirectUri.fromUriString(wrongUri) + + // then + assert(redirectUri is RedirectUri.Failure) { + "Creating a RedirectUri from a string with an error parameter should result in a RedirectUri.Failure type" + } + assert((redirectUri as RedirectUri.Failure).errorMessage == "helloWorld") { + "A RedirectUri created from a string with an error parameter should have its 'errorMessage' field set correctly" + } + } + + @Test + fun `instance creation satisfying the requirements should return a correct Correct subclass`() { + // given + val correctUri = + "https://tidal.com/android/login/auth?state=na&appMode=android&lang=en&code=HERE_BE_CODE" + + // when + val redirectUri = RedirectUri.fromUriString(correctUri) + + // then + assert(redirectUri is RedirectUri.Success) { + "Creating a RedirectUri from a correct uri string should result in a RedirectUri.Success type" + } + assert((redirectUri as RedirectUri.Success).code == "HERE_BE_CODE") { + "A RedirectUri.Success should have the correct value in its 'code' property" + } + } +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/auth/model/CredentialsTest.kt b/auth/src/test/kotlin/com/tidal/sdk/auth/model/CredentialsTest.kt new file mode 100644 index 00000000..32a2fdee --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/auth/model/CredentialsTest.kt @@ -0,0 +1,148 @@ +package com.tidal.sdk.auth.model + +import com.tidal.sdk.util.TestTimeProvider +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.datetime.Clock.System.now +import org.junit.jupiter.api.Test + +class CredentialsTest { + + private val timeProvider = TestTimeProvider() + + @Test + fun `An access token expiring in more than a minute is valid`() { + // given + val time = now() + val expiry = time.plus(2.toDuration(DurationUnit.MINUTES)) + val token = Credentials( + "", + setOf(), + "", + setOf(), + "", + expiry, + "", + ) + + // when + val isValid = token.isExpired(timeProvider).not() + + // then + assert(isValid) { + "A token that expires in more than one minute should consider itself valid" + } + } + + @Test + fun `An access token expiring in less than a minute is not valid`() { + // given + val time = now() + val expiry = time.plus(30.toDuration(DurationUnit.SECONDS)) + val token = Credentials( + "", + setOf(), + "", + setOf(), + "", + expiry, + "", + ) + + // when + val isValid = token.isExpired(timeProvider).not() + + // then + assert(!isValid) { + "A token that expires in less than one minute should consider itself invalid" + } + } + + @Test + fun `An access token with no expiry is not valid`() { + // given + val token = Credentials( + "", + setOf(), + "", + setOf(), + "", + null, + "", + ) + + // when + val isValid = token.isExpired(timeProvider).not() + + // then + assert(!isValid) { + "A token that has no set expiry should consider itself invalid" + } + } + + @Test + fun `An access token with userId and token is level USER`() { + // given + val token = Credentials( + "", + setOf(), + "", + setOf(), + "userId", + null, + "token", + ) + + // when + val level = token.level + + // then + assert(level == Credentials.Level.USER) { + "The token should be Level.USER" + } + } + + @Test + fun `An access token without userId,but with token is level CLIENT`() { + // given + val token = Credentials( + "", + setOf(), + "", + setOf(), + null, + null, + "token", + ) + + // when + val level = token.level + + // then + assert(level == Credentials.Level.CLIENT) { + "The token should be Level.Client" + } + } + + @Test + fun `An access token without userId and without token is level BASIC`() { + // given + val token = Credentials( + "", + setOf(), + "", + setOf(), + null, + null, + null, + ) + + // when + val level = token.level + + // then + assert(level == Credentials.Level.BASIC) { + "The token should be Level.BASIC" + } + } +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/auth/storage/DefaultTokensStoreTest.kt b/auth/src/test/kotlin/com/tidal/sdk/auth/storage/DefaultTokensStoreTest.kt new file mode 100644 index 00000000..1d00f3eb --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/auth/storage/DefaultTokensStoreTest.kt @@ -0,0 +1,89 @@ +package com.tidal.sdk.auth.storage + +import android.content.SharedPreferences +import com.tidal.sdk.auth.model.Tokens +import com.tidal.sdk.util.makeCredentials +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.test.assertEquals +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class DefaultTokensStoreTest { + private val testCredentialsKey = "testKey" + private var isPrefsEmpty = true + private lateinit var sharedPreferences: SharedPreferences + private lateinit var editor: SharedPreferences.Editor + private lateinit var tokens: Tokens + private lateinit var defaultTokensStore: DefaultTokensStore + + @BeforeEach + fun setUp() { + sharedPreferences = mockk() + editor = mockk(relaxed = true) + tokens = Tokens( + credentials = makeCredentials(isExpired = false), + refreshToken = + "refreshToken" + ) + + every { sharedPreferences.edit() } returns editor + every { editor.putString(any(), any()) } answers { + isPrefsEmpty = false + editor + } + every { editor.clear() } answers { + isPrefsEmpty = true + editor + } + every { sharedPreferences.getString(any(), any()) } answers { + if (isPrefsEmpty) null else Json.encodeToString(tokens) + } + + defaultTokensStore = DefaultTokensStore(testCredentialsKey, sharedPreferences) + } + + @Test + fun `getLatestTokens returns correct tokens when key matches`() { + // given + isPrefsEmpty = false + + // when + val result = defaultTokensStore.getLatestTokens(testCredentialsKey) + + assert(result == tokens) + } + + @Test + fun `saveTokens correctly saves tokens to shared preferences`() { + // when + defaultTokensStore.saveTokens(tokens) + + // then + verify { editor.putString(any(), any()) } + verify { editor.apply() } + assertEquals( + defaultTokensStore.getLatestTokens(testCredentialsKey), + tokens, + "Tokens passed in for saving should be served when calling getLatestTokens." + ) + } + + @Test + fun `eraseTokens correctly erases tokens from shared preferences`() { + // when + defaultTokensStore.eraseTokens() + + // then + verify { editor.clear() } + verify { editor.apply() } + assertEquals( + defaultTokensStore.getLatestTokens(testCredentialsKey), + null, + "Tokens should be null after erasing." + ) + } +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/auth/util/RetryWithPolicyTest.kt b/auth/src/test/kotlin/com/tidal/sdk/auth/util/RetryWithPolicyTest.kt new file mode 100644 index 00000000..c25e842f --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/auth/util/RetryWithPolicyTest.kt @@ -0,0 +1,87 @@ +package com.tidal.sdk.auth.util + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import retrofit2.HttpException + +class RetryWithPolicyTest { + + @Test + fun `Retry the defined number of times when receiving server errors`() = runTest { + // given + val testPolicy = object : RetryPolicy { + override val numberOfRetries = 5 + override val delayMillis = 1 + override val delayFactor = 1 + } + val exception = buildTestHttpException(503) + var attempts = 0 + fun throwIt(): Int { + attempts += 1 + throw exception + } + + // when + retryWithPolicy(testPolicy) { throwIt() } + + // then + assert(attempts == 6) { + "The function should have made 6 attempts (1 + 5 retries)" + } + } + + @Test + fun `Stop retrying on non-server errors`() = runTest { + // given + val testPolicy = object : RetryPolicy { + override val numberOfRetries = 5 + override val delayMillis = 1 + override val delayFactor = 1 + } + + val exception = buildTestHttpException(401) + var attempts = 0 + fun throwIt(): Int { + attempts += 1 + throw exception + } + + // when + retryWithPolicy(testPolicy) { throwIt() } + + // then + assert(attempts == 1) { + "The function should have made just 1 attempt" + } + } + + @Test + fun `Retry the defined number of times while custom shouldRetry function is true`() = runTest { + // given + val errorCode = 999 + val testPolicy = object : RetryPolicy { + override val numberOfRetries = 5 + override val delayMillis = 1 + override val delayFactor = 1 + + override fun shouldRetryOnException(throwable: Throwable): Boolean { + return (throwable as? HttpException)?.code() == errorCode + } + } + + val exception = buildTestHttpException(errorCode) + var attempts = 0 + fun throwIt(): Int { + attempts += 1 + throw exception + } + + // when + retryWithPolicy(testPolicy) { throwIt() } + + // then + assert(attempts == 6) { + "The function should have made 6 attempts (1 + 5 retries)" + } + } +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/auth/util/TestUtils.kt b/auth/src/test/kotlin/com/tidal/sdk/auth/util/TestUtils.kt new file mode 100644 index 00000000..d47febbe --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/auth/util/TestUtils.kt @@ -0,0 +1,26 @@ +package com.tidal.sdk.auth.util + +import com.tidal.sdk.auth.model.ErrorResponse +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.HttpException +import retrofit2.Response + +/** + * @return a [retrofit2.HttpException] with the submitted error code + */ +fun buildTestHttpException(code: Int): HttpException { + val response = Response.error(code, "response".toResponseBody()) + return HttpException(response) +} + +/** + * @return a [retrofit2.HttpException] with the submitted error response + */ +internal fun buildTestHttpException(errorResponse: ErrorResponse): HttpException { + val encoded = Json.encodeToString(errorResponse) + val body = encoded.toResponseBody() + val response = Response.error(errorResponse.status, body) + return HttpException(response) +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/util/CoroutineTestTimeProvider.kt b/auth/src/test/kotlin/com/tidal/sdk/util/CoroutineTestTimeProvider.kt new file mode 100644 index 00000000..ae7bcd5a --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/util/CoroutineTestTimeProvider.kt @@ -0,0 +1,72 @@ +package com.tidal.sdk.util + +import com.tidal.sdk.auth.util.TimeProvider +import com.tidal.sdk.util.CoroutineTestTimeProvider.Mode +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.datetime.Instant + +/** + * [TimeProvider] implementation that can be used to control time flow inside a + * coroutine by seconds. Since all classes inside `TidalAuth` use a [TimeProvider] to + * determine current or elapsed times, even non-coroutine code will be synced with + * the time in this [TimeProvider]. Using it with [runTest] enables us to + * control and measure time without having to wait the actual time frame. + * @param dispatcher The [TestDispatcher] to use. + * @param mode The [Mode] to use. Default is [Mode.SECONDS], so when running, time will tick + * forward 1 second at a time. If you need it more fine-grained, use [Mode.MILLISECONDS]. + */ +class CoroutineTestTimeProvider( + private val dispatcher: TestDispatcher, + val mode: Mode = Mode.SECONDS, +) : TimeProvider { + + override val now + get() = Instant.fromEpochMilliseconds(getTime()) + + private var timeJob: Job? = null + + private fun getTime(): Long { + return dispatcher.scheduler.currentTime + } + + fun startTimeFor(scope: CoroutineScope, units: Int) { + if (mode == Mode.SECONDS) { + startTimeForSeconds(scope, units) + } else { + startTimeForMillis(scope, units) + } + } + + private fun startTimeForSeconds(scope: CoroutineScope, units: Int) { + var unitsElapsed = 1 + timeJob = scope.launch { + while (unitsElapsed < units) { + delay(1000) + dispatcher.scheduler.advanceTimeBy(1.toDuration(DurationUnit.SECONDS)) + unitsElapsed += 1 + } + } + } + + private fun startTimeForMillis(scope: CoroutineScope, units: Int) { + var unitsElapsed = 1 + timeJob = scope.launch { + while (unitsElapsed < units) { + delay(1) + dispatcher.scheduler.advanceTimeBy(1.toDuration(DurationUnit.MILLISECONDS)) + unitsElapsed += 1 + } + } + } + + enum class Mode { + SECONDS, + MILLISECONDS, + } +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/util/TestTimeProvider.kt b/auth/src/test/kotlin/com/tidal/sdk/util/TestTimeProvider.kt new file mode 100644 index 00000000..93922c48 --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/util/TestTimeProvider.kt @@ -0,0 +1,22 @@ +package com.tidal.sdk.util + +import com.tidal.sdk.auth.util.TimeProvider +import io.fluidsonic.time.ManualClock +import io.fluidsonic.time.advance +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.datetime.Clock + +class TestTimeProvider : TimeProvider { + val clock = ManualClock().apply { + set(Clock.System.now()) + } + + override val now + get() = clock.now() + + fun advanceSeconds(seconds: Int) = + clock.advance(seconds.toDuration(DurationUnit.SECONDS)).also { + clock.set(it) + } +} diff --git a/auth/src/test/kotlin/com/tidal/sdk/util/TestUtils.kt b/auth/src/test/kotlin/com/tidal/sdk/util/TestUtils.kt new file mode 100644 index 00000000..f18d8acb --- /dev/null +++ b/auth/src/test/kotlin/com/tidal/sdk/util/TestUtils.kt @@ -0,0 +1,35 @@ +package com.tidal.sdk.util + +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.auth.util.TimeProvider +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +const val TEST_CLIENT_ID = "testClientId" +const val TEST_CLIENT_UNIQUE_KEY = "testClientUniqueKey" + +internal val TEST_TIME_PROVIDER: TimeProvider by lazy { TestTimeProvider() } + +fun makeCredentials( + clientId: String = TEST_CLIENT_ID, + clientUniqueKey: String = TEST_CLIENT_UNIQUE_KEY, + scopes: Set = setOf(), + isExpired: Boolean, + userId: String? = "userId", + token: String = "token", +): Credentials { + val expiry = if (isExpired) { + TEST_TIME_PROVIDER.now.minus(5.toDuration(DurationUnit.MINUTES)) + } else { + TEST_TIME_PROVIDER.now.plus(5.toDuration(DurationUnit.MINUTES)) + } + return Credentials( + clientId = clientId, + requestedScopes = scopes, + clientUniqueKey = clientUniqueKey, + grantedScopes = setOf(), + userId = userId, + expires = expiry, + token = token, + ) +} diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts new file mode 100644 index 00000000..301fdb0b --- /dev/null +++ b/bom/build.gradle.kts @@ -0,0 +1,27 @@ +import com.tidal.sdk.sdkModules +import org.gradle.api.internal.artifacts.verification.exceptions.DependencyVerificationException + +plugins { + alias(libs.plugins.tidal.jvm.platform) +} + +dependencies { + constraints { + val dependencies = mutableListOf>() + rootProject.sdkModules + .filterNot { it === project } + .forEach { + val expectedDependencyName = "tidal-sdk-${it.name}" + try { + dependencies.add( + versionCatalogs.named("libs").findLibrary(expectedDependencyName).get(), + ) + } catch (ignored: NoSuchElementException) { + throw DependencyVerificationException( + "Dependency $expectedDependencyName missing in version catalog", + ) + } + } + dependencies.forEach { api(it) } + } +} diff --git a/bom/gradle.properties b/bom/gradle.properties new file mode 100644 index 00000000..670612ec --- /dev/null +++ b/bom/gradle.properties @@ -0,0 +1,2 @@ +projectDescription=This artifact ensures usage of compatible versions across imported SDK modules. For more information, see https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html#bill-of-materials-bom-poms +version=4 diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..14992544 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,38 @@ +import com.tidal.sdk.sdkModules +import java.time.LocalDate + +plugins { + alias(libs.plugins.kotlin.dokka) + alias(libs.plugins.tidal.android.application) apply false + alias(libs.plugins.tidal.android.library) apply false + alias(libs.plugins.tidal.kotlin.jvm) apply false + alias(libs.plugins.kotlin.kapt) apply false + alias(libs.plugins.tidal.jvm.platform) apply false +} + +tasks.register("printSdkModules") { + doLast { + sdkModules.forEach { println(it.name) } + } +} + +sdkModules.forEach { + rootProject.tasks.register("publish-sdk-module-${it.name}") { + (it.subprojects + it).forEach { + it.tasks.findByName("publishToMavenCentral")?.let { + dependsOn(it) + } + it.tasks.findByName("publishMavenPublicationToGithubPackagesRepository")?.let { + dependsOn(it) + } + } + } +} + +tasks.dokkaHtmlMultiModule.configure { + moduleVersion = "bom-${project(":bom").property("version")}" + includes.setFrom("README.md") + pluginsMapConfiguration.set(mapOf("org.jetbrains.dokka.base.DokkaBase" to + """{"footerMessage": "© ${LocalDate.now().year} TIDAL"}""" + )) +} diff --git a/buildlogic/build.gradle.kts b/buildlogic/build.gradle.kts new file mode 100644 index 00000000..804caa1b --- /dev/null +++ b/buildlogic/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + `kotlin-dsl` +} + +dependencies { + compileOnly(gradleApi()) + implementation(libs.plugin.kotlin.android) + implementation(libs.plugin.dokka) + implementation(libs.plugin.android.tools.build) + implementation(libs.plugin.gradle.maven.publish) +} + +gradlePlugin { + plugins { + register("tidal-sdk-kotlin-jvm") { + id = libs.plugins.tidal.kotlin.jvm.get().pluginId + version = libs.plugins.tidal.kotlin.jvm.get().version + implementationClass = "com.tidal.sdk.plugins.KotlinJvmLibraryConventionPlugin" + } + + register("tidal-sdk-android-library") { + id = libs.plugins.tidal.android.library.get().pluginId + version = libs.plugins.tidal.android.library.get().version + implementationClass = "com.tidal.sdk.plugins.KotlinAndroidLibraryConventionPlugin" + } + + register("tidal-sdk-android-application") { + id = libs.plugins.tidal.android.application.get().pluginId + version = libs.plugins.tidal.android.application.get().version + implementationClass = "com.tidal.sdk.plugins.KotlinAndroidApplicationConventionPlugin" + } + + register("tidal-sdk-jvm-platform") { + id = libs.plugins.tidal.jvm.platform.get().pluginId + version = libs.plugins.tidal.jvm.platform.get().version + implementationClass = "com.tidal.sdk.plugins.JvmPlatformConventionPlugin" + } + } +} diff --git a/buildlogic/gradle/libs.versions.toml b/buildlogic/gradle/libs.versions.toml new file mode 120000 index 00000000..d9b1d4e6 --- /dev/null +++ b/buildlogic/gradle/libs.versions.toml @@ -0,0 +1 @@ +../../gradle/libs.versions.toml \ No newline at end of file diff --git a/buildlogic/settings.gradle.kts b/buildlogic/settings.gradle.kts new file mode 100644 index 00000000..2b818565 --- /dev/null +++ b/buildlogic/settings.gradle.kts @@ -0,0 +1,7 @@ +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/SdkModules.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/SdkModules.kt new file mode 100644 index 00000000..75722da7 --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/SdkModules.kt @@ -0,0 +1,6 @@ +package com.tidal.sdk + +import org.gradle.api.Project + +val Project.sdkModules + get() = rootProject.subprojects.filter { it.parent === rootProject } diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresDokka.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresDokka.kt new file mode 100644 index 00000000..16feb839 --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresDokka.kt @@ -0,0 +1,32 @@ +package com.tidal.sdk.plugins + +import com.tidal.sdk.plugins.constant.PluginId +import java.time.LocalDate +import org.gradle.api.Project +import org.gradle.kotlin.dsl.withType +import org.jetbrains.dokka.gradle.DokkaTaskPartial + +internal class ConfiguresDokka : (Project) -> Unit { + + override fun invoke(target: Project) = with(target) { + pluginManager.apply(PluginId.KOTLIN_DOKKA_PLUGIN_ID) + tasks.withType().configureEach { + dokkaSourceSets.configureEach { + val moduleNameBuilder = StringBuilder(project.name) + var crawler = project + while (crawler.parent !== rootProject) { + crawler = crawler.parent!! + moduleNameBuilder.insert(0, "${crawler.name}-") + } + moduleName.set(moduleNameBuilder.toString()) + includes.setFrom("README.md") + pluginsMapConfiguration.set( + mapOf( + "org.jetbrains.dokka.base.DokkaBase" to + """{"footerMessage": "© ${LocalDate.now().year} TIDAL"}""", + ), + ) + } + } + } +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresGradleProjectVersion.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresGradleProjectVersion.kt new file mode 100644 index 00000000..716e35e7 --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresGradleProjectVersion.kt @@ -0,0 +1,20 @@ +package com.tidal.sdk.plugins + +import org.gradle.api.Project + +internal class ConfiguresGradleProjectVersion : (Project) -> Unit { + + override fun invoke(target: Project) { + var project = target + while (!project.hasVersion) { + project = project.parent ?: break + } + target.version = project.version + } + + /** + * @see [Project.getVersion] + */ + private val Project.hasVersion + get() = !(version as String).contentEquals("unspecified") +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresJUnit5.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresJUnit5.kt new file mode 100644 index 00000000..38ed278f --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresJUnit5.kt @@ -0,0 +1,14 @@ +package com.tidal.sdk.plugins + +import org.gradle.api.Project +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.withType + +internal class ConfiguresJUnit5 : (Project) -> Unit { + + override fun invoke(target: Project) { + target.tasks.withType { + useJUnitPlatform() + } + } +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresKotlinCompiler.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresKotlinCompiler.kt new file mode 100644 index 00000000..3ca90efe --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresKotlinCompiler.kt @@ -0,0 +1,18 @@ +package com.tidal.sdk.plugins + +import org.gradle.api.Project +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +internal class ConfiguresKotlinCompiler : (Project) -> Unit { + + override fun invoke(target: Project) = with(target) { + tasks.withType().configureEach { + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + listOf( + "-Xjvm-default=all" + ) + } + } + } +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresMavenPublish.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresMavenPublish.kt new file mode 100644 index 00000000..53deb6ec --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/ConfiguresMavenPublish.kt @@ -0,0 +1,107 @@ +package com.tidal.sdk.plugins + +import com.tidal.sdk.plugins.constant.PluginId +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import com.vanniktech.maven.publish.SonatypeHost +import java.net.URI +import org.gradle.api.Project +import org.gradle.api.artifacts.dsl.RepositoryHandler +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPomLicense +import org.gradle.kotlin.dsl.configure + +internal class ConfiguresMavenPublish : (Project) -> Unit { + + override fun invoke(target: Project): Unit = with(target) { + pluginManager.apply(PluginId.GRADLE_MAVEN_PUBLISH_PLUGIN_ID) + ConfiguresGradleProjectVersion()(this) + + configure { + repositories.addGithubPackages() + } + + configure { + setPublishJavadoc(this@with, false) + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) + + if (project.properties.hasGpgData()) { + signAllPublications() + } + + val artifactId = this@with.path.toArtifactId() + val version = this@with.version as String + coordinates(SDK_GROUP_ID, artifactId, version) + + pom { + name.set(artifactId) + inceptionYear.set(SDK_INCEPTION_YEAR) + url.set(SDK_GITHUB_URL) + licenses { license { apache() } } + scm { + url.set(SDK_GITHUB_URL) + connection.set(SCM_CONNECTION) + developerConnection.set( + SCM_DEVELOPER_CONNECTION, + ) + } + developers { developer { name.set("TIDAL") } } + project.gradle.projectsEvaluated { + this@pom.description.set(project.properties[PROJECT_DESCRIPTION_KEY] as String?) + } + } + } + } + + private fun Map.hasGpgData() = ( + this.containsKey("signingInMemoryKeyId") && + this.containsKey("signingInMemoryKeyPassword") && + this.containsKey("signingInMemoryKey") + ) + + private fun MavenPublishBaseExtension.setPublishJavadoc( + project: Project, + shouldPublish: Boolean, + ) { + project.plugins.withId("com.android.library") { + configure( + AndroidSingleVariantLibrary( + publishJavadocJar = shouldPublish, + ), + ) + } + } + + private fun String.toArtifactId(): String { + return substring(1).replace(':', '-') + } + + private fun RepositoryHandler.addGithubPackages() { + maven { + name = "GithubPackages" + url = URI.create("https://maven.pkg.github.com/${System.getenv("GITHUB_REPOSITORY")}") + credentials { + username = System.getenv("GITHUB_USER") + password = System.getenv("GITHUB_TOKEN") + } + } + } + + private fun MavenPomLicense.apache() { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + + companion object { + + private const val SDK_GROUP_ID = "com.tidal.sdk" + private const val SDK_INCEPTION_YEAR = "2024" + private const val SDK_GITHUB_URL = "https://github.com/tidal-music/tidal-sdk-android/" + private const val SCM_CONNECTION = + "scm:git:https://github.com/tidal-music/tidal-sdk-android.git" + private const val SCM_DEVELOPER_CONNECTION = + "scm:git:ssh://git@github.com/tidal-music/tidal-sdk-android.git" + private const val PROJECT_DESCRIPTION_KEY = "projectDescription" + } +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/JvmPlatformConventionPlugin.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/JvmPlatformConventionPlugin.kt new file mode 100644 index 00000000..d1df758d --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/JvmPlatformConventionPlugin.kt @@ -0,0 +1,13 @@ +package com.tidal.sdk.plugins + +import com.tidal.sdk.plugins.constant.PluginId +import org.gradle.api.Plugin +import org.gradle.api.Project + +internal class JvmPlatformConventionPlugin : Plugin { + + override fun apply(target: Project) = target.run { + pluginManager.apply(PluginId.JAVA_PLATFORM_PLUGIN_ID) + ConfiguresMavenPublish()(this) + } +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidApplicationConventionPlugin.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidApplicationConventionPlugin.kt new file mode 100644 index 00000000..e79f87c0 --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidApplicationConventionPlugin.kt @@ -0,0 +1,92 @@ +package com.tidal.sdk.plugins + +import com.tidal.sdk.plugins.constant.Config +import com.tidal.sdk.plugins.constant.PluginId +import com.tidal.sdk.plugins.extensions.androidApplication +import java.io.File +import java.util.Properties +import org.gradle.api.InvalidUserDataException +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +internal class KotlinAndroidApplicationConventionPlugin : Plugin { + + override fun apply(target: Project) = target.run { + pluginManager.apply(PluginId.ANDROID_APPLICATION_PLUGIN_ID) + pluginManager.apply(KotlinAndroidConventionPlugin::class.java) + val libs = extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + val testAndroidXRunnerLibrary = libs.findLibrary("test-androidx-runner").get() + dependencies.add("androidTestImplementation", testAndroidXRunnerLibrary) + val androidTestOrchestratorLibrary = libs.findLibrary("test-androidx-orchestrator").get() + dependencies.add("androidTestUtil", androidTestOrchestratorLibrary) + + configureApplication() + } + + private fun Project.configureApplication() { + val localProperties = Properties() + val localPropertiesFile: File = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) + } + + val tidalClientId = "tidal.clientid" + val clientId = localProperties[tidalClientId] + androidApplication { + compileSdk = Config.ANDROID_COMPILE_SDK + + defaultConfig { + targetSdk = Config.ANDROID_TARGET_SDK + minSdk = Config.ANDROID_MIN_SDK + buildConfigField( + "String", + "TIDAL_CLIENT_ID", + "$clientId", + ) + buildConfigField( + "String", + "TIDAL_CLIENT_SECRET", + "${localProperties["tidal.clientsecret"]}", + ) + testInstrumentationRunner = Config.ANDROID_TEST_RUNNER_JUNIT + testInstrumentationRunnerArguments["clearPackageData"] = "true" + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + buildFeatures { + buildConfig = true + compose = true + } + } + val ensureClientIdPresent: TaskProvider<*> = tasks.register("ensureClientIdPresent") { + doLast { + if (clientId == null && System.getenv("CI").isNullOrBlank()) { + throw InvalidUserDataException( + "$tidalClientId missing in ${localPropertiesFile.absolutePath}", + ) + } + } + } + tasks.withType(KotlinCompile::class.java).configureEach { + dependsOn(ensureClientIdPresent) + } + } +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidConventionPlugin.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidConventionPlugin.kt new file mode 100644 index 00000000..647e2eed --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidConventionPlugin.kt @@ -0,0 +1,14 @@ +package com.tidal.sdk.plugins + +import com.tidal.sdk.plugins.constant.PluginId +import org.gradle.api.Plugin +import org.gradle.api.Project + +internal class KotlinAndroidConventionPlugin : Plugin { + + override fun apply(target: Project) = target.run { + pluginManager.apply(PluginId.KOTLIN_ANDROID_PLUGIN_ID) + ConfiguresKotlinCompiler()(this) + ConfiguresJUnit5()(this) + } +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidLibraryConventionPlugin.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidLibraryConventionPlugin.kt new file mode 100644 index 00000000..4843edcd --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinAndroidLibraryConventionPlugin.kt @@ -0,0 +1,55 @@ +package com.tidal.sdk.plugins + +import com.tidal.sdk.plugins.constant.Config +import com.tidal.sdk.plugins.constant.PluginId +import com.tidal.sdk.plugins.extensions.androidLibrary +import kotlin.collections.set +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension + +internal class KotlinAndroidLibraryConventionPlugin : Plugin { + override fun apply(target: Project) = target.run { + pluginManager.apply(PluginId.ANDROID_LIBRARY_PLUGIN_ID) + pluginManager.apply(KotlinAndroidConventionPlugin::class.java) + ConfiguresDokka()(this) + val libs = extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + val dokkaAndroidLibrary = libs.findLibrary("dokka-android").get().get() + dependencies.add("dokkaPlugin", dokkaAndroidLibrary) + val testAndroidXRunnerLibrary = libs.findLibrary("test-androidx-runner").get() + dependencies.add("androidTestImplementation", testAndroidXRunnerLibrary) + val testAndroidXOrchestratorLibrary = libs.findLibrary("test-androidx-orchestrator").get() + dependencies.add("androidTestUtil", testAndroidXOrchestratorLibrary) + ConfiguresMavenPublish()(this) + configureLibrary() + } + + private fun Project.configureLibrary() { + androidLibrary { + compileSdk = Config.ANDROID_COMPILE_SDK + + defaultConfig { + minSdk = Config.ANDROID_MIN_SDK + testInstrumentationRunner = Config.ANDROID_TEST_RUNNER_JUNIT + consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunnerArguments["clearPackageData"] = "true" + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + } + } +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinJvmLibraryConventionPlugin.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinJvmLibraryConventionPlugin.kt new file mode 100644 index 00000000..eff77bde --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/KotlinJvmLibraryConventionPlugin.kt @@ -0,0 +1,16 @@ +package com.tidal.sdk.plugins + +import com.tidal.sdk.plugins.constant.PluginId +import org.gradle.api.Plugin +import org.gradle.api.Project + +internal class KotlinJvmLibraryConventionPlugin : Plugin { + + override fun apply(target: Project) = target.run { + pluginManager.apply(PluginId.KOTLIN_JVM_PLUGIN_ID) + ConfiguresDokka()(this) + ConfiguresKotlinCompiler()(this) + ConfiguresMavenPublish()(this) + ConfiguresJUnit5()(this) + } +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/constant/Config.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/constant/Config.kt new file mode 100644 index 00000000..63f31e68 --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/constant/Config.kt @@ -0,0 +1,8 @@ +package com.tidal.sdk.plugins.constant + +object Config { + const val ANDROID_MIN_SDK = 24 + const val ANDROID_COMPILE_SDK = 34 + const val ANDROID_TARGET_SDK = 34 + const val ANDROID_TEST_RUNNER_JUNIT = "androidx.test.runner.AndroidJUnitRunner" +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/constant/PluginId.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/constant/PluginId.kt new file mode 100644 index 00000000..2cc04821 --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/constant/PluginId.kt @@ -0,0 +1,11 @@ +package com.tidal.sdk.plugins.constant + +internal object PluginId { + const val KOTLIN_JVM_PLUGIN_ID = "org.jetbrains.kotlin.jvm" + const val KOTLIN_ANDROID_PLUGIN_ID = "org.jetbrains.kotlin.android" + const val KOTLIN_DOKKA_PLUGIN_ID = "org.jetbrains.dokka" + const val ANDROID_APPLICATION_PLUGIN_ID = "com.android.application" + const val ANDROID_LIBRARY_PLUGIN_ID = "com.android.library" + const val GRADLE_MAVEN_PUBLISH_PLUGIN_ID = "com.vanniktech.maven.publish" + const val JAVA_PLATFORM_PLUGIN_ID = "java-platform" +} diff --git a/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/extensions/GradleAPIExtensions.kt b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/extensions/GradleAPIExtensions.kt new file mode 100644 index 00000000..0259fc33 --- /dev/null +++ b/buildlogic/src/main/kotlin/com/tidal/sdk/plugins/extensions/GradleAPIExtensions.kt @@ -0,0 +1,30 @@ +package com.tidal.sdk.plugins.extensions + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Project +import org.gradle.kotlin.dsl.getByType + +/** + * Extension function that allows configuring Gradle's [ApplicationExtension] + * Same as calling: + * android { + * ... + * } + * block in an application module + */ +internal fun Project.androidApplication(action: ApplicationExtension.() -> Unit) { + action.invoke(extensions.getByType()) +} + +/** + * Extension function that allows configuring Gradle's [LibraryExtension] + * Same as calling: + * android { + * ... + * } + * block in a library module + */ +internal fun Project.androidLibrary(action: LibraryExtension.() -> Unit) { + action.invoke(extensions.getByType()) +} diff --git a/common/CHANGELOG.md b/common/CHANGELOG.md new file mode 100644 index 00000000..a0383e32 --- /dev/null +++ b/common/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.5] - 2024-05-22 +### Changed +- Uses the latest `ConfiguresMavenPublish` including mandatory `developers` block + +## [0.2.4] - 2024-04-29 +### Added +- IllegalConfigurationError and UnexpectedError +- CredentialsProvider.isUserLoggedIn +### Changed +- Report events asynchronously +### Removed +- Need to move Credentials once authenticated + +## [0.2.3] - 2024-04-29 +### Added +- Initial Public version +- Common errors used by other modules diff --git a/common/README.md b/common/README.md new file mode 100644 index 00000000..c2fff9dd --- /dev/null +++ b/common/README.md @@ -0,0 +1,8 @@ +# Module common + +This module contains shared code that should be available to multiple modules. +This currently includes globlally shared error and message classes. + +## Usage + +Just add a dependency to this module and use it. diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 00000000..882347ef --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.tidal.android.library) + alias(libs.plugins.android.junit5) +} + +android { + namespace = "com.tidal.sdk.common" +} + +dependencies { + api(libs.kotlin.logging) + api(libs.slf4j.api) +} diff --git a/common/consumer-rules.pro b/common/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/common/gradle.properties b/common/gradle.properties new file mode 100644 index 00000000..5c9a490c --- /dev/null +++ b/common/gradle.properties @@ -0,0 +1,2 @@ +projectDescription=The Common module holds types and functionality that is shared across different modules, like errors, messages, interfaces and more. +version=0.2.5 diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/common/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/common/src/main/kotlin/com/tidal/sdk/common/IllegalConfigurationError.kt b/common/src/main/kotlin/com/tidal/sdk/common/IllegalConfigurationError.kt new file mode 100644 index 00000000..3515f369 --- /dev/null +++ b/common/src/main/kotlin/com/tidal/sdk/common/IllegalConfigurationError.kt @@ -0,0 +1,13 @@ +package com.tidal.sdk.common + +/** + * Raised whenever an operation failed due to an incorrect configuration. + * + * @param code The error code returned by the API. + * @param subStatus The TIDAL-specific error code returned by the API. + */ +class IllegalConfigurationError( + override val code: String, + val subStatus: Int? = null, + val throwable: Throwable? = null, +) : TidalError diff --git a/common/src/main/kotlin/com/tidal/sdk/common/Logging.kt b/common/src/main/kotlin/com/tidal/sdk/common/Logging.kt new file mode 100644 index 00000000..b127946d --- /dev/null +++ b/common/src/main/kotlin/com/tidal/sdk/common/Logging.kt @@ -0,0 +1,63 @@ +package com.tidal.sdk.common + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging + +/** + * @return a logger with the name of the class that calls this extension function + * Loggers are stored in a map and reused + */ +val Any.logger: KLogger + get() = getLoggerByName(this.javaClass.name) + +private val LOGGER_MAP = hashMapOf() + +fun getLoggerByName(name: String): KLogger { + return LOGGER_MAP.getOrPut(name) { + KotlinLogging.logger(name) + } +} + +/** + * Convenience function to call KLogger.debug() + */ +fun KLogger.d(throwable: Throwable? = null, message: () -> Any?) { + throwable?.let { debug(it, message) } ?: debug(message) +} + +/** + * Convenience function to call KLogger.info() + */ +fun KLogger.i(throwable: Throwable? = null, message: () -> Any?) { + throwable?.let { info(it, message) } ?: info(message) +} + +/** + * Convenience function to call KLogger.warn() + */ +fun KLogger.w(throwable: Throwable? = null, message: () -> Any?) { + throwable?.let { warn(it, message) } ?: warn(message) +} + +/** + * Convenience function to call KLogger.error() + */ +fun KLogger.e(throwable: Throwable? = null, message: () -> Any?) { + throwable?.let { error(it, message) } ?: error(message) +} + +/** + * Convenience function to call KLogger.verbose() + */ +fun KLogger.v(throwable: Throwable? = null, message: () -> Any?) { + throwable?.let { trace(it, message) } ?: trace(message) +} + +/** + * verbose is generally considered a synonym of trace, + * so to stay in line with typical logging frameworks + * on Android, we add this convenience function + */ +fun KLogger.verbose(throwable: Throwable? = null, message: () -> Any?) { + throwable?.let { trace(it, message) } ?: trace(message) +} diff --git a/common/src/main/kotlin/com/tidal/sdk/common/NetworkError.kt b/common/src/main/kotlin/com/tidal/sdk/common/NetworkError.kt new file mode 100644 index 00000000..c4484846 --- /dev/null +++ b/common/src/main/kotlin/com/tidal/sdk/common/NetworkError.kt @@ -0,0 +1,3 @@ +package com.tidal.sdk.common + +class NetworkError(override val code: String, val throwable: Throwable? = null) : TidalError diff --git a/common/src/main/kotlin/com/tidal/sdk/common/RetryableError.kt b/common/src/main/kotlin/com/tidal/sdk/common/RetryableError.kt new file mode 100644 index 00000000..48b39750 --- /dev/null +++ b/common/src/main/kotlin/com/tidal/sdk/common/RetryableError.kt @@ -0,0 +1,7 @@ +package com.tidal.sdk.common + +data class RetryableError( + override val code: String, + val subStatus: Int? = null, + val throwable: Throwable? = null, +) : TidalError diff --git a/common/src/main/kotlin/com/tidal/sdk/common/TidalError.kt b/common/src/main/kotlin/com/tidal/sdk/common/TidalError.kt new file mode 100644 index 00000000..0423b8cb --- /dev/null +++ b/common/src/main/kotlin/com/tidal/sdk/common/TidalError.kt @@ -0,0 +1,6 @@ +package com.tidal.sdk.common + +interface TidalError : TidalMessage { + + val code: String +} diff --git a/common/src/main/kotlin/com/tidal/sdk/common/TidalMessage.kt b/common/src/main/kotlin/com/tidal/sdk/common/TidalMessage.kt new file mode 100644 index 00000000..e1b30e9f --- /dev/null +++ b/common/src/main/kotlin/com/tidal/sdk/common/TidalMessage.kt @@ -0,0 +1,3 @@ +package com.tidal.sdk.common + +interface TidalMessage diff --git a/common/src/main/kotlin/com/tidal/sdk/common/UnexpectedError.kt b/common/src/main/kotlin/com/tidal/sdk/common/UnexpectedError.kt new file mode 100644 index 00000000..fd0a282c --- /dev/null +++ b/common/src/main/kotlin/com/tidal/sdk/common/UnexpectedError.kt @@ -0,0 +1,13 @@ +package com.tidal.sdk.common + +/** + * An error to be raised for unexpected errors. Can be used as a "catch all" error. + * + * @param code The error code returned by the API. + * @param subStatus The TIDAL-specific error code returned by the API. + */ +class UnexpectedError( + override val code: String, + val subStatus: Int? = null, + val throwable: Throwable? = null, +) : TidalError diff --git a/eventproducer/CHANGELOG.md b/eventproducer/CHANGELOG.md new file mode 100644 index 00000000..b403850c --- /dev/null +++ b/eventproducer/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.1] - 2024-05-24 + +### Fixed +- Fixed tidal production uri + +### Changed +- Move tlConsumerUri to be the last argument in the EventProducer.getInstance method + +## [0.3.0] - 2024-05-17 + +### Changed +- set default tlConsumer to tidal production environment +- access modifiers adjustments + +## [0.2.9] - 2024-05-16 + +### Added +- recreation of the the 0.2.9 version from the old repository \ No newline at end of file diff --git a/eventproducer/README.md b/eventproducer/README.md new file mode 100644 index 00000000..2c59d56c --- /dev/null +++ b/eventproducer/README.md @@ -0,0 +1,79 @@ +# Module eventproducer + +This module is only intended for internal use at TIDAL, but feel free to look at the code. + +Event Producer is an events transportation layer of the TIDAL Event Platform (TEP). Its responsibility is to make sure that events get transported to the backend as fast, secure, and reliable as possible. + +## Features +* Sending events in batches. +* Filtering events based on the provided blocked consent categories list. +* Collecting and sending monitoring data about dropped events. +* Notifying about the Outage. + +## Documentation + +* Read the [documentation](https://github.com/tidal-music/tidal-sdk/blob/main/EventProducer.md) for a detailed overview of the EventProducer functionality. +* Check the [API documentation](https://verbose-guide-z41gg88.pages.github.io/eventproducer/index.html) for the module classes and methods. +* Visit our [TIDAL Developer Platform](https://developer.tidal.com/) for more information and getting started. + +## Usage + +### Installation + +Add the dependency to your `build.gradle.kts` file. +```kotlin +dependencies { + implementation("com.tidal.sdk:eventproducer:") +} +``` + +### Initialization + +The [EventSender](https://github.com/tidal-music/tidal-sdk-android/blob/main/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/EventSender.kt) role exposes functionality for sending events and monitoring the status of the transportation layer. It is exposed through the [EventProducer](https://github.com/tidal-music/tidal-sdk-android/blob/main/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/EventProducer.kt) which is initialized by providing appropriate configuration and [CredentialsProvider](https://github.com/tidal-music/tidal-sdk-android/blob/main/auth/src/main/kotlin/com/tidal/sdk/auth/CredentialsProvider.kt). The `tlConsumerUri` parameter is optional, with its default value set to the Tidal production environment. If user chooses to provide their own ingest endpoint, it's important to ensure that the entire backend infrastructure is in place. + +```kotlin +val eventsConfig = EventsConfig( + maxDiskUsageBytes = 1000000, + blockedConsentCategories = setOf(ConsentCategory.PERFORMANCE), + appVersion = "1.0", +) + +val eventProducer = EventProducer.getInstance( + credentialsProvider = getCredentialsProvider(), + config = eventsConfig, + context = context, + coroutineScope = CoroutineScope(Dispatchers.IO), + tlConsumerUri = URI("https://event-collector-url") +) + +val eventSender = eventProducer.eventSender +``` +### Sending events + +```kotlin +eventSender.sendEvent( + eventName = "click_button", + consentCategory = ConsentCategory.PERFORMANCE, + payload = "{'buttonId':'123'}", + headers = mapOf("client-id" to "45678") +) +``` + +### Updating `blockedConsentCategories` on the fly + +```kotlin +eventSender.setBlockedConsentCategories(setOf(ConsentCategory.TARGETING)) +``` + +### Receiving outage notifications +```kotlin +coroutineScope.launch { + eventSender.outageState.collect { + if (it is OutageState.Outage) { + // Outage start + } else if (it is OutageState.NoOutage) { + // Outage end + } + } +} +``` diff --git a/eventproducer/apps/demo/build.gradle.kts b/eventproducer/apps/demo/build.gradle.kts new file mode 100644 index 00000000..520017f1 --- /dev/null +++ b/eventproducer/apps/demo/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + alias(libs.plugins.tidal.android.application) +} + +android { + namespace = "com.tidal.sdk.eventproducer.demo" + + defaultConfig { + applicationId = "com.tidal.sdk.eventproducer.demo" + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + debug {} + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + } + packagingOptions { + resources.excludes.apply { + add("META-INF/LICENSE.md") + add("META-INF/LICENSE-notice.md") + } + } +} + +dependencies { + implementation(project(":eventproducer")) + + implementation(libs.bundles.compose) + implementation(libs.androidx.core.ktx) +} diff --git a/eventproducer/apps/demo/src/main/AndroidManifest.xml b/eventproducer/apps/demo/src/main/AndroidManifest.xml new file mode 100644 index 00000000..88783a86 --- /dev/null +++ b/eventproducer/apps/demo/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DemoApp.kt b/eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DemoApp.kt new file mode 100644 index 00000000..e7594656 --- /dev/null +++ b/eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/DemoApp.kt @@ -0,0 +1,5 @@ +package com.tidal.sdk.demo + +import android.app.Application + +class DemoApp : Application() diff --git a/eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/EventSenderDemoScreen.kt b/eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/EventSenderDemoScreen.kt new file mode 100644 index 00000000..95049041 --- /dev/null +++ b/eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/EventSenderDemoScreen.kt @@ -0,0 +1,43 @@ +package com.tidal.sdk.demo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun EventSenderDemoScreen(onButtonClick: () -> Unit) { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colors.background, + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Text( + modifier = Modifier.padding(vertical = 20.dp), + text = "The name of this TIDAL module is: EventProducer!", + ) + Button( + modifier = Modifier.padding(vertical = 100.dp), + onClick = { + onButtonClick() + }, + ) { + Text(text = "Generate new event") + } + } + } + } +} diff --git a/eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/MainActivity.kt b/eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/MainActivity.kt new file mode 100644 index 00000000..b50d5a1a --- /dev/null +++ b/eventproducer/apps/demo/src/main/kotlin/com/tidal/sdk/demo/MainActivity.kt @@ -0,0 +1,83 @@ +package com.tidal.sdk.demo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.common.TidalMessage +import com.tidal.sdk.eventproducer.EventProducer +import com.tidal.sdk.eventproducer.EventSender +import com.tidal.sdk.eventproducer.model.ConsentCategory +import com.tidal.sdk.eventproducer.model.EventsConfig +import java.net.URI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.datetime.Instant + +class MainActivity : ComponentActivity() { + + private lateinit var eventSender: EventSender + private val coroutineScope = CoroutineScope(Dispatchers.IO) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val eventProducer = EventProducer.getInstance( + getCredentialsProvider(), + EventsConfig(MAX_DISK_USAGE_BYTES, emptySet(), "1.0"), + applicationContext, + coroutineScope, + TL_CONSUMER_URI, + ) + eventSender = eventProducer.eventSender + + setContent { + EventSenderDemoScreen { + sendEvent() + } + } + } + + private fun getCredentialsProvider(): CredentialsProvider { + val credentials = Credentials( + clientId = "123", + requestedScopes = emptySet(), + clientUniqueKey = "clientUniqueKey", + grantedScopes = emptySet(), + userId = "123", + expires = Instant.DISTANT_FUTURE, + token = null, + ) + return object : CredentialsProvider { + override val bus: Flow + get() = flowOf() + + override suspend fun getCredentials( + apiErrorSubStatus: String?, + ): AuthResult { + return AuthResult.Success(credentials) + } + + override fun isUserLoggedIn() = true + } + } + + private fun sendEvent() { + eventSender.sendEvent( + "event1", + ConsentCategory.NECESSARY, + "{'group':'onboarding','name':'test-0000001'}", + emptyMap(), + ) + } + + companion object { + private const val MAX_DISK_USAGE_BYTES = 1000000 + private val TL_CONSUMER_URI = + URI("https://event-collector.obelix-staging-use1.tidalhi.fi/") + } +} diff --git a/eventproducer/apps/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml b/eventproducer/apps/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..da1dcd94 --- /dev/null +++ b/eventproducer/apps/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/eventproducer/apps/demo/src/main/res/drawable/ic_launcher_background.xml b/eventproducer/apps/demo/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..ca3826a4 --- /dev/null +++ b/eventproducer/apps/demo/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/eventproducer/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/eventproducer/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..bbd3e021 --- /dev/null +++ b/eventproducer/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/eventproducer/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/eventproducer/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..bbd3e021 --- /dev/null +++ b/eventproducer/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/eventproducer/apps/demo/src/main/res/mipmap-hdpi/ic_launcher.webp b/eventproducer/apps/demo/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/eventproducer/apps/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/eventproducer/apps/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/eventproducer/apps/demo/src/main/res/mipmap-mdpi/ic_launcher.webp b/eventproducer/apps/demo/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/eventproducer/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp b/eventproducer/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/eventproducer/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/eventproducer/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/eventproducer/apps/demo/src/main/res/values/colors.xml b/eventproducer/apps/demo/src/main/res/values/colors.xml new file mode 100644 index 00000000..59c82d82 --- /dev/null +++ b/eventproducer/apps/demo/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/eventproducer/apps/demo/src/main/res/values/strings.xml b/eventproducer/apps/demo/src/main/res/values/strings.xml new file mode 100644 index 00000000..594ac298 --- /dev/null +++ b/eventproducer/apps/demo/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + EventProducer Demo + diff --git a/eventproducer/apps/demo/src/main/res/xml/backup_rules.xml b/eventproducer/apps/demo/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..926eedef --- /dev/null +++ b/eventproducer/apps/demo/src/main/res/xml/backup_rules.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/eventproducer/apps/demo/src/main/res/xml/data_extraction_rules.xml b/eventproducer/apps/demo/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..a73ffe12 --- /dev/null +++ b/eventproducer/apps/demo/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/eventproducer/build.gradle.kts b/eventproducer/build.gradle.kts new file mode 100644 index 00000000..e0a3e484 --- /dev/null +++ b/eventproducer/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.tidal.android.library) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.junit5) +} + +android { + namespace = "com.tidal.sdk.eventproducer" + + buildFeatures { + buildConfig = true + } +} + +dependencies { + api(libs.okhttp.okhttp) + api(libs.tidal.sdk.common) + api(libs.tidal.sdk.auth) + + implementation(libs.dagger) + implementation(libs.kotlinxCoroutinesCore) + implementation(libs.retrofit) + implementation(libs.room.runtime) + implementation(libs.moshi) + implementation(libs.okhttp.loggingInterceptor) + implementation(libs.tickaroo.annotation) + implementation(libs.tickaroo.core) + implementation(libs.tickaroo.retrofitConverter) + + ksp(libs.dagger.compiler) + kapt(libs.room.compiler) + kapt(libs.tickaroo.processor) + kapt(libs.moshi.codegen) + + testImplementation(libs.test.assertk) + testImplementation(libs.test.mockk) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.kotlinx.coroutines.test) + + testApi(libs.test.androidx.junit) + testApi(libs.test.junit5Api) + testApi(libs.test.junit5Engine) +} diff --git a/eventproducer/consumer-rules.pro b/eventproducer/consumer-rules.pro new file mode 100644 index 00000000..a9c43e0b --- /dev/null +++ b/eventproducer/consumer-rules.pro @@ -0,0 +1,11 @@ +-keep class com.tickaroo.tikxml.** { *; } +-keep @com.tickaroo.tikxml.annotation.Xml public class * +-keep class **$$TypeAdapter { *; } + +-keepclasseswithmembernames class * { + @com.tickaroo.tikxml.* ; +} + +-keepclasseswithmembernames class * { + @com.tickaroo.tikxml.* ; +} diff --git a/eventproducer/gradle.properties b/eventproducer/gradle.properties new file mode 100644 index 00000000..2c1f539a --- /dev/null +++ b/eventproducer/gradle.properties @@ -0,0 +1,2 @@ +projectDescription=The Event Producer's responsibility is to transport submitted events as fast, secure and reliable as possible. +version=0.3.1 diff --git a/eventproducer/src/main/AndroidManifest.xml b/eventproducer/src/main/AndroidManifest.xml new file mode 100644 index 00000000..19d2638e --- /dev/null +++ b/eventproducer/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/DefaultEventSender.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/DefaultEventSender.kt new file mode 100644 index 00000000..ddfbc65c --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/DefaultEventSender.kt @@ -0,0 +1,52 @@ +package com.tidal.sdk.eventproducer + +import com.tidal.sdk.eventproducer.model.ConsentCategory +import com.tidal.sdk.eventproducer.model.EventsConfigProvider +import com.tidal.sdk.eventproducer.monitoring.MonitoringInfo +import com.tidal.sdk.eventproducer.utils.CoroutineScopeCanceledException +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +@Singleton +internal class DefaultEventSender @Inject constructor( + private val coroutineScope: CoroutineScope, + private val submitter: Submitter, + private val configProvider: EventsConfigProvider, +) : EventSender { + + /** + * Sends an event to the TL Consumer. Tl Consumer - backend part of the event delivery platform + * @param eventName The name of the event + * @param consentCategory The consent category the event belongs to + * @param payload The payload of the event, i.e. the actual business data being sent. + * @param headers Optional headers the app want to send together with the event. + * @throws [CoroutineScopeCanceledException] if the provided coroutine scope has been canceled + */ + override fun sendEvent( + eventName: String, + consentCategory: ConsentCategory, + payload: String, + headers: Map, + ) { + if (coroutineScope.isActive) { + coroutineScope.launch(Dispatchers.IO) { + submitter.sendEvent(eventName, consentCategory, payload, headers) + } + } else { + throw CoroutineScopeCanceledException() + } + } + + /** + * Sets blocked consent categories which results in filtering events associated with the + * blocked category. Events that are dropped are included in the monitoring statistics in the + * [MonitoringInfo]. + */ + override fun setBlockedConsentCategories(blockedConsentCategories: Set) { + configProvider.updateBlockedConsentCategories(blockedConsentCategories) + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/EventProducer.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/EventProducer.kt new file mode 100644 index 00000000..8bea9464 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/EventProducer.kt @@ -0,0 +1,103 @@ +package com.tidal.sdk.eventproducer + +import android.content.Context +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.eventproducer.di.DaggerEventsComponent +import com.tidal.sdk.eventproducer.model.EventsConfig +import com.tidal.sdk.eventproducer.model.EventsConfigProvider +import com.tidal.sdk.eventproducer.outage.OutageState +import com.tidal.sdk.eventproducer.scheduler.MonitoringScheduler +import com.tidal.sdk.eventproducer.scheduler.SendEventBatchScheduler +import java.net.URI +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * The [EventProducer] exposes an access to an [EventSender] responsible for sending events. + * Additionally, it keeps track of the transportation layer's status through the exposure + * of the [outageState] + */ +class EventProducer private constructor(coroutineScope: CoroutineScope) { + + private var _outageState: MutableStateFlow = + MutableStateFlow(OutageState.NoOutage()) + + val outageState: StateFlow = _outageState.stateIn( + coroutineScope, + SharingStarted.WhileSubscribed(), + OutageState.NoOutage(), + ) + + @Inject + lateinit var eventSender: EventSender + + @Inject + internal lateinit var scheduler: SendEventBatchScheduler + + @Inject + internal lateinit var monitoringScheduler: MonitoringScheduler + + internal fun startOutage() { + _outageState.value = OutageState.Outage() + } + + internal fun endOutage() { + _outageState.value = OutageState.NoOutage() + } + companion object { + + @Volatile + var instance: EventProducer? = null + + private val TIDAL_PRODUCTION_TL_CONSUMER_URI = URI("https://ec.tidal.com") + + /** + * Used to get a Singleton instance of the [EventProducer] + * + * @param credentialsProvider is an implementation of the [CredentialsProvider] from the + * tidal sdk auth module. It's responsible for providing credentials. + * @param config specifies basic attributes of the Event Producer contained within the + * [EventsConfig]. + * @param context is a context. + * @param coroutineScope used to launch coroutines responsible for adding events to + * local db and scheduling sending events to the TL consumer. + * @param tlConsumerUri identifies the TL Consumer ingest endpoint. + * TL Consumer is the backend part of the Event delivery platform. Default value is + * TIDAL production environment. + * @return EventProducer instance. + */ + fun getInstance( + credentialsProvider: CredentialsProvider, + config: EventsConfig, + context: Context, + coroutineScope: CoroutineScope, + tlConsumerUri: URI = TIDAL_PRODUCTION_TL_CONSUMER_URI, + ): EventProducer { + return instance ?: synchronized(this) { + val eventsComponent = + DaggerEventsComponent.factory() + .create( + context.applicationContext, + coroutineScope, + credentialsProvider, + EventsConfigProvider(config), + tlConsumerUri, + ) + EventProducer(coroutineScope).also { + eventsComponent.inject(it) + coroutineScope.launch(Dispatchers.IO) { it.scheduler.scheduleBatchAndSend() } + coroutineScope.launch(Dispatchers.IO) { + it.monitoringScheduler.scheduleSendMonitoringInfo() + } + instance = it + } + } + } + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/EventSender.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/EventSender.kt new file mode 100644 index 00000000..a6ea4d66 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/EventSender.kt @@ -0,0 +1,15 @@ +package com.tidal.sdk.eventproducer + +import com.tidal.sdk.eventproducer.model.ConsentCategory + +interface EventSender { + + fun sendEvent( + eventName: String, + consentCategory: ConsentCategory, + payload: String, + headers: Map, + ) + + fun setBlockedConsentCategories(blockedConsentCategories: Set) +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/Submitter.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/Submitter.kt new file mode 100644 index 00000000..a8ead6bf --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/Submitter.kt @@ -0,0 +1,63 @@ +package com.tidal.sdk.eventproducer + +import com.tidal.sdk.eventproducer.model.ConsentCategory +import com.tidal.sdk.eventproducer.model.Event +import com.tidal.sdk.eventproducer.model.EventsConfigProvider +import com.tidal.sdk.eventproducer.model.MonitoringEvent +import com.tidal.sdk.eventproducer.model.MonitoringEventType +import com.tidal.sdk.eventproducer.repository.EventsRepository +import com.tidal.sdk.eventproducer.utils.EventSizeValidator +import com.tidal.sdk.eventproducer.utils.HeadersUtils +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class Submitter @Inject constructor( + private val configProvider: EventsConfigProvider, + private val eventSizeValidator: EventSizeValidator, + private val repository: EventsRepository, + private val headersUtils: HeadersUtils, +) { + + fun sendEvent( + eventName: String, + consentCategory: ConsentCategory, + payload: String, + suppliedHeaders: Map, + ) { + if (configProvider.config.blockedConsentCategories.contains(consentCategory)) { + storeNewMonitoringEvent(eventName, MonitoringEventType.EventConsentFiltered) + } else { + val event = createEvent(eventName, consentCategory, payload, suppliedHeaders) + if (eventSizeValidator.isEventSizeValid(event)) { + repository.insertEvent(event) + } else { + storeNewMonitoringEvent(eventName, MonitoringEventType.EventValidationFailed) + } + } + } + + private fun storeNewMonitoringEvent( + eventName: String, + monitoringEventType: MonitoringEventType, + ) { + repository.storeNewMonitoringEvent( + MonitoringEvent( + monitoringEventType, + eventName, + ), + ) + } + + private fun createEvent( + eventName: String, + consentCategory: ConsentCategory, + payload: String, + suppliedHeaders: Map, + ): Event { + val defaultHeaders = headersUtils.getDefaultHeaders(consentCategory, false) + val headers = headersUtils.getEventHeaders(defaultHeaders, suppliedHeaders) + return Event(UUID.randomUUID().toString(), eventName, headers, payload) + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/auth/AuthHeadersUtil.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/auth/AuthHeadersUtil.kt new file mode 100644 index 00000000..5e682500 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/auth/AuthHeadersUtil.kt @@ -0,0 +1,25 @@ +package com.tidal.sdk.eventproducer.auth + +import com.tidal.sdk.auth.CredentialsProvider +import kotlinx.coroutines.runBlocking +import okhttp3.Request + +internal const val AUTHORIZATION_HEADER_NAME = "Authorization" +internal const val X_TIDAL_TOKEN_HEADER_NAME = "X-Tidal-Token" +internal const val BEARER = "Bearer" + +internal fun Request.Builder.updateAuthHeader( + credentialsProvider: CredentialsProvider, +): Request.Builder { + val credentials = runBlocking { credentialsProvider.getCredentials().successData } + val clientId = credentials?.clientId + val token = credentials?.token + return apply { + if (!token.isNullOrEmpty()) { + header(AUTHORIZATION_HEADER_NAME, "$BEARER $token") + } else if (!clientId.isNullOrEmpty()) { + removeHeader(AUTHORIZATION_HEADER_NAME) + header(X_TIDAL_TOKEN_HEADER_NAME, clientId) + } + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/auth/DefaultAuthenticator.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/auth/DefaultAuthenticator.kt new file mode 100644 index 00000000..bca95bfc --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/auth/DefaultAuthenticator.kt @@ -0,0 +1,49 @@ +package com.tidal.sdk.eventproducer.auth + +import com.tidal.sdk.auth.CredentialsProvider +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route + +/** + * Implementation of [Authenticator] that will delegate decision of authenticating to the provided + * [AuthProvider]. + * + * The authenticate function is synchronized, so that if multiple requests receive 401, we will + * make sure not all of those requests handle the same auth error at the same time. The idea is + * that once [authProvider] has done its job with getting a fresh token, subsequent requests will + * simply swap out the Authorization header with the new one and retry the request. + * + * @param[authProvider] An [AuthProvider] that provides the access token used in the + * Authorization header of the request. + */ +@Singleton +internal class DefaultAuthenticator @Inject constructor( + private val credentialsProvider: CredentialsProvider, +) : Authenticator { + + @Synchronized + override fun authenticate(route: Route?, response: Response): Request? { + val shouldRetryRequest = runBlocking { + credentialsProvider.getCredentials().successData != null + } + + return if (shouldRetryRequest) { + response.createNewRequest() + } else { + null + } + } + + /** + * Make a copy of the request with a new Authorization header. + */ + private fun Response.createNewRequest(): Request { + val newRequest = request.newBuilder() + return newRequest.updateAuthHeader(credentialsProvider).build() + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/database/EventsDatabase.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/database/EventsDatabase.kt new file mode 100644 index 00000000..d5f77a24 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/database/EventsDatabase.kt @@ -0,0 +1,35 @@ +package com.tidal.sdk.eventproducer.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.tidal.sdk.eventproducer.events.EventDao +import com.tidal.sdk.eventproducer.events.EventEntity +import com.tidal.sdk.eventproducer.monitoring.MonitoringDao +import com.tidal.sdk.eventproducer.monitoring.MonitoringEntity +import com.tidal.sdk.eventproducer.utils.MapConverter +import java.io.File + +@TypeConverters(MapConverter::class) +@Database(entities = [EventEntity::class, MonitoringEntity::class], version = 1) +internal abstract class EventsDatabase : RoomDatabase() { + + abstract fun eventDao(): EventDao + abstract fun monitoringDao(): MonitoringDao + + fun isDatabaseLimitReached(maxDiskUsageBytes: Int): Boolean { + val dbPath = openHelper.writableDatabase.path + val dbSize = dbPath?.let { path -> + val mainDbFileSize = File(path).length() + val shmFileSize = File("$path-shm").length() + val walFileSize = File("$path-wal").length() + val journalFileSize = File("$path-journal").length() + mainDbFileSize + shmFileSize + walFileSize + journalFileSize + } + return dbSize?.let { dbSize >= maxDiskUsageBytes } ?: true + } + + companion object { + const val EVENTS_DATABASE_NAME = "events_database" + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/ConvertersModule.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/ConvertersModule.kt new file mode 100644 index 00000000..153d9da2 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/ConvertersModule.kt @@ -0,0 +1,26 @@ +package com.tidal.sdk.eventproducer.di + +import com.squareup.moshi.Moshi +import com.tickaroo.tikxml.TikXml +import com.tickaroo.tikxml.retrofit.TikXmlConverterFactory +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +internal class ConvertersModule { + + @Provides + @Singleton + fun provideXmlConverter(): TikXmlConverterFactory { + return TikXmlConverterFactory.create( + TikXml.Builder() + .exceptionOnUnreadXml(false) + .build(), + ) + } + + @Provides + @Singleton + fun provideMoshi(): Moshi = Moshi.Builder().build() +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/DatabaseModule.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/DatabaseModule.kt new file mode 100644 index 00000000..b1a7a08b --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/DatabaseModule.kt @@ -0,0 +1,42 @@ +package com.tidal.sdk.eventproducer.di + +import android.content.Context +import androidx.room.Room +import com.tidal.sdk.eventproducer.database.EventsDatabase +import com.tidal.sdk.eventproducer.database.EventsDatabase.Companion.EVENTS_DATABASE_NAME +import com.tidal.sdk.eventproducer.events.EventsLocalDataSource +import com.tidal.sdk.eventproducer.model.EventsConfigProvider +import com.tidal.sdk.eventproducer.monitoring.MonitoringLocalDataSource +import com.tidal.sdk.eventproducer.utils.DatabaseSizeChecker +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +internal class DatabaseModule { + + @Provides + @Singleton + fun provideEventsDatabase(context: Context): EventsDatabase = Room.databaseBuilder( + context, + EventsDatabase::class.java, + EVENTS_DATABASE_NAME, + ).build() + + @Provides + @Singleton + fun provideEventLocalDataSource(db: EventsDatabase): EventsLocalDataSource = db.eventDao() + + @Provides + @Singleton + fun provideMonitoringDataSource(db: EventsDatabase): MonitoringLocalDataSource { + return db.monitoringDao() + } + + @Provides + @Singleton + fun provideDatabaseSizeChecker( + db: EventsDatabase, + configProvider: EventsConfigProvider, + ): DatabaseSizeChecker = DatabaseSizeChecker(db, configProvider.config.maxDiskUsageBytes) +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/EventProducerModule.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/EventProducerModule.kt new file mode 100644 index 00000000..985a80a2 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/EventProducerModule.kt @@ -0,0 +1,13 @@ +package com.tidal.sdk.eventproducer.di + +import com.tidal.sdk.eventproducer.DefaultEventSender +import com.tidal.sdk.eventproducer.EventSender +import dagger.Binds +import dagger.Module + +@Module +internal interface EventProducerModule { + + @Binds + fun provideEventSender(defaultEventSender: DefaultEventSender): EventSender +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/EventsComponent.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/EventsComponent.kt new file mode 100644 index 00000000..0042a864 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/EventsComponent.kt @@ -0,0 +1,38 @@ +package com.tidal.sdk.eventproducer.di + +import android.content.Context +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.eventproducer.EventProducer +import com.tidal.sdk.eventproducer.model.EventsConfigProvider +import dagger.BindsInstance +import dagger.Component +import java.net.URI +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope + +@Singleton +@Component( + modules = [ + DatabaseModule::class, + EventProducerModule::class, + NetworkModule::class, + OkHttpModule::class, + ConvertersModule::class, + UtilsModule::class, + ], +) +internal interface EventsComponent { + + fun inject(eventsSender: EventProducer) + + @Component.Factory + interface Factory { + fun create( + @BindsInstance context: Context, + @BindsInstance coroutineScope: CoroutineScope, + @BindsInstance credentialsProvider: CredentialsProvider, + @BindsInstance configProvider: EventsConfigProvider, + @BindsInstance tlConsumerUri: URI, + ): EventsComponent + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/NetworkModule.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/NetworkModule.kt new file mode 100644 index 00000000..7e9daf31 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/NetworkModule.kt @@ -0,0 +1,26 @@ +package com.tidal.sdk.eventproducer.di + +import com.tickaroo.tikxml.retrofit.TikXmlConverterFactory +import com.tidal.sdk.eventproducer.network.service.SqsService +import dagger.Module +import dagger.Provides +import java.net.URI +import javax.inject.Singleton +import okhttp3.OkHttpClient +import retrofit2.Retrofit + +@Module +internal class NetworkModule { + + @Provides + @Singleton + fun provideSqsService( + tlConsumerUri: URI, + okhttpClient: OkHttpClient, + xmlConverter: TikXmlConverterFactory, + ): SqsService { + val retrofit = Retrofit.Builder().baseUrl(tlConsumerUri.toURL()).client(okhttpClient) + .addConverterFactory(xmlConverter).build() + return retrofit.create(SqsService::class.java) + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/OkHttpModule.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/OkHttpModule.kt new file mode 100644 index 00000000..dced7e0c --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/OkHttpModule.kt @@ -0,0 +1,46 @@ +package com.tidal.sdk.eventproducer.di + +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.eventproducer.BuildConfig +import com.tidal.sdk.eventproducer.auth.DefaultAuthenticator +import com.tidal.sdk.eventproducer.network.HeadersInterceptor +import dagger.Module +import dagger.Provides +import javax.inject.Named +import javax.inject.Singleton +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor + +@Module +internal class OkHttpModule { + + @Provides + @Singleton + @Named("loggingInterceptor") + fun provideLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY) + } + + @Provides + @Singleton + @Named("headerInterceptor") + fun provideHeadersInterceptor(credentialsProvider: CredentialsProvider): Interceptor = + HeadersInterceptor(credentialsProvider) + + @Provides + @Singleton + fun provideBaseOkHttpClient( + @Named("loggingInterceptor") loggingInterceptor: HttpLoggingInterceptor, + @Named("headerInterceptor") headerInterceptor: Interceptor, + defaultAuthenticator: DefaultAuthenticator, + ): OkHttpClient { + return OkHttpClient.Builder().apply { + addInterceptor(headerInterceptor) + authenticator(defaultAuthenticator) + if (BuildConfig.DEBUG) { + addInterceptor(loggingInterceptor) + } + }.build() + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/UtilsModule.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/UtilsModule.kt new file mode 100644 index 00000000..ab35f831 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/di/UtilsModule.kt @@ -0,0 +1,17 @@ +package com.tidal.sdk.eventproducer.di + +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.eventproducer.model.EventsConfigProvider +import com.tidal.sdk.eventproducer.utils.HeadersUtils +import dagger.Module +import dagger.Provides + +@Module +internal class UtilsModule { + + @Provides + fun provideHeadersUtils( + configProvider: EventsConfigProvider, + credentialsProvider: CredentialsProvider, + ) = HeadersUtils(configProvider.config.appVersion, credentialsProvider) +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventDao.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventDao.kt new file mode 100644 index 00000000..2e077bd8 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventDao.kt @@ -0,0 +1,32 @@ +package com.tidal.sdk.eventproducer.events + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.tidal.sdk.eventproducer.model.Event + +@Dao +internal abstract class EventDao : EventsLocalDataSource { + + override fun insertEvent(event: Event) { + insert(event.toEventEntity()) + } + + override fun getAllEvents(): List { + return getAll().map { it.toEvent() } + } + + override fun deleteEvents(ids: List) { + delete(ids) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract fun insert(event: EventEntity): Long + + @Query("DELETE FROM events WHERE id IN (:ids)") + protected abstract fun delete(ids: List): Int + + @Query("SELECT * FROM events") + protected abstract fun getAll(): List +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventEntity.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventEntity.kt new file mode 100644 index 00000000..80a9dbe3 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventEntity.kt @@ -0,0 +1,17 @@ +package com.tidal.sdk.eventproducer.events + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.tidal.sdk.eventproducer.model.Event + +@Entity(tableName = "events") +internal data class EventEntity( + @PrimaryKey val id: String, + val name: String, + val headers: Map, + val payload: String, +) + +internal fun EventEntity.toEvent(): Event = Event(id, name, headers, payload) + +internal fun Event.toEventEntity(): EventEntity = EventEntity(id, name, headers, payload) diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventsLocalDataSource.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventsLocalDataSource.kt new file mode 100644 index 00000000..b2279067 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/events/EventsLocalDataSource.kt @@ -0,0 +1,9 @@ +package com.tidal.sdk.eventproducer.events + +import com.tidal.sdk.eventproducer.model.Event + +internal interface EventsLocalDataSource { + fun insertEvent(event: Event) + fun deleteEvents(ids: List) + fun getAllEvents(): List +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/ConsentCategory.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/ConsentCategory.kt new file mode 100644 index 00000000..10b2cf04 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/ConsentCategory.kt @@ -0,0 +1,26 @@ +package com.tidal.sdk.eventproducer.model + +/** + * Consent categories allow the end user to control how the app can use information gathered + * via events. Each event belongs to one consent category, and the user can opt out + * a consent category. + */ +enum class ConsentCategory { + /** + * The event is considered strictly necessary. End users cannot opt out of strictly necessary + * events. + */ + NECESSARY, + + /** + * The event is used e.g. for advertisement. End users can opt out of targeting events. Also + * called advertising. + */ + TARGETING, + + /** + * The event is used e.g. for tracking the performance and usage of the app. End users can opt + * out of performance events. Also called analytics. + */ + PERFORMANCE, +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/Event.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/Event.kt new file mode 100644 index 00000000..84738ab6 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/Event.kt @@ -0,0 +1,11 @@ +package com.tidal.sdk.eventproducer.model + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Event( + val id: String, + val name: String, + val headers: Map, + val payload: String, +) diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/EventsConfig.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/EventsConfig.kt new file mode 100644 index 00000000..aa60ec38 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/EventsConfig.kt @@ -0,0 +1,14 @@ +package com.tidal.sdk.eventproducer.model + +/** + * @property maxDiskUsageBytes specifies the maximum amount of disk the EventProducer + * is allowed to use for temporarily storing events before they are sent to TL Consume + * @property blockedConsentCategories specifies consent categories based on which events + * will be filtered + * @property appVersion specifies version of the app + */ +data class EventsConfig( + val maxDiskUsageBytes: Int, + val blockedConsentCategories: Set, + val appVersion: String, +) diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/EventsConfigProvider.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/EventsConfigProvider.kt new file mode 100644 index 00000000..d3b3cd7d --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/EventsConfigProvider.kt @@ -0,0 +1,12 @@ +package com.tidal.sdk.eventproducer.model + +import javax.inject.Singleton + +@Singleton +internal class EventsConfigProvider( + var config: EventsConfig, +) { + fun updateBlockedConsentCategories(blockedConsentCategories: Set) { + config = config.copy(blockedConsentCategories = blockedConsentCategories) + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/MonitoringEvent.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/MonitoringEvent.kt new file mode 100644 index 00000000..7baa547c --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/MonitoringEvent.kt @@ -0,0 +1,3 @@ +package com.tidal.sdk.eventproducer.model + +internal data class MonitoringEvent(val type: MonitoringEventType, val name: String) diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/MonitoringEventType.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/MonitoringEventType.kt new file mode 100644 index 00000000..1e9bfb06 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/MonitoringEventType.kt @@ -0,0 +1,7 @@ +package com.tidal.sdk.eventproducer.model + +internal enum class MonitoringEventType { + EventStoringFailed, + EventValidationFailed, + EventConsentFiltered, +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/Result.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/Result.kt new file mode 100644 index 00000000..8407280d --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/model/Result.kt @@ -0,0 +1,39 @@ +package com.tidal.sdk.eventproducer.model + +import com.tidal.sdk.common.TidalMessage + +internal sealed class Result { + + data class Success(val data: T?) : Result() + + data class Failure(val message: TidalMessage?) : Result() + + /** + * Convenience function to quickly check if this result is a [Result.Success] + */ + val isSuccess get() = this is Success<*> + + /** + * Convenience function to quickly check if this result is a [Result.Failure] + */ + val isFailure get() = this is Failure + + /** + * Helper property to directly access the data payload without having to + * safequard it in every call + */ + val successData: T? + get() { + return if (this is Success) this.data else null + } +} + +/** + * Creates a [Result.Success] with data payload + */ +internal fun success(data: T?) = Result.Success(data) + +/** + * Creates a [Result.Failure] + */ +internal fun failure(message: TidalMessage? = null) = Result.Failure(message) diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringDao.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringDao.kt new file mode 100644 index 00000000..86f1d587 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringDao.kt @@ -0,0 +1,24 @@ +package com.tidal.sdk.eventproducer.monitoring + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +internal abstract class MonitoringDao : MonitoringLocalDataSource { + + override fun insert(monitoringInfo: MonitoringInfo) { + insertMonitoringEntity(monitoringInfo.toMonitoringEntity()) + } + + override fun getMonitoringInfo(): MonitoringInfo { + return getMonitoringEntity()?.toMonitoringInfo() ?: MonitoringInfo() + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract fun insertMonitoringEntity(monitoringEntity: MonitoringEntity) + + @Query("SELECT * FROM monitoring LIMIT 1") + protected abstract fun getMonitoringEntity(): MonitoringEntity? +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringEntity.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringEntity.kt new file mode 100644 index 00000000..f39cba97 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringEntity.kt @@ -0,0 +1,16 @@ +package com.tidal.sdk.eventproducer.monitoring + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "monitoring") +internal data class MonitoringEntity( + @PrimaryKey val id: String = MONITORING_ENTITY_PRIMARY_KEY, + var consentFilteredEvents: Map, + var validationFailedEvents: Map, + var storingFailedEvents: Map, +) { + companion object { + const val MONITORING_ENTITY_PRIMARY_KEY = "1" + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringInfo.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringInfo.kt new file mode 100644 index 00000000..285cd24c --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringInfo.kt @@ -0,0 +1,32 @@ +package com.tidal.sdk.eventproducer.monitoring + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class MonitoringInfo( + var consentFilteredEvents: Map = mapOf(), + var validationFailedEvents: Map = mapOf(), + var storingFailedEvents: Map = mapOf(), +) + +internal fun MonitoringEntity.toMonitoringInfo(): MonitoringInfo { + return MonitoringInfo( + consentFilteredEvents = consentFilteredEvents, + validationFailedEvents = validationFailedEvents, + storingFailedEvents = storingFailedEvents, + ) +} + +internal fun MonitoringInfo.toMonitoringEntity(): MonitoringEntity { + return MonitoringEntity( + consentFilteredEvents = consentFilteredEvents, + validationFailedEvents = validationFailedEvents, + storingFailedEvents = storingFailedEvents, + ) +} + +internal fun MonitoringInfo.isNotEmpty(): Boolean { + return consentFilteredEvents.isNotEmpty() || + validationFailedEvents.isNotEmpty() || + storingFailedEvents.isNotEmpty() +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringLocalDataSource.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringLocalDataSource.kt new file mode 100644 index 00000000..2855a1c4 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/monitoring/MonitoringLocalDataSource.kt @@ -0,0 +1,8 @@ +package com.tidal.sdk.eventproducer.monitoring + +internal interface MonitoringLocalDataSource { + + fun insert(monitoringEntity: MonitoringInfo) + + fun getMonitoringInfo(): MonitoringInfo +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/HeadersInterceptor.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/HeadersInterceptor.kt new file mode 100644 index 00000000..8386b01d --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/HeadersInterceptor.kt @@ -0,0 +1,26 @@ +package com.tidal.sdk.eventproducer.network + +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.eventproducer.auth.updateAuthHeader +import okhttp3.Interceptor +import okhttp3.Response + +internal class HeadersInterceptor( + private val credentialsProvider: CredentialsProvider, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val updatedRequest = chain.request().newBuilder().apply { + addHeader( + CONTENT_TYPE_HEADER_NAME, + CONTENT_TYPE_HEADER, + ) + updateAuthHeader(credentialsProvider) + }.build() + return chain.proceed(updatedRequest) + } + + companion object { + private const val CONTENT_TYPE_HEADER = "application/x-www-form-urlencoded" + private const val CONTENT_TYPE_HEADER_NAME = "Content-type" + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/service/SendMessageBatchResponse.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/service/SendMessageBatchResponse.kt new file mode 100644 index 00000000..913ee1af --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/service/SendMessageBatchResponse.kt @@ -0,0 +1,24 @@ +package com.tidal.sdk.eventproducer.network.service + +import com.tickaroo.tikxml.annotation.Element +import com.tickaroo.tikxml.annotation.PropertyElement +import com.tickaroo.tikxml.annotation.Xml + +@Xml(name = "SendMessageBatchResponse") +internal data class SendMessageBatchResponse( + + @Element + val result: SendMessageBatchResult, +) + +@Xml(name = "SendMessageBatchResult") +internal data class SendMessageBatchResult( + @Element + val successfullySentEntries: List?, +) + +@Xml(name = "SendMessageBatchResultEntry") +internal data class SendMessageBatchResultEntry( + @PropertyElement(name = "Id") + val id: String, +) diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/service/SqsService.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/service/SqsService.kt new file mode 100644 index 00000000..abf031ba --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/network/service/SqsService.kt @@ -0,0 +1,21 @@ +package com.tidal.sdk.eventproducer.network.service + +import java.util.* +import retrofit2.http.FieldMap +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +internal interface SqsService { + + @FormUrlEncoded + @POST("api/event-batch") + suspend fun sendEventsBatch( + @FieldMap parameters: Map, + ): SendMessageBatchResponse + + @FormUrlEncoded + @POST("api/public/event-batch") + suspend fun sendEventsBatchPublic( + @FieldMap parameters: Map, + ): SendMessageBatchResponse +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageEndMessage.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageEndMessage.kt new file mode 100644 index 00000000..87707aa7 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageEndMessage.kt @@ -0,0 +1,5 @@ +package com.tidal.sdk.eventproducer.outage + +import com.tidal.sdk.common.TidalMessage + +class OutageEndMessage : TidalMessage diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageStartError.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageStartError.kt new file mode 100644 index 00000000..b4284066 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageStartError.kt @@ -0,0 +1,6 @@ +package com.tidal.sdk.eventproducer.outage + +import com.tidal.sdk.common.TidalError + +// this error code needs to be agreed with the rest of the platforms +class OutageStartError(override val code: String = "1") : TidalError diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageState.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageState.kt new file mode 100644 index 00000000..1926d291 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/outage/OutageState.kt @@ -0,0 +1,11 @@ +package com.tidal.sdk.eventproducer.outage + +import com.tidal.sdk.common.TidalError +import com.tidal.sdk.common.TidalMessage + +sealed class OutageState { data class Outage( + val outageStartError: TidalError = OutageStartError(), +) : OutageState() + + data class NoOutage(val outageEndMessage: TidalMessage = OutageEndMessage()) : OutageState() +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/repository/EventsRepository.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/repository/EventsRepository.kt new file mode 100644 index 00000000..9524f30e --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/repository/EventsRepository.kt @@ -0,0 +1,126 @@ +package com.tidal.sdk.eventproducer.repository + +import android.database.sqlite.SQLiteException +import com.tidal.sdk.eventproducer.EventProducer +import com.tidal.sdk.eventproducer.events.EventsLocalDataSource +import com.tidal.sdk.eventproducer.model.Event +import com.tidal.sdk.eventproducer.model.MonitoringEvent +import com.tidal.sdk.eventproducer.model.MonitoringEventType +import com.tidal.sdk.eventproducer.model.Result +import com.tidal.sdk.eventproducer.monitoring.MonitoringInfo +import com.tidal.sdk.eventproducer.monitoring.MonitoringLocalDataSource +import com.tidal.sdk.eventproducer.monitoring.isNotEmpty +import com.tidal.sdk.eventproducer.network.service.SendMessageBatchResponse +import com.tidal.sdk.eventproducer.network.service.SqsService +import com.tidal.sdk.eventproducer.utils.SqsRequestParametersConverter +import com.tidal.sdk.eventproducer.utils.safeRequest +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@Suppress("TooManyFunctions") +internal class EventsRepository @Inject constructor( + private val eventsLocalDataSource: EventsLocalDataSource, + private val monitoringLocalDataSource: MonitoringLocalDataSource, + private val repositoryHelper: RepositoryHelper, + private val sqsParametersConverter: SqsRequestParametersConverter, + private val sqsService: SqsService, +) { + + /** + * Adds event to local database only if database limit size hasn't been reached + */ + fun insertEvent(event: Event) { + if (repositoryHelper.isDatabaseLimitReached()) { + registerEventStoringFailed(event.name) + } else { + insertEventToDatabase(event) + } + } + + private fun registerEventStoringFailed(eventName: String) { + storeNewMonitoringEvent( + MonitoringEvent( + MonitoringEventType.EventStoringFailed, + eventName, + ), + ) + EventProducer.instance?.startOutage() + } + + @Suppress("SwallowedException") + private fun insertEventToDatabase(event: Event) { + try { + eventsLocalDataSource.insertEvent(event) + EventProducer.instance?.endOutage() + } catch (e: SQLiteException) { + registerEventStoringFailed(event.name) + } + } + + fun deleteEvents(ids: List) = eventsLocalDataSource.deleteEvents(ids) + + fun getAll(): List = eventsLocalDataSource.getAllEvents() + + suspend fun sendEventsToSqs(events: List): Result { + return sendEventBatch(events) + } + + private suspend fun sendEventBatch(events: List): Result { + val sqsParameters = sqsParametersConverter.getSendEventsParameters(events) + return if (repositoryHelper.isUserLoggedIn()) { + safeRequest { sqsService.sendEventsBatch(sqsParameters) } + } else { + safeRequest { sqsService.sendEventsBatchPublic(sqsParameters) } + } + } + + fun storeNewMonitoringEvent(event: MonitoringEvent) { + with(monitoringLocalDataSource) { + val updatedInfo = getMonitoringInfo().updateWithEvent(event) + insert(updatedInfo) + } + } + + private fun MonitoringInfo.updateWithEvent(event: MonitoringEvent): MonitoringInfo { + val updatedInfo = when (event.type) { + MonitoringEventType.EventStoringFailed -> this.copy( + consentFilteredEvents = this.consentFilteredEvents, + validationFailedEvents = this.validationFailedEvents, + storingFailedEvents = this.storingFailedEvents.bumpValueForKeyByOne(event.name), + ) + + MonitoringEventType.EventValidationFailed -> this.copy( + consentFilteredEvents = this.consentFilteredEvents, + validationFailedEvents = this.validationFailedEvents.bumpValueForKeyByOne( + event.name, + ), + storingFailedEvents = this.storingFailedEvents, + ) + + MonitoringEventType.EventConsentFiltered -> this.copy( + consentFilteredEvents = this.consentFilteredEvents.bumpValueForKeyByOne(event.name), + validationFailedEvents = this.validationFailedEvents, + storingFailedEvents = this.storingFailedEvents, + ) + } + return updatedInfo + } + + private fun Map.bumpValueForKeyByOne(name: String): Map { + val map = this.toMutableMap() + val value = this[name]?.let { it + 1 } ?: 1 + map[name] = value + return map + } + + suspend fun sendMonitoringEvent() { + val monitoringInfo = monitoringLocalDataSource.getMonitoringInfo() + if (monitoringInfo.isNotEmpty()) { + val monitoringEvent = repositoryHelper.getMonitoringEvent(monitoringInfo) + sendEventBatch(listOf(monitoringEvent)) + } + } + + fun clearMonitoringInfo() = monitoringLocalDataSource.insert(MonitoringInfo()) +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/repository/RepositoryHelper.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/repository/RepositoryHelper.kt new file mode 100644 index 00000000..0f193ded --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/repository/RepositoryHelper.kt @@ -0,0 +1,51 @@ +package com.tidal.sdk.eventproducer.repository + +import com.squareup.moshi.Moshi +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.eventproducer.model.ConsentCategory +import com.tidal.sdk.eventproducer.model.Event +import com.tidal.sdk.eventproducer.monitoring.MonitoringInfo +import com.tidal.sdk.eventproducer.utils.DatabaseSizeChecker +import com.tidal.sdk.eventproducer.utils.HeadersUtils +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.runBlocking + +@Singleton +internal class RepositoryHelper @Inject constructor( + private val credentialsProvider: CredentialsProvider, + private val databaseSizeChecker: DatabaseSizeChecker, + private val moshi: Moshi, + private val headersUtils: HeadersUtils, +) { + + fun isDatabaseLimitReached() = databaseSizeChecker.isDatabaseLimitReached() + + fun isUserLoggedIn(): Boolean { + val credentials = runBlocking { credentialsProvider.getCredentials().successData } + return credentials?.level == Credentials.Level.USER + } + + fun getMonitoringEvent(monitoringInfo: MonitoringInfo): Event { + val monitoringEventPayload = getMonitoringEventPayload(monitoringInfo) + val headers = headersUtils.getDefaultHeaders(ConsentCategory.NECESSARY, true) + return Event( + UUID.randomUUID().toString(), + MONITORING_EVENT_NAME, + headers, + monitoringEventPayload, + ) + } + + private fun getMonitoringEventPayload(monitoringInfo: MonitoringInfo): String { + val adapter = moshi.adapter(MonitoringInfo::class.java) + return adapter.toJson(monitoringInfo) + } + + companion object { + + const val MONITORING_EVENT_NAME = "tep-tl-monitoring" + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/scheduler/MonitoringScheduler.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/scheduler/MonitoringScheduler.kt new file mode 100644 index 00000000..306b697f --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/scheduler/MonitoringScheduler.kt @@ -0,0 +1,27 @@ +package com.tidal.sdk.eventproducer.scheduler + +import com.tidal.sdk.eventproducer.repository.EventsRepository +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.delay + +@Singleton +internal class MonitoringScheduler @Inject constructor(private val repository: EventsRepository) { + + suspend fun scheduleSendMonitoringInfo() { + while (true) { + delay(MONITORING_SLEEP_DURATION_MS) + sendMonitoringEvent() + } + } + + private suspend fun sendMonitoringEvent() { + repository.sendMonitoringEvent() + repository.clearMonitoringInfo() + } + + companion object { + + const val MONITORING_SLEEP_DURATION_MS = 60000L + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/scheduler/SendEventBatchScheduler.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/scheduler/SendEventBatchScheduler.kt new file mode 100644 index 00000000..b72cf496 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/scheduler/SendEventBatchScheduler.kt @@ -0,0 +1,37 @@ +package com.tidal.sdk.eventproducer.scheduler + +import com.tidal.sdk.eventproducer.model.Event +import com.tidal.sdk.eventproducer.repository.EventsRepository +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.delay + +@Singleton +internal class SendEventBatchScheduler @Inject constructor( + private val repository: EventsRepository, +) { + + suspend fun scheduleBatchAndSend() { + while (true) { + delay(SLEEP_DURATION_MS) + val allEvents = repository.getAll() + if (allEvents.isNotEmpty()) { + allEvents.chunked(MAX_BATCH_SIZE).forEach { eventBatch -> + sendEventBatch(eventBatch) + } + } + } + } + + private suspend fun sendEventBatch(eventBatch: List) { + val response = repository.sendEventsToSqs(eventBatch) + val successfullySentEventIds = + response.successData?.result?.successfullySentEntries?.map { it.id } ?: emptyList() + successfullySentEventIds.let { repository.deleteEvents(it) } + } + + companion object { + const val MAX_BATCH_SIZE = 10 + const val SLEEP_DURATION_MS = 30000L + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/CoroutineScopeCanceledException.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/CoroutineScopeCanceledException.kt new file mode 100644 index 00000000..64c9b08a --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/CoroutineScopeCanceledException.kt @@ -0,0 +1,4 @@ +package com.tidal.sdk.eventproducer.utils + +class CoroutineScopeCanceledException : + Exception("Coroutine scope used for sending events has been canceled") diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/DatabaseSizeChecker.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/DatabaseSizeChecker.kt new file mode 100644 index 00000000..2d5b054c --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/DatabaseSizeChecker.kt @@ -0,0 +1,18 @@ +package com.tidal.sdk.eventproducer.utils + +import com.tidal.sdk.eventproducer.database.EventsDatabase + +/** + * This class is an entry point to evaluate if local database size limit has been reached + * + * @property db is local database + * @property maxDiskUsageBytes specifies the maximum amount of disk the EventProducer + * is allowed to use for temporarily storing events before they are sent to TL Consumer. + */ +internal class DatabaseSizeChecker( + private val db: EventsDatabase, + private val maxDiskUsageBytes: Int, +) { + + fun isDatabaseLimitReached(): Boolean = db.isDatabaseLimitReached(maxDiskUsageBytes) +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/EventSizeValidator.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/EventSizeValidator.kt new file mode 100644 index 00000000..11e3373f --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/EventSizeValidator.kt @@ -0,0 +1,21 @@ +package com.tidal.sdk.eventproducer.utils + +import com.squareup.moshi.Moshi +import com.tidal.sdk.eventproducer.model.Event +import dagger.Reusable +import javax.inject.Inject + +@Reusable +internal class EventSizeValidator @Inject constructor(private val moshi: Moshi) { + + fun isEventSizeValid(event: Event): Boolean { + val adapter = moshi.adapter(Event::class.java) + val eventSize = adapter.toJson(event).toByteArray(CHARSET).size + return eventSize <= EVENT_SIZE_BYTES_LIMIT + } + + companion object { + private val CHARSET = Charsets.UTF_8 + private const val EVENT_SIZE_BYTES_LIMIT = 20480 + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/HeadersUtils.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/HeadersUtils.kt new file mode 100644 index 00000000..a3a3c3c8 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/HeadersUtils.kt @@ -0,0 +1,54 @@ +package com.tidal.sdk.eventproducer.utils + +import android.os.Build +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.eventproducer.model.ConsentCategory +import javax.inject.Inject +import kotlinx.coroutines.runBlocking + +internal const val CONSENT_CATEGORY_KEY = "consent-category" +internal const val OS_NAME_KEY = "os-name" +internal const val OS_VERSION_KEY = "os-version" +internal const val APP_VERSION_KEY = "app-version" +internal const val REQUESTED_SENT_TIMESTAMP_KEY = "requested-sent-timestamp" +internal const val AUTHORIZATION_KEY = "authorization" +internal const val CLIENT_ID_KEY = "client-id" +internal const val DEVICE_MODEL_KEY = "device-model" +internal const val DEVICE_VENDOR_KEY = "device-vendor" + +internal class HeadersUtils @Inject constructor( + private val appVersion: String, + private val credentialsProvider: CredentialsProvider, +) { + fun getEventHeaders( + defaultHeaders: Map, + suppliedHeaders: Map, + ): Map = defaultHeaders + suppliedHeaders + + fun getDefaultHeaders( + consentCategory: ConsentCategory, + isMonitoringEvent: Boolean, + ): Map { + val deviceModel = Build.MODEL + val deviceVendor = Build.MANUFACTURER + val sentTimestamp = System.currentTimeMillis().toString() + val osName = "Android" + val osVersion = Build.VERSION.SDK_INT.toString() + val headers = mutableMapOf() + headers[APP_VERSION_KEY] = appVersion + headers[OS_VERSION_KEY] = osVersion + headers[OS_NAME_KEY] = osName + headers[REQUESTED_SENT_TIMESTAMP_KEY] = sentTimestamp + headers[DEVICE_VENDOR_KEY] = deviceVendor + headers[DEVICE_MODEL_KEY] = deviceModel + headers[CONSENT_CATEGORY_KEY] = consentCategory.toString() + val credentials = runBlocking { credentialsProvider.getCredentials().successData } + val token = credentials?.token + if (!isMonitoringEvent && !token.isNullOrEmpty()) { + headers[AUTHORIZATION_KEY] = token + } + val clientId = credentials?.clientId + if (!clientId.isNullOrEmpty()) headers[CLIENT_ID_KEY] = clientId + return headers + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/MapConverter.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/MapConverter.kt new file mode 100644 index 00000000..5c6fefc3 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/MapConverter.kt @@ -0,0 +1,45 @@ +package com.tidal.sdk.eventproducer.utils + +import androidx.room.TypeConverter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dagger.Reusable +import javax.inject.Inject + +@Reusable +internal class MapConverter @Inject constructor() { + + private val mapStringStringType = Types.newParameterizedType( + Map::class.java, + String::class.java, + String::class.java, + ) + + private val mapStringIntType = Types.newParameterizedType( + Map::class.java, + String::class.java, + Int::class.javaObjectType, + ) + + private val moshi = Moshi.Builder().build() + + @TypeConverter + fun toStringStringMap(value: String): Map? { + return moshi.adapter>(mapStringStringType).fromJson(value) + } + + @TypeConverter + fun fromStringStringMap(map: Map): String { + return moshi.adapter>(mapStringStringType).toJson(map) + } + + @TypeConverter + fun toIntStringMap(value: String): Map? { + return moshi.adapter>(mapStringIntType).fromJson(value) + } + + @TypeConverter + fun fromIntStringMap(map: Map): String { + return moshi.adapter>(mapStringIntType).toJson(map) + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/SqsRequestParametersConverter.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/SqsRequestParametersConverter.kt new file mode 100644 index 00000000..cc6938a0 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/SqsRequestParametersConverter.kt @@ -0,0 +1,45 @@ +package com.tidal.sdk.eventproducer.utils + +import com.tidal.sdk.eventproducer.model.Event +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class SqsRequestParametersConverter @Inject constructor( + private val mapConverter: MapConverter, +) { + + fun getSendEventsParameters(events: List): Map { + val params = mutableMapOf() + events.forEachIndexed { index, event -> + val eventIndex = index + 1 + var attributeIndex = 1 + params["$SEND_BATCH.$eventIndex.$ID"] = event.id + params["$SEND_BATCH.$eventIndex.$BODY"] = event.payload + params["$SEND_BATCH.$eventIndex.$ATTRIBUTE.$attributeIndex.$NAME_KEY"] = NAME + params["$SEND_BATCH.$eventIndex.$ATTRIBUTE.$attributeIndex.$VALUE"] = event.name + params["$SEND_BATCH.$eventIndex.$ATTRIBUTE.$attributeIndex.$VALUE_DATATYPE"] = STRING + + attributeIndex++ + val headersJson = mapConverter.fromStringStringMap(event.headers) + params["$SEND_BATCH.$eventIndex.$ATTRIBUTE.$attributeIndex.$NAME_KEY"] = HEADERS_KEY + params["$SEND_BATCH.$eventIndex.$ATTRIBUTE.$attributeIndex.$VALUE_DATATYPE"] = + STRING + params["$SEND_BATCH.$eventIndex.$ATTRIBUTE.$attributeIndex.$VALUE"] = headersJson + } + return params + } + + companion object { + private const val SEND_BATCH = "SendMessageBatchRequestEntry" + private const val NAME = "Name" + private const val STRING = "String" + private const val ATTRIBUTE = "MessageAttribute" + private const val BODY = "MessageBody" + private const val ID = "Id" + private const val NAME_KEY = "Name" + private const val HEADERS_KEY = "Headers" + private const val VALUE = "Value.StringValue" + private const val VALUE_DATATYPE = "Value.DataType" + } +} diff --git a/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/Utils.kt b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/Utils.kt new file mode 100644 index 00000000..849698e1 --- /dev/null +++ b/eventproducer/src/main/kotlin/com/tidal/sdk/eventproducer/utils/Utils.kt @@ -0,0 +1,14 @@ +package com.tidal.sdk.eventproducer.utils + +import com.tidal.sdk.eventproducer.model.Result +import com.tidal.sdk.eventproducer.model.failure +import com.tidal.sdk.eventproducer.model.success + +@Suppress("TooGenericExceptionCaught", "SwallowedException") +internal suspend fun safeRequest(block: suspend () -> T): Result { + return try { + success(block()) + } catch (t: Throwable) { + failure() + } +} diff --git a/eventproducer/src/test/kotlin/com/tidal/eventproducer/auth/DefaultAuthenticatorTest.kt b/eventproducer/src/test/kotlin/com/tidal/eventproducer/auth/DefaultAuthenticatorTest.kt new file mode 100644 index 00000000..781b6842 --- /dev/null +++ b/eventproducer/src/test/kotlin/com/tidal/eventproducer/auth/DefaultAuthenticatorTest.kt @@ -0,0 +1,100 @@ +package com.tidal.eventproducer.auth + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.tidal.eventproducer.fakes.FakeCredentialsProvider +import com.tidal.sdk.eventproducer.auth.AUTHORIZATION_HEADER_NAME +import com.tidal.sdk.eventproducer.auth.BEARER +import com.tidal.sdk.eventproducer.auth.DefaultAuthenticator +import com.tidal.sdk.eventproducer.auth.X_TIDAL_TOKEN_HEADER_NAME +import com.tidal.sdk.eventproducer.network.HeadersInterceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.Test + +class DefaultAuthenticatorTest { + + private val server = MockWebServer() + + private val request = Request.Builder() + .url(server.url("/")) + .build() + + @Test + fun `Should not retry request when CredentialsProvider returns failure AuthResult`() { + // given + val credentialsProvider = FakeCredentialsProvider(false) + val defaultAuthenticator = DefaultAuthenticator(credentialsProvider) + val okHttpClient = OkHttpClient.Builder() + .authenticator(defaultAuthenticator) + .build() + + val unauthorisedResponse = MockResponse().setResponseCode(401) + server.enqueue(unauthorisedResponse) + + // when + val response = okHttpClient.newCall(request).execute() + + // then + assertThat(response.code).isEqualTo(401) + } + + @Test + fun `Should retry request with new access token when CredentialsProvider successfully got new token`() { + // given + val newAccessToken = "newAccessToken" + val credentialsProvider = FakeCredentialsProvider(true, newAccessToken) + val headersInterceptor = HeadersInterceptor(credentialsProvider) + val defaultAuthenticator = DefaultAuthenticator(credentialsProvider) + val okHttpClient = OkHttpClient.Builder() + .authenticator(defaultAuthenticator) + .addInterceptor(headersInterceptor) + .build() + + val unauthorisedResponse = MockResponse().setResponseCode(401) + val successResponse = MockResponse().setResponseCode(200) + + server.enqueue(unauthorisedResponse) + server.enqueue(successResponse) + + // when + val response = okHttpClient.newCall(request).execute() + + // then + assertThat(response.code).isEqualTo(200) + assertThat(response.request.header(AUTHORIZATION_HEADER_NAME)) + .isEqualTo("$BEARER $newAccessToken") + } + + @Test + fun `Should change request header to contain client id instead of the token after refresh that changed credentials level`() { + // given + val refreshToken = "" + val refreshClientId = "123" + val credentialsProvider = FakeCredentialsProvider(true, refreshToken, refreshClientId) + val headersInterceptor = HeadersInterceptor(credentialsProvider) + val defaultAuthenticator = DefaultAuthenticator(credentialsProvider) + val okHttpClient = OkHttpClient.Builder() + .authenticator(defaultAuthenticator) + .addInterceptor(headersInterceptor) + .build() + + val unauthorisedResponse = MockResponse().setResponseCode(401) + val successResponse = MockResponse().setResponseCode(200) + + server.enqueue(unauthorisedResponse) + server.enqueue(successResponse) + + // when + val response = okHttpClient.newCall(request).execute() + + // then + assertThat(response.code).isEqualTo(200) + assertThat(response.request.header(X_TIDAL_TOKEN_HEADER_NAME)) + .isEqualTo(refreshClientId) + assertThat(response.request.header(AUTHORIZATION_HEADER_NAME)) + .isEqualTo(null) + } +} diff --git a/eventproducer/src/test/kotlin/com/tidal/eventproducer/fakes/FakeCredentialsProvider.kt b/eventproducer/src/test/kotlin/com/tidal/eventproducer/fakes/FakeCredentialsProvider.kt new file mode 100644 index 00000000..b94848ca --- /dev/null +++ b/eventproducer/src/test/kotlin/com/tidal/eventproducer/fakes/FakeCredentialsProvider.kt @@ -0,0 +1,47 @@ +package com.tidal.eventproducer.fakes + +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.auth.model.AuthResult +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.common.NetworkError +import com.tidal.sdk.common.TidalMessage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.datetime.Instant + +class FakeCredentialsProvider( + private val isSuccessResult: Boolean = true, + private val refreshToken: String = "", + private val refreshClientId: String = "", +) : CredentialsProvider { + + var accessToken = "accessToken" + var clientId = "clientId" + + override val bus: Flow + get() = flowOf() + + override suspend fun getCredentials( + apiErrorSubStatus: String?, + ): AuthResult { + return if (isSuccessResult) { + accessToken = refreshToken + clientId = refreshClientId + AuthResult.Success(getDefaultCredentials(accessToken, clientId)) + } else { + AuthResult.Failure(NetworkError("404")) + } + } + + override fun isUserLoggedIn() = true + + private fun getDefaultCredentials(accessToken: String, clientId: String) = Credentials( + clientId = clientId, + requestedScopes = emptySet(), + clientUniqueKey = "", + grantedScopes = emptySet(), + userId = "", + expires = Instant.DISTANT_FUTURE, + token = accessToken, + ) +} diff --git a/eventproducer/src/test/kotlin/com/tidal/eventproducer/repository/EventsRepositoryTest.kt b/eventproducer/src/test/kotlin/com/tidal/eventproducer/repository/EventsRepositoryTest.kt new file mode 100644 index 00000000..1a5dab7b --- /dev/null +++ b/eventproducer/src/test/kotlin/com/tidal/eventproducer/repository/EventsRepositoryTest.kt @@ -0,0 +1,332 @@ +package com.tidal.eventproducer.repository + +import android.database.sqlite.SQLiteException +import com.tidal.sdk.eventproducer.EventProducer +import com.tidal.sdk.eventproducer.events.EventsLocalDataSource +import com.tidal.sdk.eventproducer.model.Event +import com.tidal.sdk.eventproducer.model.MonitoringEvent +import com.tidal.sdk.eventproducer.model.MonitoringEventType +import com.tidal.sdk.eventproducer.monitoring.MonitoringInfo +import com.tidal.sdk.eventproducer.monitoring.MonitoringLocalDataSource +import com.tidal.sdk.eventproducer.network.service.SendMessageBatchResponse +import com.tidal.sdk.eventproducer.network.service.SendMessageBatchResult +import com.tidal.sdk.eventproducer.network.service.SqsService +import com.tidal.sdk.eventproducer.repository.EventsRepository +import com.tidal.sdk.eventproducer.repository.RepositoryHelper +import com.tidal.sdk.eventproducer.utils.SqsRequestParametersConverter +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import java.util.concurrent.TimeoutException +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class EventsRepositoryTest { + + @MockK + private lateinit var eventsLocalDataSource: EventsLocalDataSource + + @MockK + private lateinit var monitoringDataSource: MonitoringLocalDataSource + + @MockK + private lateinit var repositoryHelper: RepositoryHelper + + @MockK + private lateinit var sqsParametersConverter: SqsRequestParametersConverter + + @MockK + private lateinit var sqsService: SqsService + + private lateinit var eventsRepository: EventsRepository + + @BeforeEach + fun init() { + clearAllMocks() + MockKAnnotations.init(this) + eventsRepository = EventsRepository( + eventsLocalDataSource, + monitoringDataSource, + repositoryHelper, + sqsParametersConverter, + sqsService, + ) + } + + @Test + fun `insert event add event to local data source when database size limit has not been reached`() { + // given + val event = Event("1", "name", emptyMap(), "payload") + every { repositoryHelper.isDatabaseLimitReached() } returns false + every { eventsLocalDataSource.insertEvent(any()) } returns Unit + + // when + eventsRepository.insertEvent(event) + + // then + verify { eventsLocalDataSource.insertEvent(event) } + } + + @Test + fun `insert event updates storing failed monitoring data when database size limit has been reached`() { + // given + val eventName = "eventName" + val event = Event("1", eventName, emptyMap(), "payload") + val monitoringInfoBeforeUpdate = + MonitoringInfo(mutableMapOf(), mutableMapOf(), mutableMapOf()) + val monitoringInfoAfterUpdate = MonitoringInfo( + mutableMapOf(), + mutableMapOf(), + storingFailedEvents = mutableMapOf(eventName to 1), + ) + every { repositoryHelper.isDatabaseLimitReached() } returns true + every { monitoringDataSource.getMonitoringInfo() } returns monitoringInfoBeforeUpdate + every { monitoringDataSource.insert(any()) } returns Unit + + // when + eventsRepository.insertEvent(event) + + // then + verify { monitoringDataSource.insert(monitoringInfoAfterUpdate) } + } + + @Test + fun `insert event starts outage and updates monitoring data when data source responses with exception`() { + // given + val eventName = "eventName" + val event = Event("1", eventName, emptyMap(), "payload") + val monitoringInfoBeforeUpdate = + MonitoringInfo(mutableMapOf(), mutableMapOf(), mutableMapOf()) + val monitoringInfoAfterUpdate = MonitoringInfo( + mutableMapOf(), + mutableMapOf(), + storingFailedEvents = mutableMapOf(eventName to 1), + ) + every { repositoryHelper.isDatabaseLimitReached() } returns false + every { monitoringDataSource.getMonitoringInfo() } returns monitoringInfoBeforeUpdate + every { monitoringDataSource.insert(any()) } returns Unit + every { eventsLocalDataSource.insertEvent(any()) } throws SQLiteException() + mockkObject(EventProducer.Companion) + every { EventProducer.instance?.startOutage() } returns Unit + + // when + eventsRepository.insertEvent(event) + + // then + verify { monitoringDataSource.insert(monitoringInfoAfterUpdate) } + verify { EventProducer.instance?.startOutage() } + + unmockkObject(EventProducer.Companion) + } + + @Test + fun `delete event deletes event from local data source`() { + // given + val eventIds = listOf("1", "3", "5") + every { eventsLocalDataSource.deleteEvents(eventIds) } returns Unit + + // when + eventsRepository.deleteEvents(eventIds) + + // then + verify { eventsLocalDataSource.deleteEvents(eventIds) } + } + + @Test + fun `get all returns events from local data source`() { + // given + every { eventsLocalDataSource.getAllEvents() } returns emptyList() + + // when + eventsRepository.getAll() + + // then + verify { eventsLocalDataSource.getAllEvents() } + } + + @Test + fun `send events to sqs invokes sendEventBatch on sqsService when user is logged in`() = + runTest { + // given + val sqsParameters = mapOf("SendMessageBatchRequestEntry.1.Id" to "1") + val response = SendMessageBatchResponse( + SendMessageBatchResult(null), + ) + every { sqsParametersConverter.getSendEventsParameters(any()) } returns sqsParameters + every { repositoryHelper.isUserLoggedIn() } returns true + coEvery { sqsService.sendEventsBatch(any()) } returns response + + // when + eventsRepository.sendEventsToSqs(listOf()) + + // then + coVerify { sqsService.sendEventsBatch(sqsParameters) } + } + + @Test + fun `send events to sqs invokes sendEventBatchPublic on sqsService when user is logged out`() = + runTest { + // given + val sqsParameters = mapOf("SendMessageBatchRequestEntry.1.Id" to "1") + val response = SendMessageBatchResponse( + SendMessageBatchResult(null), + ) + every { sqsParametersConverter.getSendEventsParameters(any()) } returns sqsParameters + every { repositoryHelper.isUserLoggedIn() } returns false + coEvery { sqsService.sendEventsBatchPublic(any()) } returns response + + // when + eventsRepository.sendEventsToSqs(listOf()) + + // then + coVerify { sqsService.sendEventsBatchPublic(sqsParameters) } + } + + @Test + fun `send events returns success result with payload returned by service`() = + runTest { + // given + val sqsParameters = mapOf("SendMessageBatchRequestEntry.1.Id" to "1") + val serviceResponse = SendMessageBatchResponse( + SendMessageBatchResult(null), + ) + every { sqsParametersConverter.getSendEventsParameters(any()) } returns sqsParameters + every { repositoryHelper.isUserLoggedIn() } returns false + coEvery { sqsService.sendEventsBatchPublic(any()) } returns serviceResponse + + // when + val result = eventsRepository.sendEventsToSqs(listOf()) + + // then + assert(result.isSuccess) + assert(result.successData == serviceResponse) + } + + @Test + fun `send events returns failure result when service throws an exception`() = + runTest { + // given + val sqsParameters = mapOf("SendMessageBatchRequestEntry.1.Id" to "1") + every { sqsParametersConverter.getSendEventsParameters(any()) } returns sqsParameters + every { repositoryHelper.isUserLoggedIn() } returns false + coEvery { sqsService.sendEventsBatchPublic(any()) } throws TimeoutException() + + // when + val result = eventsRepository.sendEventsToSqs(listOf()) + + // then + assert(result.isFailure) + } + + @Test + fun `increase event validation failed occurrence updates monitoring data in monitoring data source`() { + // given + val eventName = "eventName" + val monitoringInfoBeforeUpdate = + MonitoringInfo(mutableMapOf(), mutableMapOf(), mutableMapOf()) + val monitoringInfoAfterUpdate = MonitoringInfo( + mutableMapOf(), + validationFailedEvents = mutableMapOf(eventName to 1), + mutableMapOf(), + ) + every { monitoringDataSource.getMonitoringInfo() } returns monitoringInfoBeforeUpdate + every { monitoringDataSource.insert(any()) } returns Unit + + // when + eventsRepository.storeNewMonitoringEvent( + MonitoringEvent( + MonitoringEventType.EventValidationFailed, + eventName, + ), + ) + + // then + verify { monitoringDataSource.insert(monitoringInfoAfterUpdate) } + } + + @Test + fun `increase event consent filtered occurrence updates monitoring data in monitoring data source`() { + // given + val eventName = "eventName" + val monitoringInfoBeforeUpdate = + MonitoringInfo(mutableMapOf(), mutableMapOf(), mutableMapOf()) + val monitoringInfoAfterUpdate = MonitoringInfo( + consentFilteredEvents = mutableMapOf(eventName to 1), + mutableMapOf(), + mutableMapOf(), + ) + every { monitoringDataSource.getMonitoringInfo() } returns monitoringInfoBeforeUpdate + every { monitoringDataSource.insert(any()) } returns Unit + + // when + eventsRepository.storeNewMonitoringEvent( + MonitoringEvent( + MonitoringEventType.EventConsentFiltered, + eventName, + ), + ) + + // then + verify { monitoringDataSource.insert(monitoringInfoAfterUpdate) } + } + + @Test + fun `send monitoring event invokes send event batch on sqsService when monitoring info not empty`() = runTest { + // given + val monitoringInfo = + MonitoringInfo(mutableMapOf("event1" to 1), mutableMapOf(), mutableMapOf()) + val monitoringEvent = Event("1", "eventName", emptyMap(), "payload") + val sqsParameters = mapOf("SendMessageBatchRequestEntry.1.Id" to "1") + val serviceResponse = SendMessageBatchResponse( + SendMessageBatchResult(null), + ) + every { monitoringDataSource.getMonitoringInfo() } returns monitoringInfo + every { repositoryHelper.getMonitoringEvent(monitoringInfo) } returns monitoringEvent + every { repositoryHelper.isUserLoggedIn() } returns true + every { + sqsParametersConverter.getSendEventsParameters(listOf(monitoringEvent)) + } returns sqsParameters + coEvery { sqsService.sendEventsBatch(sqsParameters) } returns serviceResponse + + // when + eventsRepository.sendMonitoringEvent() + + // then + coVerify { sqsService.sendEventsBatch(sqsParameters) } + } + + @Test + fun `send monitoring event doesn't invoke send event batch on sqsService when monitoring info is empty`() = runTest { + // given + val monitoringInfo = MonitoringInfo(mutableMapOf(), mutableMapOf(), mutableMapOf()) + every { monitoringDataSource.getMonitoringInfo() } returns monitoringInfo + + // when + eventsRepository.sendMonitoringEvent() + + // then + coVerify(exactly = 0) { sqsService.sendEventsBatch(any()) } + } + + @Test + fun `clear monitoring info inserts empty object as monitoring info to monitoring data source`() { + // given + val emptyMonitoringInfo = MonitoringInfo(mutableMapOf(), mutableMapOf(), mutableMapOf()) + every { monitoringDataSource.insert(any()) } returns Unit + + // when + eventsRepository.clearMonitoringInfo() + + // then + verify { monitoringDataSource.insert(emptyMonitoringInfo) } + } +} diff --git a/eventproducer/src/test/kotlin/com/tidal/eventproducer/utils/HeadersUtilsTest.kt b/eventproducer/src/test/kotlin/com/tidal/eventproducer/utils/HeadersUtilsTest.kt new file mode 100644 index 00000000..6ab18112 --- /dev/null +++ b/eventproducer/src/test/kotlin/com/tidal/eventproducer/utils/HeadersUtilsTest.kt @@ -0,0 +1,41 @@ +package com.tidal.eventproducer.utils + +import assertk.assertThat +import assertk.assertions.containsAll +import com.tidal.eventproducer.fakes.FakeCredentialsProvider +import com.tidal.sdk.eventproducer.utils.APP_VERSION_KEY +import com.tidal.sdk.eventproducer.utils.CLIENT_ID_KEY +import com.tidal.sdk.eventproducer.utils.HeadersUtils +import com.tidal.sdk.eventproducer.utils.OS_NAME_KEY +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class HeadersUtilsTest { + + private val headerUtils = HeadersUtils( + "", + FakeCredentialsProvider(), + ) + + @Test + fun `verify that event headers contain both default and supplied headers`() { + val header3 = Pair(APP_VERSION_KEY, "1.0") + val header1 = Pair(CLIENT_ID_KEY, "1") + val header2 = Pair(OS_NAME_KEY, "Android") + val suppliedHeaders = mapOf(header1) + val defaultHeaders = mapOf(header2, header3) + val headers = headerUtils.getEventHeaders(defaultHeaders, suppliedHeaders) + assertThat(headers).containsAll(header1, header2, header3) + assertEquals(3, headers.size) + } + + @Test + fun `verify that supplied header override default one`() { + val defaultClientId = "1" + val suppliedClientId = "2" + val suppliedHeaders = mapOf(Pair(CLIENT_ID_KEY, suppliedClientId)) + val defaultHeaders = mapOf(Pair(CLIENT_ID_KEY, defaultClientId)) + val headers = headerUtils.getEventHeaders(defaultHeaders, suppliedHeaders) + assertEquals(headers[CLIENT_ID_KEY], suppliedClientId) + } +} diff --git a/eventproducer/src/test/kotlin/com/tidal/eventproducer/utils/SqsParametersConverterTest.kt b/eventproducer/src/test/kotlin/com/tidal/eventproducer/utils/SqsParametersConverterTest.kt new file mode 100644 index 00000000..bb1b5cd9 --- /dev/null +++ b/eventproducer/src/test/kotlin/com/tidal/eventproducer/utils/SqsParametersConverterTest.kt @@ -0,0 +1,78 @@ +package com.tidal.eventproducer.utils + +import com.tidal.sdk.eventproducer.model.Event +import com.tidal.sdk.eventproducer.utils.APP_VERSION_KEY +import com.tidal.sdk.eventproducer.utils.MapConverter +import com.tidal.sdk.eventproducer.utils.OS_NAME_KEY +import com.tidal.sdk.eventproducer.utils.SqsRequestParametersConverter +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.jupiter.api.Test + +class SqsParametersConverterTest { + + private var mapConverter = mockk() + + private val sqsParametersConverter = SqsRequestParametersConverter(mapConverter) + + @Test + fun `Properly converts list of events to sqs parameters`() { + val firstEventId = "1234" + val firstEventName = "Event1" + val osHeaderKey = OS_NAME_KEY + val androidHeaderValue = "Android" + val firstEventHeaders = mapOf(osHeaderKey to androidHeaderValue) + val firstEventPayload = "payload1" + val secondEventId = "5678" + val secondEventName = "Event2" + val iosHeaderValue = "ios" + val appVersionHeaderKey = APP_VERSION_KEY + val appVersionHeaderValue = "1.0" + val secondEventHeaders = + mapOf( + osHeaderKey to iosHeaderValue, + appVersionHeaderKey to appVersionHeaderValue.toString(), + ) + val payload2 = "payload2" + val event1 = Event(firstEventId, firstEventName, firstEventHeaders, firstEventPayload) + val event2 = Event(secondEventId, secondEventName, secondEventHeaders, payload2) + + val firstEventHeadersJson = firstEventHeaders.toString() + val secondEventHeadersJson = secondEventHeaders.toString() + every { mapConverter.fromStringStringMap(firstEventHeaders) } returns + firstEventHeadersJson + every { mapConverter.fromStringStringMap(secondEventHeaders) } returns + secondEventHeadersJson + + val expectedParametersStrings = mapOf( + "SendMessageBatchRequestEntry.1.Id" to firstEventId, + "SendMessageBatchRequestEntry.1.MessageBody" to firstEventPayload, + "SendMessageBatchRequestEntry.1.MessageAttribute.1.Name" to "Name", + "SendMessageBatchRequestEntry.1.MessageAttribute.1.Value.StringValue" to firstEventName, + "SendMessageBatchRequestEntry.1.MessageAttribute.1.Value.DataType" to "String", + "SendMessageBatchRequestEntry.1.MessageAttribute.2.Name" to "Headers", + "SendMessageBatchRequestEntry.1.MessageAttribute.2.Value.DataType" to "String", + "SendMessageBatchRequestEntry.1.MessageAttribute.2.Value.StringValue" to + firstEventHeadersJson, + "SendMessageBatchRequestEntry.2.Id" to secondEventId, + "SendMessageBatchRequestEntry.2.MessageBody" to payload2, + "SendMessageBatchRequestEntry.2.MessageAttribute.1.Name" to "Name", + "SendMessageBatchRequestEntry.2.MessageAttribute.1.Value.StringValue" to + secondEventName, + "SendMessageBatchRequestEntry.2.MessageAttribute.1.Value.DataType" to "String", + "SendMessageBatchRequestEntry.2.MessageAttribute.2.Name" to "Headers", + "SendMessageBatchRequestEntry.2.MessageAttribute.2.Value.DataType" to "String", + "SendMessageBatchRequestEntry.2.MessageAttribute.2.Value.StringValue" to + secondEventHeadersJson, + ) + + val convertedParameters = sqsParametersConverter.getSendEventsParameters( + listOf( + event1, + event2, + ), + ) + assertEquals(expectedParametersStrings, convertedParameters) + } +} diff --git a/generate-module.sh b/generate-module.sh new file mode 100755 index 00000000..06048f65 --- /dev/null +++ b/generate-module.sh @@ -0,0 +1,69 @@ +#!/bin/bash +readonly PLACEHOLDER="template" +readonly ROOT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +check_correct_repo(){ + if ! find $ROOT_DIR -maxdepth 1 -type d | grep -q $PLACEHOLDER; then + echo "Repository root does not contain a '$PLACEHOLDER' directory!" + echo "Are you sure you are in the correct project for this script?" + exit 1 + fi +} + +uppercase(){ + string=$1 + first=`echo $string|cut -c1|tr [a-z] [A-Z]` + second=`echo $string|cut -c2-` + echo $first$second +} + +check_correct_repo + +printf "\nEnter new module's name, using capitalized CamelCase\nExample: PlaybackEngine\n" +read module_name + +printf "\nDo you want to create a module named '%s'?" "$module_name" + +read -r -p "(y/n)" +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted!" + exit 1 +fi + +module_name_lowercase=$(echo "${module_name}" | tr '[:upper:]' '[:lower:]') + +echo "Copying files to '$ROOT_DIR/$module_name_lowercase'..." +mkdir -p $ROOT_DIR/$module_name_lowercase +rsync -a $ROOT_DIR/$PLACEHOLDER/* $ROOT_DIR/$module_name + +echo "Rename directories to '$module_name_lowercase'..." +for file in $(find "$ROOT_DIR/$module_name_lowercase" -name "*$PLACEHOLDER*" -type d -depth); do + if [[ ! $file =~ "build" ]]; then + dirname=$(echo $file) + fixed=$(echo $dirname | sed -r "s/(.*)$PLACEHOLDER/\1$module_name_lowercase/") + mv $file $fixed + fi +done + +echo "Rename keywords in files to '$module_name'..." +for file in $(find "$ROOT_DIR/$module_name_lowercase" -type f); do + if [[ $file == *.kt || $file == *.gradle*kts || $file == *.xml || $file == *.md ]]; then + sed -i '' "s/$PLACEHOLDER/$module_name_lowercase/g" $file + sed -i '' "s/$(uppercase $PLACEHOLDER)/$module_name/g" $file + fi +done + +echo "Rename keywords in file name_inputs to '$module_name_lowercase'..." +for file in $(find "$ROOT_DIR/$module_name_lowercase" -name "*$(uppercase $PLACEHOLDER)*" -type f); do + if [[ $file == *.kt || $file == *.kts || $file == *.xml ]]; then + filename=$(echo $file) + fixed=$(echo $filename | sed "s/$PLACEHOLDER/$module_name_lowercase/g") + fixed=$(echo $filename | sed "s/$(uppercase $PLACEHOLDER)/$module_name/g") + mv $file $fixed + fi +done + +echo "includeFromDefaultHierarchy(\"$module_name_lowercase\")" >> settings.gradle.kts + +echo "Done! Module '$module_name' has been successfully created in '$ROOT_DIR/$module_name_lowercase'." diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..665430b3 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx4096m -XX:+UseParallelGC -Dfile.encoding=UTF-8 +org.gradle.caching=true +org.gradle.parallel=true +android.useAndroidX=true +android.enableBuildConfigAsBytecode=true +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..3d322902 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,135 @@ +[versions] +activity-compose = "1.8.2" +coroutines = "1.8.0" +compose-compiler = "1.5.11" +compose-libs = "1.6.5" +compose-navigation = "2.7.7" +core-ktx = "1.12.0" +dagger = "2.51.1" +dokka = "1.9.20" +junit5 = "5.10.2" +kotlin = "1.9.23" +kotlin-logging = "6.0.3" +mockito = "5.8.0" +mockito-kotlin = "5.2.1" +moshi = "1.15.1" +okhttp3 = "4.12.0" +plugin-android = "8.2.2" +retrofit = "2.11.0" +room = "2.6.1" +slf4j-api = "2.0.12" +tickaroo = "0.8.13" +tidal-androidx-media = "1.1.1.2" +plugins-tidal = "unspecified" + +[libraries] +plugin-kotlin-android = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +plugin-android-tools-build = { group = "com.android.tools.build", name = "gradle", version.ref = "plugin-android" } +plugin-dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } +plugin-gradle-maven-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version = "0.28.0" } + +# Android +androidx-annotations = "androidx.annotation:annotation:1.3.0" +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version = "2.7.0" } + +# Compose +compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose-libs" } +compose-material = { module = "androidx.compose.material:material", version.ref = "compose-libs" } +compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "compose-navigation" } +androidx-compose-bom = "androidx.compose:compose-bom:2024.04.00" +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } + +# Coroutines +kotlinxCoroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinxCoroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } + +# Dependency injection +dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } +dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } + +dokka-android = { module = "org.jetbrains.dokka:android-documentation-plugin", version.ref = "dokka" } +gson = "com.google.code.gson:gson:2.9.0" +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +kotlin-logging = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "kotlin-logging" } +slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-api" } +truetime = "com.github.instacart.truetime-android:library-extension-rx:3.5" + +# Moshi +moshi = { module = "com.squareup.moshi:moshi", version.ref ="moshi"} +moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } + +# Networking +okhttp-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp3" } +okhttp-loggingInterceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp3" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp3" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } + +# Room +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } + +# Serialization +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.5.0" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.6.3" } +kotlinx-serialization-retrofit-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version = "1.0.0" } + +# SDK +tidal-sdk-common = { module = "com.tidal.sdk:common", version = "0.2.4" } +tidal-sdk-auth = { module = "com.tidal.sdk:auth", version = "0.9.1" } +tidal-sdk-eventproducer = { module = "com.tidal.sdk:eventproducer", version = "0.3.1" } +tidal-sdk-player = { module = "com.tidal.sdk:player", version = "0.0.39" } + +# Testing +test-androidx-espresso-core = "androidx.test.espresso:espresso-core:3.5.1" +test-androidx-junit = { module = "androidx.test.ext:junit", version = "1.1.5" } +test-androidx-runner = { module = "androidx.test:runner", version = "1.5.2" } +test-androidx-orchestrator = { module = "androidx.test:orchestrator", version = "1.4.2" } +test-assertk = "com.willowtreeapps.assertk:assertk-jvm:0.28.0" +test-fluidtime = { module = "io.fluidsonic.time:fluid-time", version = "0.18.0" } +test-junit5Api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" } +test-junit5Engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } +test-junit5Params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit5" } +test-kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +test-mockk = { module = "io.mockk:mockk", version = "1.13.9" } +test-mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockito" } +test-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } +test-turbine = { module = "app.cash.turbine:turbine", version = "1.0.0" } + +#Tickaroo +tickaroo-annotation = { module = "com.tickaroo.tikxml:annotation", version.ref = "tickaroo" } +tickaroo-core = { module = "com.tickaroo.tikxml:core", version.ref = "tickaroo" } +tickaroo-processor = { module = "com.tickaroo.tikxml:processor", version.ref = "tickaroo" } +tickaroo-retrofitConverter = { module = "com.tickaroo.tikxml:retrofit-converter", version.ref = "tickaroo" } + +tidal-exoPlayer-core = { module = "com.tidal.androidx.media3:media3-exoplayer", version.ref = "tidal-androidx-media" } +tidal-exoPlayer-dash = { module = "com.tidal.androidx.media3:media3-exoplayer-dash", version.ref = "tidal-androidx-media" } +tidal-exoPlayer-hls = { module = "com.tidal.androidx.media3:media3-exoplayer-hls", version.ref = "tidal-androidx-media" } +tidal-exoPlayer-extension-flac = { module = "com.tidal.androidx.media3:media3-flac", version.ref = "tidal-androidx-media" } +tidal-exoPlayer-extension-okhttp = { module = "com.tidal.androidx.media3:media3-datasource-okhttp", version.ref = "tidal-androidx-media" } + +# Security +androidx-security-crypto = { module = "androidx.security:security-crypto", version = "1.0.0" } + +[bundles] +compose = [ + "androidx-activity-compose", + "compose-ui", + "compose-material", + "compose-navigation" +] + +[plugins] +android-junit5 = { id = "de.mannodermaus.android-junit5", version = "1.10.0.0" } +google-devtools-ksp = { id = "com.google.devtools.ksp", version = "1.9.23-1.0.20" } +gradle-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.28.0" } +kotlin-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +tidal-android-application = { id = "com.tidal-sdk.android.application", version.ref = "plugins-tidal" } +tidal-android-library = { id = "com.tidal-sdk.android.library", version.ref = "plugins-tidal" } +tidal-kotlin-jvm = { id = "com.tidal-sdk.kotlin.jvm", version.ref = "plugins-tidal" } +tidal-jvm-platform = { id = "com.tidal-sdk.jvm.platform", version.ref = "plugins-tidal" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..943f0cbfa754578e88a3dae77fce6e3dea56edbf GIT binary patch literal 61574 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 ScNgau(U?ep-K_E5zy1%ZQTdPn literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..7fc84bec --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..65dcd68d --- /dev/null +++ b/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..93e3f59f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/local-setup.sh b/local-setup.sh new file mode 100755 index 00000000..7b161a71 --- /dev/null +++ b/local-setup.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +create_git_hooks() { + echo "Copying pre-commit hook" >&2 + mkdir -p "./.git/hooks/" + cp "./static-analysis/git-hooks/pre-commit" "./.git/hooks/pre-commit" + chmod a+x ".git/hooks/pre-commit" + echo "Done." >&2 +} + +create_git_hooks diff --git a/player/CHANGELOG.md b/player/CHANGELOG.md new file mode 100644 index 00000000..e329a51e --- /dev/null +++ b/player/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.0.41] - 2024-05-27 +### Changed +- Use our public fork of TIDAL's AndroidX Media library + +## [0.0.40] - 2024-05-24 +### Added +- Added support for Fragmented MP4. This is needed to be able to play those files directly, which is a new way of playing back certain files coming from our backend. +- Added back version parameter in Player constructor, but this time it is optional. + +### Changed +- Depend on EventProducer 0.3.0 + +### Fixed +- Fix broken broadcast playback: Wrong parameter order for djSessionId and streamingSessionId. + +### Removed +- Don't explicitly allow chunkless preparation on hls. This is already set as default. + +## [0.0.39] - 2024-05-15 +### Fixed +- Use the FlacExtractor from decoder package. This uses LibflacAudioRenderer and has better support than the one that was currently in use. This is needed so we have as broad support as possible. + +### Removed +- Remove version as parameter from Player constructor. + +## [0.0.38] - 2024-02-08 +### Added +- Initial release of the TIDAL Player module +### Removed +- Usage of CredentialsProvider.getLatestCredentials +- Stop reporting "progress" events diff --git a/player/README.md b/player/README.md new file mode 100644 index 00000000..61be6718 --- /dev/null +++ b/player/README.md @@ -0,0 +1,76 @@ +# Module player +The TIDAL Player client for Android. + +The Player module encapsulates the playback functionality of TIDAL media products. The implementation depends on a fork of AndroidX Media repository and uses `ExoPlayer` as the media player. Check [tidal-androidx-media](https://github.com/tidal-music/tidal-androidx-media). + +## Features +* Streaming and playing TIDAL catalog content. +* Core playback functionality. +* Media streaming, caching and error handling. +* Automatic management of playback session event reporting. + +## Documentation + +* Read the [documentation](https://github.com/tidal-music/tidal-sdk/blob/main/Player.md) for a detailed overview of the player functionality. +* Check the [API documentation](https://tidal-music.github.io/tidal-sdk-android/player/index.html) for the module classes and methods. +* Visit our [TIDAL Developer Platform](https://developer.tidal.com/) for more information and getting started. + +## Usage + +### Installation + +1. We are using the [TrueTime library](https://github.com/instacart/truetime-android) internally, so you need to add the following to your repositories list: +```kotlin +maven { + url = uri("https://jitpack.io") +} +``` + +2. Add the dependency to your `build.gradle.kts` file. +```kotlin +dependencies { + implementation("com.tidal.sdk:player:") +} +``` +### Playing a TIDAL track +The Player depends on the [Auth](https://github.com/tidal-music/tidal-sdk-android/blob/main/auth/README.md) and [EventProducer](https://github.com/tidal-music/tidal-sdk-android/tree/main/eventproducer) modules for authentication and event reporting handling. For detailed instructions on how to set them up, please refer to their guide. + +Here's how to setup the Player and play a TIDAL track: + +1. Initialise the Player which depends on a `CredentialsProvider` from the Auth module and an `EventSender` from the EventProducer module. +```kotlin +val player = Player( + application = application, + credentialsProvider = auth.credentialsProvider, + eventSender = eventSender +) +``` + +2. Load and play a `MediaProduct` track. +```kotlin +val mediaProduct = MediaProduct(ProductType.TRACK, "PRODUCT_ID") + +player.playbackEngine.load(mediaProduct) +player.playbackEngine.play() +``` + +3. _(Optional)_ Listen to [player events](https://github.com/tidal-music/tidal-sdk-android/blob/main/player/playback-engine/src/main/kotlin/com/tidal/sdk/player/playbackengine/model/Event.kt). +```kotlin +player.playbackEngine.events.onEach { + Log.d(TAG, "Event=$it") +}.launchIn(coroutineScope) +``` + +## Running the test app + +The player module includes a [test app](https://github.com/tidal-music/tidal-sdk-android/tree/main/player/app) that demonstrates how to setup the player and showcases its different functionalities. + +As a prerequisite for the player to work, the client is required to be authenticated. You can learn more about the authentication flows in the [Auth module](https://github.com/tidal-music/tidal-sdk-android/tree/main/auth). Note that currently only the ```Client Credentials``` flow is publicly supported. This enables the test app to play 30-second tracks previews. Full length playback is only enabled when the client is authenticated through ```Device Login``` or ```Authorization Code Flow```. + +In order to run the test app, please declare your client credentials in the top-level ```local.properties``` file: + +``` +tidal.clientid="YOUR_CLIENT_ID" +tidal.clientsecret="YOUR_CLIENT_SECRET" +``` +**Note**: you can obtain the ```client id``` and ```client secret``` after signing up and creating an application in the [TIDAL Developer Platform](https://developer.tidal.com/). diff --git a/player/apps/demo/build.gradle.kts b/player/apps/demo/build.gradle.kts new file mode 100644 index 00000000..e7448ac3 --- /dev/null +++ b/player/apps/demo/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + alias(libs.plugins.tidal.android.application) +} + +android { + namespace = "com.tidal.sdk.player" + + defaultConfig { + applicationId = "com.tidal.sdk.player" + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + signingConfig = signingConfigs.getByName("debug") + } + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + + kotlinOptions { + freeCompilerArgs += "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api" + freeCompilerArgs += "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi" + } +} + +dependencies { + implementation(project(":player")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.truetime) +} diff --git a/player/apps/demo/src/main/AndroidManifest.xml b/player/apps/demo/src/main/AndroidManifest.xml new file mode 100644 index 00000000..506fe124 --- /dev/null +++ b/player/apps/demo/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/MyApplication.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/MyApplication.kt new file mode 100644 index 00000000..3c081175 --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/MyApplication.kt @@ -0,0 +1,31 @@ +package com.tidal.sdk.player + +import android.app.Application +import android.util.Log +import com.instacart.library.truetime.TrueTime + +internal class MyApplication : Application() { + + override fun onCreate() { + super.onCreate() + + Thread( + { + while (true) { + try { + TrueTime() + .withRootDelayMax(Float.MAX_VALUE) + .withRootDispersionMax(Float.MAX_VALUE) + .withServerResponseDelayMax(Int.MAX_VALUE) + .initialize() + break + } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { + Log.e(this::class.java.simpleName, "TrueTime initialization error", e) + Thread.sleep(1) + } + } + }, + "TrueTime initialization", + ).start() + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ComposeWebView.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ComposeWebView.kt new file mode 100644 index 00000000..4e12dad3 --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ComposeWebView.kt @@ -0,0 +1,52 @@ +package com.tidal.sdk.player.auth.weblogin + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.net.Uri +import android.webkit.WebSettings +import android.webkit.WebView +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.util.UnstableApi + +@Composable +@SuppressLint("SetJavaScriptEnabled") +@UnstableApi +internal fun ComposeWebView( + modifier: Modifier = Modifier, + dispatchSetSnackbarMessage: (String) -> Unit, + dispatchFinalizeWebLogin: (Context, Uri) -> Unit, + url: String, +) { + val javaScriptInterface = JavaScriptInterface() + val extendedWebViewClient = ExtendedWebClient( + LocalContext.current, + dispatchSetSnackbarMessage, + dispatchFinalizeWebLogin, + ) + + AndroidView( + modifier = modifier, + factory = { context -> + WebView.setWebContentsDebuggingEnabled(true) + WebView(context).apply { + setBackgroundColor(Color.TRANSPARENT) + settings.apply { + loadWithOverviewMode = true + useWideViewPort = true + javaScriptEnabled = true + cacheMode = WebSettings.LOAD_NO_CACHE + isScrollbarFadingEnabled = false + } + + webChromeClient = ExtendedChromeClient() + webViewClient = extendedWebViewClient + addJavascriptInterface(javaScriptInterface, "javascriptObject") + loadUrl(url) + } + }, + ) +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ExtendedChromeClient.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ExtendedChromeClient.kt new file mode 100644 index 00000000..322f8f27 --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ExtendedChromeClient.kt @@ -0,0 +1,21 @@ +package com.tidal.sdk.player.auth.weblogin + +import android.net.Uri +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebView + +internal class ExtendedChromeClient : WebChromeClient() { + + override fun onShowFileChooser( + webView: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams?, + ): Boolean { + return if (filePathCallback != null && fileChooserParams != null) { + true + } else { + super.onShowFileChooser(webView, filePathCallback, fileChooserParams) + } + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ExtendedWebClient.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ExtendedWebClient.kt new file mode 100644 index 00000000..3289057b --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/ExtendedWebClient.kt @@ -0,0 +1,69 @@ +package com.tidal.sdk.player.auth.weblogin + +import android.annotation.TargetApi +import android.content.Context +import android.net.Uri +import android.os.Build +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.media3.common.util.UnstableApi +import com.tidal.sdk.player.mainactivity.MainActivityViewModel + +@UnstableApi +internal class ExtendedWebClient( + private val context: Context, + private val onErrorReceived: (String) -> Unit, + private val onRedirectUriReceived: (Context, Uri) -> Unit, +) : WebViewClient() { + + override fun onPageCommitVisible(view: WebView?, url: String?) { + super.onPageCommitVisible(view, url) + } + + @TargetApi(Build.VERSION_CODES.N) + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return hasRedirectUri(request.url) + } + + @SuppressWarnings("deprecation") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + val uri = Uri.parse(url) + return hasRedirectUri(uri) + } + + private fun hasRedirectUri(uri: Uri): Boolean { + return if (uri.toString().startsWith(MainActivityViewModel.LOGIN_URI)) { + onRedirectUriReceived(context, uri) + true + } else { + false + } + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + onReceivedError( + view, + error.errorCode, + error.description.toString(), + request.url.toString(), + ) + } + + @SuppressWarnings("deprecation") + override fun onReceivedError( + view: WebView, + errorCode: Int, + description: String, + failingUrl: String, + ) { + onErrorReceived( + "Error errorCode=$errorCode description=$description failingUrl=$failingUrl", + ) + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/JavaScriptInterface.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/JavaScriptInterface.kt new file mode 100644 index 00000000..0da4135b --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/auth/weblogin/JavaScriptInterface.kt @@ -0,0 +1,32 @@ +package com.tidal.sdk.player.auth.weblogin + +import android.webkit.JavascriptInterface +import androidx.annotation.Keep + +@Suppress("EmptyFunctionBlock") +internal class JavaScriptInterface { + + @Keep + @Suppress("unused") + @JavascriptInterface + fun triggerFacebookSDKLogin() { + } + + @Keep + @Suppress("unused") + @JavascriptInterface + fun triggerTwitterSDKLogin() { + } + + @Keep + @Suppress("unused") + @JavascriptInterface + fun triggerResetPassword(url: String) { + } + + @Keep + @Suppress("unused") + @JavascriptInterface + fun openInExternalBrowser(url: String, closeWebView: Boolean) { + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DemoPlayableItem.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DemoPlayableItem.kt new file mode 100644 index 00000000..4901f841 --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DemoPlayableItem.kt @@ -0,0 +1,64 @@ +package com.tidal.sdk.player.mainactivity + +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.player.common.model.MediaProduct +import com.tidal.sdk.player.common.model.ProductType +import java.util.UUID + +internal data class DemoPlayableItem( + val name: String, + val productType: ProductType, + val mediaProductId: String, + val allowedCredentialLevels: Set, +) { + + fun createMediaProduct(referenceId: String = UUID.randomUUID().toString()) = + MediaProduct(productType, mediaProductId, referenceId = referenceId) + + companion object { + val HARDCODED = arrayOf( + DemoPlayableItem( + "Izzo", + ProductType.TRACK, + "35738577", + setOf(Credentials.Level.USER, Credentials.Level.CLIENT), + ), + DemoPlayableItem( + "Empire State Of Mind", + ProductType.TRACK, + "37704290", + setOf(Credentials.Level.USER, Credentials.Level.CLIENT), + ), + DemoPlayableItem( + "Yellow", + ProductType.TRACK, + "120272", + setOf(Credentials.Level.USER, Credentials.Level.CLIENT), + ), + DemoPlayableItem( + "The heart part 5", + ProductType.VIDEO, + "228097594", + setOf(Credentials.Level.USER), + ), + DemoPlayableItem( + "Hurt", + ProductType.VIDEO, + "104175463", + setOf(Credentials.Level.USER), + ), + DemoPlayableItem( + "Ronnie", + ProductType.VIDEO, + "63520295", + setOf(Credentials.Level.USER), + ), + DemoPlayableItem( + "Dreams (Dolby Atmos)", + ProductType.TRACK, + "215245845", + setOf(Credentials.Level.USER), + ), + ) + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DemoPlayableItemComposable.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DemoPlayableItemComposable.kt new file mode 100644 index 00000000..96630e4d --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DemoPlayableItemComposable.kt @@ -0,0 +1,88 @@ +package com.tidal.sdk.player.mainactivity + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.tidal.sdk.player.common.model.MediaProduct + +@Composable +@Suppress("LongMethod", "LongParameterList") +internal fun DemoPlayableItemComposable( + item: DemoPlayableItem, + isCurrent: Boolean, + isNext: Boolean, + dispatchLoad: (MediaProduct) -> Unit, + calculateFollowingHardcodedMediaProduct: () -> MediaProduct?, + dispatchSetNext: (MediaProduct?) -> Unit, + dispatchPlay: () -> Unit, + dispatchSkip: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .clickable { + dispatchLoad(item.createMediaProduct()) + dispatchSetNext(calculateFollowingHardcodedMediaProduct()) + dispatchPlay() + }, + ) { + Text( + text = + "${item.name} (${item.mediaProductId}, ${item.productType})", + style = LocalTextStyle.current.copy( + color = when { + isCurrent -> MaterialTheme.colorScheme.primary + isNext -> MaterialTheme.colorScheme.inversePrimary + else -> Color.Unspecified + }, + ), + ) + Row { + Column { + TextButton( + enabled = !isCurrent, + onClick = { dispatchLoad(item.createMediaProduct()) }, + ) { + Text(text = if (isCurrent) "LOADED" else "LOAD") + } + TextButton( + enabled = !isNext, + onClick = { dispatchSetNext(item.createMediaProduct()) }, + ) { + Text(text = if (isNext) "SET AS NEXT" else "NEXT") + } + } + Column { + TextButton( + enabled = !isCurrent, + onClick = { + dispatchLoad(item.createMediaProduct()) + dispatchPlay() + }, + ) { + Text(text = if (isCurrent) "LOADED" else "LOAD AND PLAY") + } + TextButton( + enabled = !isNext, + onClick = { + dispatchSetNext(item.createMediaProduct()) + dispatchSkip() + }, + ) { + Text(text = if (isNext) "SET AS NEXT" else "NEXT AND SKIP TO") + } + } + } + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DeriveUiState.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DeriveUiState.kt new file mode 100644 index 00000000..6d02305c --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/DeriveUiState.kt @@ -0,0 +1,41 @@ +package com.tidal.sdk.player.mainactivity + +internal class DeriveUiState { + + operator fun invoke(mainActivityViewModelState: MainActivityViewModelState) = + mainActivityViewModelState.run { + when (this) { + is MainActivityViewModelState.AwaitingLoginFlowChoice -> + MainActivityState.AwaitingLoginFlowChoice( + snackbarMessage, + isUserLoggedIn, + webLoginUri, + ) + + is MainActivityViewModelState.LoggingIn, + is MainActivityViewModelState.PlayerInitializing, + is MainActivityViewModelState.PlayerReleasing, + -> + MainActivityState.Loading(snackbarMessage) + + is MainActivityViewModelState.PlayerNotInitialized -> + MainActivityState.PlayerNotInitialized(snackbarMessage) + + is MainActivityViewModelState.PlayerInitialized -> + MainActivityState.PlayerInitialized( + snackbarMessage, + streamingAudioQualityWifi, + streamingAudioQualityCellular, + loudnessNormalizationMode, + current, + next, + player.playbackEngine.playbackState, + player.playbackEngine.outputDevice, + isRepeatOneEnabled, + player.configuration.isOfflineMode, + player.playbackEngine.playbackContext?.duration, + draggedPositionSeconds ?: player.playbackEngine.assetPosition, + ) + } + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/LoadingScreen.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/LoadingScreen.kt new file mode 100644 index 00000000..04907577 --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/LoadingScreen.kt @@ -0,0 +1,20 @@ +package com.tidal.sdk.player.mainactivity + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +internal fun LoadingScreen() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator() + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/LoginScreen.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/LoginScreen.kt new file mode 100644 index 00000000..1fc1275f --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/LoginScreen.kt @@ -0,0 +1,67 @@ +package com.tidal.sdk.player.mainactivity + +import android.content.Context +import android.net.Uri +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.util.UnstableApi +import com.tidal.sdk.player.BuildConfig +import com.tidal.sdk.player.auth.weblogin.ComposeWebView + +@Composable +@Suppress("LongParameterList") +@UnstableApi +internal fun LoginScreen( + snackbarHostState: SnackbarHostState, + state: MainActivityState.AwaitingLoginFlowChoice, + paddingValues: PaddingValues = PaddingValues(), + dispatchSetSnackbarMessage: (String?) -> Unit, + dispatchFinalizeWebLoginFlow: (Context, Uri) -> Unit, + dispatchFinalizeImplicitLoginFlow: (Context) -> Unit, +) { + if (state.snackbarMessage == null) { + snackbarHostState.currentSnackbarData?.dismiss() + } else { + LaunchedEffect(snackbarHostState) { + check( + snackbarHostState.showSnackbar( + message = state.snackbarMessage, + withDismissAction = true, + duration = SnackbarDuration.Indefinite, + ) == SnackbarResult.Dismissed, + ) + dispatchSetSnackbarMessage(null) + } + } + if (state.isUserLoggedIn || !BuildConfig.TIDAL_CLIENT_SECRET.isNullOrBlank()) { + LoadingScreen() + val context = LocalContext.current + LaunchedEffect(Unit) { dispatchFinalizeImplicitLoginFlow(context) } + return + } + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding()), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + ComposeWebView( + Modifier.fillMaxSize(), + dispatchSetSnackbarMessage, + dispatchFinalizeWebLogin = dispatchFinalizeWebLoginFlow, + state.webLoginUri.toString(), + ) + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivity.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivity.kt new file mode 100644 index 00000000..a050252c --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivity.kt @@ -0,0 +1,154 @@ +package com.tidal.sdk.player.mainactivity + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelProvider +import androidx.media3.common.util.UnstableApi +import com.tidal.sdk.player.mainactivity.MainActivityViewModel.Operation.Impure +import com.tidal.sdk.player.ui.theme.PlayerTheme + +@UnstableApi +internal class MainActivity : ComponentActivity() { + + @Suppress("LongMethod") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val snackbarHostState = remember { SnackbarHostState() } + PlayerTheme { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + text = "Player", + ) + }, + ) + }, + ) { paddingValues -> + Column { + ViewModelProvider( + this@MainActivity, + MainActivityViewModel.Factory(this@MainActivity), + )[MainActivityViewModel::class.java].run { + val addedNonTopPadding = 16.dp + MainActivityScreen( + uiState.collectAsState().value, + snackbarHostState, + PaddingValues( + start = paddingValues.calculateStartPadding( + LocalLayoutDirection.current, + ) + addedNonTopPadding, + top = paddingValues.calculateTopPadding(), + end = paddingValues.calculateEndPadding( + LocalLayoutDirection.current, + ) + addedNonTopPadding, + bottom = paddingValues.calculateBottomPadding() + + addedNonTopPadding, + ), + dispatchLoad = { + dispatch(Impure.Load(it)) + }, + dispatchSetNext = { + dispatch(Impure.SetNext(it)) + }, + dispatchPlay = { + dispatch(MainActivityViewModel.Operation.Pure.Play) + }, + dispatchSkip = { + dispatch(MainActivityViewModel.Operation.Pure.Skip) + }, + dispatchSetVideoSurfaceView = { + dispatch(Impure.SetVideoSurfaceView(it)) + }, + dispatchSetDraggedPosition = { + dispatch(Impure.SetDraggedPosition(it)) + }, + dispatchPause = { + dispatch(MainActivityViewModel.Operation.Pure.Pause) + }, + dispatchReset = { + dispatch(Impure.Reset) + }, + dispatchRewind = { + dispatch(MainActivityViewModel.Operation.Pure.Seek.Rewind) + }, + dispatchFastForward = { + dispatch(MainActivityViewModel.Operation.Pure.Seek.FastForward) + }, + dispatchSeekToNearEnd = { + dispatch( + MainActivityViewModel.Operation.Pure.Seek.SeekToNearEnd, + ) + }, + dispatchSetRepeatOne = { + dispatch(Impure.SetRepeatOne(it)) + }, + dispatchSetOfflineMode = { + dispatch(Impure.SetOfflineMode(it)) + }, + dispatchSetAudioQualityOnWifi = { + dispatch(Impure.SetAudioQualityOnWifi(it)) + }, + dispatchSetAudioQualityOnCell = { + dispatch(Impure.SetAudioQualityOnCell(it)) + }, + dispatchSetLoudnessNormalizationMode = { + dispatch( + Impure.SetLoudnessNormalizationMode(it), + ) + }, + dispatchRelease = { dispatch(Impure.Release) }, + dispatchSetSnackbarMessage = { + dispatch(Impure.SetSnackbarMessage(it)) + }, + dispatchCreatePlayerWithExternalCache = { context, startOffline -> + dispatch( + Impure.CreatePlayer.WithExternalCache( + context, + this, + startOffline, + ), + ) + }, + dispatchCreatePlayerWithInternalCache = { context, startOffline -> + dispatch( + Impure.CreatePlayer.WithInternalCache( + context, + this, + startOffline, + ), + ) + }, + dispatchFinalizeWebLoginFlow = { context: Context, uri: Uri -> + dispatch(Impure.FinalizeLoginFlow.Web(context, this, uri)) + }, + dispatchFinalizeImplicitLoginFlow = { context: Context -> + dispatch(Impure.FinalizeLoginFlow.Implicit(context, this)) + }, + ) + } + } + } + } + } + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityScreen.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityScreen.kt new file mode 100644 index 00000000..909445ec --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityScreen.kt @@ -0,0 +1,105 @@ +package com.tidal.sdk.player.mainactivity + +import android.content.Context +import android.net.Uri +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.media3.common.util.UnstableApi +import com.tidal.sdk.player.common.model.AudioQuality +import com.tidal.sdk.player.common.model.LoudnessNormalizationMode +import com.tidal.sdk.player.common.model.MediaProduct +import com.tidal.sdk.player.playbackengine.view.AspectRatioAdjustingSurfaceView + +@Composable +@Suppress("LongParameterList") +@UnstableApi +internal fun MainActivityScreen( + state: MainActivityState, + snackbarHostState: SnackbarHostState, + paddingValues: PaddingValues = PaddingValues(), + dispatchLoad: (MediaProduct) -> Unit, + dispatchSetNext: (MediaProduct?) -> Unit, + dispatchPlay: () -> Unit, + dispatchSkip: () -> Unit, + dispatchSetVideoSurfaceView: (AspectRatioAdjustingSurfaceView?) -> Unit, + dispatchSetDraggedPosition: (Float?) -> Unit, + dispatchPause: () -> Unit, + dispatchReset: () -> Unit, + dispatchRewind: () -> Unit, + dispatchFastForward: () -> Unit, + dispatchSeekToNearEnd: () -> Unit, + dispatchSetRepeatOne: (Boolean) -> Unit, + dispatchSetOfflineMode: (Boolean) -> Unit, + dispatchSetAudioQualityOnWifi: (AudioQuality) -> Unit, + dispatchSetAudioQualityOnCell: (AudioQuality) -> Unit, + dispatchSetLoudnessNormalizationMode: (LoudnessNormalizationMode) -> Unit, + dispatchRelease: () -> Unit, + dispatchSetSnackbarMessage: (String?) -> Unit, + dispatchCreatePlayerWithExternalCache: (Context, Boolean) -> Unit, + dispatchCreatePlayerWithInternalCache: (Context, Boolean) -> Unit, + dispatchFinalizeWebLoginFlow: (Context, Uri) -> Unit, + dispatchFinalizeImplicitLoginFlow: (Context) -> Unit, +) { + when (state) { + is MainActivityState.AwaitingLoginFlowChoice -> + LoginScreen( + snackbarHostState, + state, + paddingValues, + dispatchSetSnackbarMessage, + dispatchFinalizeWebLoginFlow, + dispatchFinalizeImplicitLoginFlow, + ) + + is MainActivityState.PlayerInitialized -> + PlayerInitializedScreen( + state, + paddingValues, + dispatchLoad, + dispatchSetNext, + dispatchPlay, + dispatchSkip, + dispatchSetVideoSurfaceView, + dispatchSetDraggedPosition, + dispatchPause, + dispatchReset, + dispatchRewind, + dispatchFastForward, + dispatchSeekToNearEnd, + dispatchSetRepeatOne, + dispatchSetOfflineMode, + dispatchSetAudioQualityOnWifi, + dispatchSetAudioQualityOnCell, + dispatchSetLoudnessNormalizationMode, + dispatchRelease, + ) + + is MainActivityState.PlayerNotInitialized -> + PlayerNotInitializedScreen( + paddingValues, + dispatchCreatePlayerWithExternalCache, + dispatchCreatePlayerWithInternalCache, + ) + + is MainActivityState.Loading -> LoadingScreen() + } + val snackbarMessage = state.snackbarMessage + if (snackbarMessage == null) { + snackbarHostState.currentSnackbarData?.dismiss() + } else { + LaunchedEffect(snackbarHostState) { + check( + snackbarHostState.showSnackbar( + message = snackbarMessage, + withDismissAction = true, + duration = SnackbarDuration.Indefinite, + ) == SnackbarResult.Dismissed, + ) + dispatchSetSnackbarMessage(null) + } + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityState.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityState.kt new file mode 100644 index 00000000..61481611 --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityState.kt @@ -0,0 +1,38 @@ +package com.tidal.sdk.player.mainactivity + +import android.net.Uri +import com.tidal.sdk.player.common.model.AudioQuality +import com.tidal.sdk.player.common.model.LoudnessNormalizationMode +import com.tidal.sdk.player.common.model.MediaProduct +import com.tidal.sdk.player.playbackengine.model.PlaybackState +import com.tidal.sdk.player.playbackengine.outputdevice.OutputDevice + +internal sealed class MainActivityState private constructor() { + + abstract val snackbarMessage: String? + + data class AwaitingLoginFlowChoice( + override val snackbarMessage: String? = null, + val isUserLoggedIn: Boolean, + val webLoginUri: Uri, + ) : MainActivityState() + + data class Loading(override val snackbarMessage: String?) : MainActivityState() + + data class PlayerNotInitialized(override val snackbarMessage: String?) : MainActivityState() + + data class PlayerInitialized( + override val snackbarMessage: String?, + val streamingAudioQualityOnWifi: AudioQuality, + val streamingAudioQualityOnCell: AudioQuality, + val loudnessNormalizationMode: LoudnessNormalizationMode, + val currentMediaProduct: MediaProduct?, + val nextMediaProduct: MediaProduct?, + val playbackState: PlaybackState, + val outputDevice: OutputDevice, + val isRepeatOneEnabled: Boolean, + val isOfflineModeEnabled: Boolean, + val durationSeconds: Float?, + val positionSeconds: Float, + ) : MainActivityState() +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityViewModel.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityViewModel.kt new file mode 100644 index 00000000..427309f7 --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityViewModel.kt @@ -0,0 +1,554 @@ +package com.tidal.sdk.player.mainactivity + +import android.app.Application +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import com.tidal.sdk.auth.CredentialsProvider +import com.tidal.sdk.auth.TidalAuth +import com.tidal.sdk.auth.model.AuthConfig +import com.tidal.sdk.auth.model.CredentialsUpdatedMessage +import com.tidal.sdk.auth.model.LoginConfig +import com.tidal.sdk.auth.model.QueryParameter +import com.tidal.sdk.eventproducer.EventProducer +import com.tidal.sdk.eventproducer.model.EventsConfig +import com.tidal.sdk.player.BuildConfig +import com.tidal.sdk.player.Player +import com.tidal.sdk.player.common.model.AudioQuality +import com.tidal.sdk.player.common.model.LoudnessNormalizationMode +import com.tidal.sdk.player.common.model.MediaProduct +import com.tidal.sdk.player.mainactivity.MainActivityViewModelState.AwaitingLoginFlowChoice +import com.tidal.sdk.player.mainactivity.MainActivityViewModelState.LoggingIn +import com.tidal.sdk.player.mainactivity.MainActivityViewModelState.PlayerInitialized +import com.tidal.sdk.player.mainactivity.MainActivityViewModelState.PlayerInitializing +import com.tidal.sdk.player.mainactivity.MainActivityViewModelState.PlayerNotInitialized +import com.tidal.sdk.player.mainactivity.MainActivityViewModelState.PlayerReleasing +import com.tidal.sdk.player.playbackengine.PlaybackEngine +import com.tidal.sdk.player.playbackengine.model.ByteAmount.Companion.megabytes +import com.tidal.sdk.player.playbackengine.model.Event +import com.tidal.sdk.player.playbackengine.player.CacheProvider +import com.tidal.sdk.player.playbackengine.view.AspectRatioAdjustingSurfaceView +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@UnstableApi +@Suppress("MagicNumber") +internal class MainActivityViewModel(context: Context) : ViewModel() { + + private val tidalAuth = TidalAuth.getInstance( + AuthConfig( + clientId = BuildConfig.TIDAL_CLIENT_ID, + clientSecret = BuildConfig.TIDAL_CLIENT_SECRET, + credentialsKey = "com.tidal.sdk.player", + enableCertificatePinning = false, + ), + context.applicationContext, + ) + val credentialsProvider by tidalAuth::credentialsProvider + val uiState by lazy { _uiState.asStateFlow() } + private val deriveUiState = DeriveUiState() + private val _uiState by lazy { + MutableStateFlow(deriveUiState(state)) + } + private val authLoginUri: Uri + get() = tidalAuth.auth.initializeLogin( + LOGIN_URI, + LoginConfig( + customParams = setOf( + QueryParameter( + key = "appMode", + value = "android", + ), + ), + ), + ) + private var state: MainActivityViewModelState = AwaitingLoginFlowChoice( + null, + credentialsProvider.isUserLoggedIn(), + authLoginUri, + ) + set(value) { + synchronized(this@MainActivityViewModel) { + if (field === value) { + return + } + field = value + _uiState.update { deriveUiState(state) } + } + } + + init { + viewModelScope.launch { + credentialsProvider.bus.collect { + Log.d(MainActivityViewModel::class.java.name, it.toString()) + if (it !is CredentialsUpdatedMessage) return@collect + if (it.credentials == null) { + dispatch( + Operation.Impure.LogOut( + this@MainActivityViewModel, + credentialsProvider, + ), + ) + } else { + state = state.castAndCopy() + } + } + } + } + + fun dispatch( + operation: Operation, + ) { + viewModelScope.launch(Dispatchers.IO) { state = operation(state as T) } + } + + sealed class Operation< + T : MainActivityViewModelState, + out V : MainActivityViewModelState, + > private constructor() { + + abstract suspend operator fun invoke(state: T): V + + sealed class Pure : Operation() { + + override suspend operator fun invoke(state: T): T { + invokePure(state) + return state + } + + abstract suspend fun invokePure(state: T) + + sealed class Seek : Pure() { + + override suspend fun invokePure(state: PlayerInitialized) { + state.player.playbackEngine.apply { + seek(targetPositionMs(this)) + } + } + + abstract fun targetPositionMs(playbackEngine: PlaybackEngine): Float + + data object Rewind : Seek() { + + override fun targetPositionMs(playbackEngine: PlaybackEngine) = + (playbackEngine.assetPosition - 10) * 1_000 + } + + data object FastForward : Seek() { + + override fun targetPositionMs(playbackEngine: PlaybackEngine) = + (playbackEngine.assetPosition + 10) * 1_000 + } + + data object SeekToNearEnd : Seek() { + + override fun targetPositionMs(playbackEngine: PlaybackEngine) = + (playbackEngine.playbackContext!!.duration - 10) * 1_000 + } + } + + data object Play : Pure() { + + override suspend fun invokePure(state: PlayerInitialized) { + state.player.playbackEngine.play() + } + } + + data object Pause : Pure() { + + override suspend fun invokePure(state: PlayerInitialized) { + state.player.playbackEngine.pause() + } + } + + data object Skip : Pure() { + + override suspend fun invokePure(state: PlayerInitialized) { + state.player.playbackEngine.skipToNext() + } + } + } + + sealed class Impure : + Operation() { + + sealed class CreatePlayer( + private val context: Context, + private val mainActivityViewModel: MainActivityViewModel, + ) : Impure() { + + abstract val cacheProviderLazy: Lazy + abstract val isOfflineMode: Boolean + + @Suppress("LongMethod", "MaxLineLength") + override suspend operator fun invoke(state: MainActivityViewModelState): PlayerInitializing { // ktlint-disable max-line-length + mainActivityViewModel.viewModelScope + .launch(Dispatchers.IO) { + val player = Player( + context.applicationContext as Application, + mainActivityViewModel.credentialsProvider, + EventProducer.getInstance( + mainActivityViewModel.credentialsProvider, + EventsConfig( + Int.MAX_VALUE, + emptySet(), + "player-sample-${BuildConfig.VERSION_NAME}", + ), + context, + CoroutineScope(Dispatchers.IO) + ).eventSender, + userClientIdSupplier = { 1 }, + isOfflineMode = isOfflineMode, + isDebuggable = BuildConfig.DEBUG, + cacheProvider = cacheProviderLazy.value, + version = "player-sample-${BuildConfig.VERSION_NAME}", + ) + + mainActivityViewModel.state = PlayerInitialized( + player = player, + eventCollectionJob = mainActivityViewModel.viewModelScope.launch { + player.playbackEngine + .events + .collect( + PlaybackEngineEventCollector( + mainActivityViewModel, + ), + ) + }, + itemPositionPollingJob = mainActivityViewModel.viewModelScope + .launch { + while (true) { + mainActivityViewModel.state = + mainActivityViewModel.state.castAndCopy() + delay(200) + } + }, + cacheProvider = cacheProviderLazy.value, + current = player.playbackEngine.mediaProduct, + next = null, + isRepeatOneEnabled = false, + snackbarMessage = null, + draggedPositionSeconds = null, + streamingAudioQualityCellular = + player.playbackEngine.streamingCellularAudioQuality, + streamingAudioQualityWifi = + player.playbackEngine.streamingWifiAudioQuality, + loudnessNormalizationMode = player.playbackEngine + .loudnessNormalizationMode, + ) + } + + return PlayerInitializing(state.snackbarMessage) + } + + class WithInternalCache( + context: Context, + mainActivityViewModel: MainActivityViewModel, + override val isOfflineMode: Boolean, + ) : CreatePlayer(context, mainActivityViewModel) { + + override val cacheProviderLazy = lazyOf(CacheProvider.Internal()) + } + + class WithExternalCache( + context: Context, + mainActivityViewModel: MainActivityViewModel, + override val isOfflineMode: Boolean, + ) : CreatePlayer(context, mainActivityViewModel) { + + override val cacheProviderLazy = lazy { + val cacheDir = File(context.cacheDir, CACHE_DIR) + val cacheEvictor = LeastRecentlyUsedCacheEvictor(CACHE_MAX_SIZE_BYTES) + val simpleCache = + SimpleCache(cacheDir, cacheEvictor, StandaloneDatabaseProvider(context)) + CacheProvider.External(simpleCache) + } + } + + companion object { + private const val CACHE_DIR = "exoplayer-cache" + private val CACHE_MAX_SIZE_BYTES = 50.megabytes.value + } + } + + class Load(private val mediaProduct: MediaProduct) : + Impure() { + + override suspend operator fun invoke(state: PlayerInitialized): PlayerInitialized { + state.player.playbackEngine.load(mediaProduct) + return state.copy(current = mediaProduct) + } + } + + data object Reset : Impure() { + + override suspend operator fun invoke(state: PlayerInitialized): PlayerInitialized { + state.player.playbackEngine.reset() + return state.copy( + current = null, + next = null, + isRepeatOneEnabled = false, + ) + } + } + + class SetRepeatOne(private val repeatOne: Boolean) : + Impure() { + + override suspend operator fun invoke(state: PlayerInitialized): PlayerInitialized { + state.player.playbackEngine.setRepeatOne(repeatOne) + return state.copy(isRepeatOneEnabled = repeatOne) + } + } + + class SetOfflineMode(private val isOfflineMode: Boolean) : + Impure() { + + override suspend operator fun invoke(state: PlayerInitialized): PlayerInitialized { + state.player.configuration.isOfflineMode = isOfflineMode + return state.copy() + } + } + + class SetSnackbarMessage(private val snackbarMessage: String?) : + Impure() { + + override suspend operator fun invoke(state: MainActivityViewModelState) = + state.castAndCopy(snackbarMessage = snackbarMessage) + } + + class SetDraggedPosition(private val positionSeconds: Float?) : + Impure() { + + override suspend operator fun invoke(state: PlayerInitialized): PlayerInitialized { + if (state.draggedPositionSeconds != null && positionSeconds != null) { + state.player.playbackEngine.seek(positionSeconds * 1_000) + } + return state.copy(draggedPositionSeconds = positionSeconds) + } + } + + class SetAudioQualityOnWifi(private val audioQuality: AudioQuality) : + Impure() { + + override suspend operator fun invoke(state: PlayerInitialized): PlayerInitialized { + state.player.playbackEngine.streamingWifiAudioQuality = audioQuality + return state.copy(streamingAudioQualityWifi = audioQuality) + } + } + + class SetAudioQualityOnCell(private val audioQuality: AudioQuality) : + Impure() { + + override suspend operator fun invoke(state: PlayerInitialized): PlayerInitialized { + state.player.playbackEngine.streamingCellularAudioQuality = audioQuality + return state.copy(streamingAudioQualityCellular = audioQuality) + } + } + + class SetLoudnessNormalizationMode( + private val loudnessNormalizationMode: LoudnessNormalizationMode, + ) : Impure() { + + override suspend operator fun invoke(state: PlayerInitialized): PlayerInitialized { + state.player.playbackEngine.loudnessNormalizationMode = + loudnessNormalizationMode + return state.copy(loudnessNormalizationMode = loudnessNormalizationMode) + } + } + + class SetNext(private val mediaProduct: MediaProduct?) : + Impure() { + + override suspend operator fun invoke(state: PlayerInitialized): PlayerInitialized { + state.player.playbackEngine.setNext(mediaProduct) + return state.copy(next = mediaProduct) + } + } + + class SetVideoSurfaceView( + private val aspectRatioAdjustingSurfaceView: AspectRatioAdjustingSurfaceView?, + ) : Impure() { + + override suspend operator fun invoke(state: PlayerInitialized): PlayerInitialized { + state.player.playbackEngine.videoSurfaceView = aspectRatioAdjustingSurfaceView + return state.copy() + } + } + + data object Release : Impure() { + + override suspend operator fun invoke(state: PlayerInitialized) = state.run { + itemPositionPollingJob.cancel() + player.release() + PlayerReleasing.FromRequest(null, eventCollectionJob, cacheProvider) + } + } + + sealed class FinalizeLoginFlow( + private val context: Context, + private val mainActivityViewModel: MainActivityViewModel, + ) : Impure() { + + override suspend fun invoke(state: AwaitingLoginFlowChoice): LoggingIn { + mainActivityViewModel.viewModelScope.launch(Dispatchers.IO) { + finalize() + mainActivityViewModel.state = + CreatePlayer.WithInternalCache(context, mainActivityViewModel, false)( + mainActivityViewModel.state, + ) + } + return LoggingIn(state.snackbarMessage) + } + + abstract suspend fun finalize() + + class Web( + context: Context, + private val mainActivityViewModel: MainActivityViewModel, + private val loginResponseUri: Uri, + ) : FinalizeLoginFlow(context, mainActivityViewModel) { + override suspend fun finalize() { + mainActivityViewModel.tidalAuth.auth.finalizeLogin( + loginResponseUri.toString() + ) + } + } + + class Implicit( + context: Context, + private val mainActivityViewModel: MainActivityViewModel, + ) : FinalizeLoginFlow(context, mainActivityViewModel) { + override suspend fun finalize() { + mainActivityViewModel.credentialsProvider.getCredentials(null) + } + } + } + + class LogOut( + private val viewModel: MainActivityViewModel, + private val credentialsProvider: CredentialsProvider, + ) : Impure() { + + @Suppress("MaxLineLength") + override suspend fun invoke(state: MainActivityViewModelState): MainActivityViewModelState { // ktlint-disable max-line-length + return if (state is PlayerInitialized) { + Release(state).run { + PlayerReleasing.FromLogOut( + snackbarMessage, + eventCollectionJob, + cacheProvider, + ) + } + } else { + AwaitingLoginFlowChoice( + state.snackbarMessage, + credentialsProvider.isUserLoggedIn(), + viewModel.authLoginUri, + ) + } + } + } + } + } + + class Factory(private val context: Context) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + check(modelClass === MainActivityViewModel::class.java) + @Suppress("UNCHECKED_CAST") + return MainActivityViewModel(context) as T + } + } + + class PlaybackEngineEventCollector(private val mainActivityViewModel: MainActivityViewModel) : + FlowCollector { + + override suspend fun emit(value: Event) { + when (value) { + is Event.MediaProductTransition -> { + val state = mainActivityViewModel.state as PlayerInitialized + if (value.mediaProduct.referenceId != state.current?.referenceId) { + mainActivityViewModel.state = state.copy( + current = value.mediaProduct, + next = null, + ) + } + } + + is Event.MediaProductEnded -> { + val state = mainActivityViewModel.state as PlayerInitialized + mainActivityViewModel.state = state.copy(current = null) + } + + is Event.Release -> { + val state = mainActivityViewModel.state as PlayerReleasing + state.eventCollectionJob.cancel() + (state.cacheProvider as? CacheProvider.External)?.cache?.release() + mainActivityViewModel.state = when (state) { + is PlayerReleasing.FromRequest -> + PlayerNotInitialized(state.snackbarMessage) + + is PlayerReleasing.FromLogOut -> AwaitingLoginFlowChoice( + state.snackbarMessage, + mainActivityViewModel.credentialsProvider.isUserLoggedIn(), + mainActivityViewModel.authLoginUri, + ) + } + } + + is Event.Error -> { + val state = mainActivityViewModel.state as PlayerInitialized + mainActivityViewModel.state = state.copy( + snackbarMessage = + "${value.javaClass.simpleName}(${value::errorCode.name}=" + + "${value.errorCode})", + ) + } + + is Event.StreamingPrivilegesRevoked, + is Event.DjSessionUpdate, + -> { + val state = mainActivityViewModel.state as PlayerInitialized + mainActivityViewModel.state = state.copy(snackbarMessage = value.toString()) + } + + else -> + mainActivityViewModel.state = (mainActivityViewModel.state as PlayerInitialized) + .copy() + } + } + } + + companion object { + const val LOGIN_URI = "https://tidal.com/android/login/auth" + } +} + +private fun T.castAndCopy( + snackbarMessage: String? = this.snackbarMessage, +) = (this as MainActivityViewModelState).run { + when (this) { + is AwaitingLoginFlowChoice -> copy(snackbarMessage = snackbarMessage) + is LoggingIn -> copy(snackbarMessage = snackbarMessage) + is PlayerReleasing.FromRequest -> copy(snackbarMessage = snackbarMessage) + is PlayerReleasing.FromLogOut -> copy(snackbarMessage = snackbarMessage) + is PlayerNotInitialized -> copy(snackbarMessage = snackbarMessage) + is PlayerInitialized -> copy(snackbarMessage = snackbarMessage) + is PlayerInitializing -> copy(snackbarMessage = snackbarMessage) + } as T +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityViewModelState.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityViewModelState.kt new file mode 100644 index 00000000..11cb43f3 --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/MainActivityViewModelState.kt @@ -0,0 +1,65 @@ +package com.tidal.sdk.player.mainactivity + +import android.net.Uri +import com.tidal.sdk.player.Player +import com.tidal.sdk.player.common.model.AudioQuality +import com.tidal.sdk.player.common.model.LoudnessNormalizationMode +import com.tidal.sdk.player.common.model.MediaProduct +import com.tidal.sdk.player.playbackengine.player.CacheProvider +import kotlinx.coroutines.Job + +internal sealed class MainActivityViewModelState private constructor() { + + abstract val snackbarMessage: String? + + data class AwaitingLoginFlowChoice( + override val snackbarMessage: String?, + val isUserLoggedIn: Boolean, + val webLoginUri: Uri, + ) : MainActivityViewModelState() + + data class LoggingIn(override val snackbarMessage: String?) : MainActivityViewModelState() + + sealed class PlayerReleasing private constructor() : MainActivityViewModelState() { + + abstract val eventCollectionJob: Job + abstract val cacheProvider: CacheProvider + + data class FromRequest( + override val snackbarMessage: String?, + override val eventCollectionJob: Job, + override val cacheProvider: CacheProvider, + ) : PlayerReleasing() + + data class FromLogOut( + override val snackbarMessage: String?, + override val eventCollectionJob: Job, + override val cacheProvider: CacheProvider, + ) : PlayerReleasing() + } + + data class PlayerNotInitialized(override val snackbarMessage: String?) : + MainActivityViewModelState() + + data class PlayerInitializing(override val snackbarMessage: String?) : + MainActivityViewModelState() + + /** + * TODO The non-Player fields should instead be appropriately exposed from Player to enable a + * single source of truth approach. + */ + data class PlayerInitialized( + override val snackbarMessage: String?, + val player: Player, + val eventCollectionJob: Job, + val itemPositionPollingJob: Job, + val cacheProvider: CacheProvider, + val current: MediaProduct?, + val next: MediaProduct?, + val isRepeatOneEnabled: Boolean, + val draggedPositionSeconds: Float?, + val streamingAudioQualityWifi: AudioQuality, + val streamingAudioQualityCellular: AudioQuality, + val loudnessNormalizationMode: LoudnessNormalizationMode, + ) : MainActivityViewModelState() +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/PlayerInitializedScreen.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/PlayerInitializedScreen.kt new file mode 100644 index 00000000..b79540c8 --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/PlayerInitializedScreen.kt @@ -0,0 +1,343 @@ +package com.tidal.sdk.player.mainactivity + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.tidal.sdk.auth.model.Credentials +import com.tidal.sdk.player.BuildConfig +import com.tidal.sdk.player.common.model.AudioQuality +import com.tidal.sdk.player.common.model.LoudnessNormalizationMode +import com.tidal.sdk.player.common.model.MediaProduct +import com.tidal.sdk.player.common.model.ProductType +import com.tidal.sdk.player.playbackengine.model.PlaybackState +import com.tidal.sdk.player.playbackengine.view.AspectRatioAdjustingSurfaceView + +@Composable +@Suppress("LongMethod", "MagicNumber", "ComplexMethod", "LongParameterList") +internal fun PlayerInitializedScreen( + state: MainActivityState.PlayerInitialized, + paddingValues: PaddingValues = PaddingValues(), + dispatchLoad: (MediaProduct) -> Unit, + dispatchSetNext: (MediaProduct?) -> Unit, + dispatchPlay: () -> Unit, + dispatchSkip: () -> Unit, + dispatchSetVideoSurfaceView: (AspectRatioAdjustingSurfaceView?) -> Unit, + dispatchSetDraggedPosition: (Float?) -> Unit, + dispatchPause: () -> Unit, + dispatchReset: () -> Unit, + dispatchRewind: () -> Unit, + dispatchFastForward: () -> Unit, + dispatchSeekToNearEnd: () -> Unit, + dispatchSetRepeatOne: (Boolean) -> Unit, + dispatchSetOfflineMode: (Boolean) -> Unit, + dispatchSetAudioQualityOnWifi: (AudioQuality) -> Unit, + dispatchSetAudioQualityOnCell: (AudioQuality) -> Unit, + dispatchSetLoudnessNormalizationMode: (LoudnessNormalizationMode) -> Unit, + dispatchRelease: () -> Unit, +) { + Column(Modifier.padding(paddingValues)) { + var demoPlayableItemsExpanded by rememberSaveable { mutableStateOf(true) } + Button( + onClick = { demoPlayableItemsExpanded = !demoPlayableItemsExpanded }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "SHOW ${ + if (demoPlayableItemsExpanded) { + "PLAYBACK CONTROLS" + } else { + "DEMO PLAYABLE ITEMS" + } + }", + ) + } + + if (demoPlayableItemsExpanded) { + DemoPlayableItemsList( + state, + dispatchLoad, + dispatchSetNext, + dispatchPlay, + dispatchSkip, + ) + } else { + PlaybackControls( + state, + dispatchPlay, + dispatchSkip, + dispatchSetVideoSurfaceView, + dispatchSetDraggedPosition, + dispatchPause, + dispatchReset, + dispatchRewind, + dispatchFastForward, + dispatchSeekToNearEnd, + dispatchSetRepeatOne, + dispatchSetOfflineMode, + dispatchSetAudioQualityOnWifi, + dispatchSetAudioQualityOnCell, + dispatchSetLoudnessNormalizationMode, + dispatchRelease, + ) + } + } +} + +@Composable +private fun DemoPlayableItemsList( + state: MainActivityState.PlayerInitialized, + dispatchLoad: (MediaProduct) -> Unit, + dispatchSetNext: (MediaProduct?) -> Unit, + dispatchPlay: () -> Unit, + dispatchSkip: () -> Unit, +) { + val itemListState = rememberLazyListState() + val selectedDemoPlayableItems = DemoPlayableItem.HARDCODED.filter { + CREDENTIAL_LEVEL in it.allowedCredentialLevels + } + val selectedIndex = if (state.currentMediaProduct != null) { + selectedDemoPlayableItems.run { + indexOf( + single { + it.mediaProductId.contentEquals(state.currentMediaProduct.productId) + }, + ) + } + } else { + null + } + selectedIndex?.let { + LaunchedEffect("ScrollToCurrent") { + itemListState.scrollToItem(it) + } + } + LazyColumn(state = itemListState) { + itemsIndexed(selectedDemoPlayableItems) { i, item -> + DemoPlayableItemComposable( + item = item, + isCurrent = + item.mediaProductId.contentEquals(state.currentMediaProduct?.productId), + isNext = item.mediaProductId + .contentEquals(state.nextMediaProduct?.productId), + dispatchLoad, + { + if (i < selectedDemoPlayableItems.size - 1) { + selectedDemoPlayableItems[i + 1].createMediaProduct() + } else { + null + } + }, + dispatchSetNext, + dispatchPlay, + dispatchSkip, + ) + } + } +} + +@Suppress("LongMethod", "LongParameterList") +@Composable +private fun PlaybackControls( + state: MainActivityState.PlayerInitialized, + dispatchPlay: () -> Unit, + dispatchSkip: () -> Unit, + dispatchSetVideoSurfaceView: (AspectRatioAdjustingSurfaceView?) -> Unit, + dispatchSetDraggedPosition: (Float?) -> Unit, + dispatchPause: () -> Unit, + dispatchReset: () -> Unit, + dispatchRewind: () -> Unit, + dispatchFastForward: () -> Unit, + dispatchSeekToNearEnd: () -> Unit, + dispatchSetRepeatOne: (Boolean) -> Unit, + dispatchSetOfflineMode: (Boolean) -> Unit, + dispatchSetAudioQualityOnWifi: (AudioQuality) -> Unit, + dispatchSetAudioQualityOnCell: (AudioQuality) -> Unit, + dispatchSetLoudnessNormalizationMode: (LoudnessNormalizationMode) -> Unit, + dispatchRelease: () -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier.verticalScroll(scrollState), + ) { + val playbackState = state.playbackState + OutlinedTextField( + value = playbackState.name, + readOnly = true, + enabled = false, + label = { Text(text = "Playback state") }, + onValueChange = { throw UnsupportedOperationException() }, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + colors = TextFieldDefaults.outlinedTextFieldColors( + disabledLabelColor = LocalContentColor.current, + disabledBorderColor = LocalContentColor.current, + disabledTextColor = LocalContentColor.current, + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + val outputDevice = state.outputDevice + OutlinedTextField( + value = outputDevice.name, + readOnly = true, + enabled = false, + label = { Text(text = "Output Device") }, + onValueChange = { throw UnsupportedOperationException() }, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + colors = TextFieldDefaults.outlinedTextFieldColors( + disabledLabelColor = LocalContentColor.current, + disabledBorderColor = LocalContentColor.current, + disabledTextColor = LocalContentColor.current, + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + if (playbackState != PlaybackState.IDLE && + state.currentMediaProduct?.productType == ProductType.VIDEO + ) { + AndroidView( + factory = { AspectRatioAdjustingSurfaceView(it) }, + modifier = Modifier.fillMaxWidth(), + update = { dispatchSetVideoSurfaceView(it) }, + ) + } else { + dispatchSetVideoSurfaceView(null) + } + + Slider( + enabled = playbackState != PlaybackState.IDLE, + value = state.positionSeconds, + onValueChange = { dispatchSetDraggedPosition(it) }, + valueRange = 0f..(state.durationSeconds?.coerceAtLeast(0F) ?: 0f), + onValueChangeFinished = { dispatchSetDraggedPosition(null) }, + ) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(0.dp, 0.dp, 0.dp, 16.dp), + ) { + Text(text = DateUtils.formatElapsedTime(state.positionSeconds.toLong())) + Text(text = DateUtils.formatElapsedTime(state.durationSeconds?.toLong() ?: 0L)) + } + + FlowRow( + horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier.fillMaxWidth(), + ) { + Button( + enabled = playbackState == PlaybackState.NOT_PLAYING, + onClick = dispatchPlay, + ) { + Text(text = "PLAY") + } + Button( + enabled = playbackState == PlaybackState.PLAYING || + playbackState == PlaybackState.STALLED, + onClick = dispatchPause, + ) { + Text(text = "PAUSE") + } + Button( + enabled = playbackState != PlaybackState.IDLE && state.nextMediaProduct != null, + onClick = dispatchSkip, + ) { + Text(text = "SKIP") + } + Button(onClick = dispatchReset) { + Text(text = "RESET") + } + Button( + enabled = playbackState != PlaybackState.IDLE, + onClick = dispatchRewind, + ) { + Text(text = "RW 10s") + } + Button( + enabled = playbackState != PlaybackState.IDLE, + onClick = dispatchFastForward, + ) { + Text(text = "FF 10s") + } + Button( + enabled = playbackState != PlaybackState.IDLE, + onClick = dispatchSeekToNearEnd, + ) { + Text(text = "SEEK TO NEAR END") + } + Button(onClick = { dispatchSetRepeatOne(!state.isRepeatOneEnabled) }) { + Text( + text = + "SET REPEAT ONE (IS ${if (state.isRepeatOneEnabled) "ON" else "OFF"})", + ) + } + Button(onClick = { dispatchSetOfflineMode(!state.isOfflineModeEnabled) }) { + Text( + text = + "SET OFFLINE MODE (IS ${if (state.isOfflineModeEnabled) "ON" else "OFF"})", + ) + } + } + Selector( + title = "Audio quality wifi", + selectedValue = state.streamingAudioQualityOnWifi, + possibleValues = AudioQuality.values(), + ) { + dispatchSetAudioQualityOnWifi(it) + } + Selector( + title = "Audio quality cellular", + selectedValue = state.streamingAudioQualityOnCell, + possibleValues = AudioQuality.values(), + ) { + dispatchSetAudioQualityOnCell(it) + } + Selector( + title = "Loudness normalization mode", + selectedValue = state.loudnessNormalizationMode, + possibleValues = LoudnessNormalizationMode.values(), + ) { + dispatchSetLoudnessNormalizationMode(it) + } + Button( + onClick = dispatchRelease, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "RELEASE PLAYER") + } + } +} + +private val CREDENTIAL_LEVEL = when { + BuildConfig.TIDAL_CLIENT_SECRET.isNullOrBlank() -> Credentials.Level.USER + else -> Credentials.Level.CLIENT +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/PlayerNotInitializedScreen.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/PlayerNotInitializedScreen.kt new file mode 100644 index 00000000..0f006401 --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/PlayerNotInitializedScreen.kt @@ -0,0 +1,73 @@ +package com.tidal.sdk.player.mainactivity + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +@Composable +internal fun PlayerNotInitializedScreen( + paddingValues: PaddingValues = PaddingValues(), + dispatchCreatePlayerWithExternalCache: (Context, Boolean) -> Unit, + dispatchCreatePlayerWithInternalCache: (Context, Boolean) -> Unit, +) { + var provideExternalCache by rememberSaveable { mutableStateOf(false) } + var startInOfflineMode by rememberSaveable { mutableStateOf(false) } + + Column(Modifier.padding(paddingValues)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Provide external cache?", + modifier = Modifier + .padding(PaddingValues(end = 8F.dp)) + .weight(1F, fill = false), + ) + Switch(checked = provideExternalCache, onCheckedChange = { provideExternalCache = it }) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Start in offline mode?", + modifier = Modifier + .padding(PaddingValues(end = 8F.dp)) + .weight(1F, fill = false), + ) + Switch(checked = startInOfflineMode, onCheckedChange = { startInOfflineMode = it }) + } + val context = LocalContext.current.applicationContext + Button( + onClick = { + if (provideExternalCache) { + dispatchCreatePlayerWithExternalCache(context, startInOfflineMode) + } else { + dispatchCreatePlayerWithInternalCache(context, startInOfflineMode) + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "CREATE PLAYER") + } + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/Selector.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/Selector.kt new file mode 100644 index 00000000..763a796e --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/mainactivity/Selector.kt @@ -0,0 +1,106 @@ +package com.tidal.sdk.player.mainactivity + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Divider +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.tidal.sdk.player.common.model.AudioQuality + +@Composable +@Suppress("LongMethod") +internal fun Selector( + title: String, + selectedValue: T, + possibleValues: Array, + onSelectionUpdated: (T) -> Unit, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .clickable { expanded = true } + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = title, + modifier = Modifier + .padding(PaddingValues(end = 8F.dp)) + .weight(1F, fill = false), + ) + Text(text = selectedValue.toString()) + } + if (expanded) { + Dialog(onDismissRequest = { expanded = false }) { + Surface(shape = RoundedCornerShape(12F.dp)) { + Column { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(16F.dp), + ) + val selectedIndex = possibleValues.indexOf(selectedValue) + val listState = rememberLazyListState() + LaunchedEffect("ScrollToSelected") { + listState.scrollToItem(index = selectedIndex) + } + LazyColumn(state = listState) { + itemsIndexed(possibleValues) { index, item -> + val contentColor = when (index) { + selectedIndex -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + expanded = false + onSelectionUpdated(item) + } + .fillMaxWidth() + .padding(16.dp), + ) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + Text( + text = item.toString(), + style = MaterialTheme.typography.titleSmall, + ) + } + } + if (index < AudioQuality.values().lastIndex) { + Divider(modifier = Modifier.padding(horizontal = 16.dp)) + } + } + } + } + } + } + } +} diff --git a/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/ui/theme/Theme.kt b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/ui/theme/Theme.kt new file mode 100644 index 00000000..ffaa039b --- /dev/null +++ b/player/apps/demo/src/main/kotlin/com/tidal/sdk/player/ui/theme/Theme.kt @@ -0,0 +1,48 @@ +package com.tidal.sdk.player.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +@Composable +fun PlayerTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkColorScheme() + else -> lightColorScheme() + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat + .getInsetsController(window, view) + .isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) +} diff --git a/player/apps/demo/src/main/res/drawable/ic_launcher_foreground.xml b/player/apps/demo/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..8f17b03f --- /dev/null +++ b/player/apps/demo/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/player/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/player/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..ac94b34f --- /dev/null +++ b/player/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/player/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/player/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..ac94b34f --- /dev/null +++ b/player/apps/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/player/apps/demo/src/main/res/mipmap-hdpi/ic_launcher.png b/player/apps/demo/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..421923e339833edb1bece05c1420d4e1a2aad27b GIT binary patch literal 1405 zcmZvcc~BAv6vpv@6c0=-4P6&xRm!8tGsSgLQ884!))Fr?LbOJ+@*-IUOl!*U8pBky z(rn43RtNF~(>zP_-ZGEWa!V6jG$yl_-TrE3-^_b&=9~9s-pn_@cZuNdtp$dH0RVs& z&c`!I(S80+HISl)uKnQ+002F4o|usIsI>|rGV8dZsBDprvstu}mA%J*LM;3DF})sr zHY+n#$IYyfz z==gNrnYO-JxwEqsKKJ6FP8V|*!~`w-SyT1O3DBhMK+Ommb}RkN>38Zus&xJMz4GJL zxM30P39T2(-;1iU#u>{?MHD6WKJKN54w?;ScN>RsH}UdoL!$D!zINk=OkD@mm8AZ5 z-qLsrUN>O^q~C6FBYbL<`&)MbhD4GvSO8kPwsv+Dl{+Fi}m|WhujdZ;ZC<0 zJ(1#m@2`NA==EX`;}QSc{Y$K4!4?@g)!N@yTht6>$Os)3n|vjM?re=%5fC-cFigg1 z3Spq{<&jF5hK5FRN=gtZDGB1`<@NjDAP|VnW&=*|7==wBr*JZa%c@?cCj>7ZMSnDG&(QB_*B|-`H4F{XKic{r&1q zO-;#|n=t(vdRtSIerjs!O3|49((BjCQmK?(S_*{2;W1<~_FC(sM>zt4BT^q6YBKbG zaZ#n9pg^V&kEnt`?wOg+th_v?BHo1?UlX>rdYn9I-rdvVDx7nfjUt>nWmSn4i^aN_ z!oos+b2E@eqw)EC0EI$1ghu1Hf5qX{S3i6hjWT~D-Pm9}e(cfL-(NE&+uG{t=>b|< zTZ^SqROG0Vl9KGj#fzYo)m7HSM5uG!ojY&EJG0U)#=zhpiAFQJ*U(_b;5$x?kJm$w zJiEx_S@u1B3M3MV!nQV6eLXVNq+-9fw|5Mcs-Byh%kS)jU@#b2#nr1?csyP-GNQ9n zz_{Po_%-oO{z?=fJREwvu5Pq0GCDeddzk((Q^%%IF^aUbyfwGIyF!?2fw{T4#h*T%ySaJZ z$<6J~%*;%^Dw#rgJTL%WLv(a>ghZvJ9KO!sJfCs$AatlbbNcKVh^&9`K$->KkVvYr zq~dO}Syh+;ODi=agJW}PWhGuNSx{LEoF2krge4A+j#S8CUthMu7iSvY&u?LV9`NGj zOYzuPu<{oN@P#Rc9gUse0;_P9Hz7<-L*DM}>`)=PK)5_}WNK;`0)ZfehSm;@y?Ujk zF!S8W%Tuu)y;V$Czjfe(W>*=`N|)l}>kF>*6XM5#B(ZRiRNPyOFo9@k)wTu`iAL?9 z?b)TJpI!l_X|uJ}ZEfh520BisV{CkK(w$5;Yd>RUm}X*ZYj2NDYkdAZ5Q#(*u8Nmg zFXeK%aH&!agNlobJBPLj(|^6XiXW~yG2=Q{TK!ce!eP4NjQ2SDqwh*1?1H_BW9#B# znQ@d(mW2_pSYSw47?a0)&pg|h{{=j{`+JK14=%|8hI}sJ0$i>c%W|4G_y%UOE}A%! z=2?#SR{$SS(hSwJ?)pVU5Mg7gW-Lndj*S&%y97>NwolyS)aL~&K z#}hK{=5exlBJW&xDO2(%LLv}FD>E}ev$nIe;YjNr6<_A;217)pz~`;z5t3$+W2W|G zcC(ZEu_B69L$9)tp+EL>0I=XcHGZOm3ymjgOXkJQyr}NZzTilKU=vcNc&h*$*5C8K Hdko_r%m0t{ literal 0 HcmV?d00001 diff --git a/player/apps/demo/src/main/res/mipmap-hdpi/ic_launcher_round.png b/player/apps/demo/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..2c9bfce6a7033a6bb27f1dc2502b3a87e15feaf4 GIT binary patch literal 2871 zcmV-73&`||P)w<2yfwn|o zL(D~#edk6h93iJ8HW}`S2p2YK_Ib29l6(>q(Imq$BtzkE-aYc@5pI%G>LJ;-R65THi_7_)`>`iZ4?fW&(M$7aecwIp@fW$kUAqt8p>5D zC!bM~$S;usY!RZ$6}})NQ71_f-#9X13N0KNkpui2sI&52iKt>`)l7u93j&!SdB1a%L!-u|Pq}JU{<`AvPmyqL;9D&$= zOQ9_psntA5D$7tF%LzGDh!2Ozw-@zUBH{OIaw<*)h%dh*BVl(nS?r}Nyd|S7mgYIN zh4=#BzC%XBTHs3Ion#axQuGMKr`_b+=?qCE+`dOfL0zt?>Kfu3Ccco6x|$@df0C&j z&!bjN5Z{hbhzVQ5Y!LaBG=vBO&|@Igb~OpH&15Pia`XtqN9=n;f_5_rua0Ck%uy+k zMy_BGAD=07BO?L3nlSejIV&~?8Jw4w=Rlh_Z8#|@$y)};MMXutWXTe4+_rGKf6faQEa24CR8CG#=2fd!@v&paxLLDi zemY_Z;`1qm#$>&EBgv?#!ZlB!kP zw{M>&3dCK!c#(JR-07!x*|KGP_wHRydef#&dEvr^{OHjmeeU7IhkWPG9iBOJrnmgk zrAv9&u3fx(^=fS=Fmd8UKfNA^&y@;2$%uoXZ1Qc6g*40uBM8Rt*s+64OG~xWHbJ=4 zAYj9W4Lp4Ka2`B(Fi)O5nP0wq$;+27*Y#JdSiw)9K6Q+II6iac3~$@EO_NwySg7kP zUc6XGA#`oovV|)uDtN$v0lLo0l`HwllP7%W&>{E{rmSijDZ6O z4)CQ*m$Y*iE?nR)UAp)YgnBSOGc%J%j2OXV$ByNKf&xB$`ZQm^eqCS3RN=^xBfM$T zCLJv}H)qZqzI^$zzBX*wFx{oLZ{M!#+Ca3RE^-YB+OubmS4RyHpI<3_myBp`WE8^` zJM1KlVI6l~aQygjo-tzvr=_LoGH!x!$JeY`!{^SO(>Vwc2tqOH2|)k|DJv_}dXSWF z-n_}{)~(am#*ZJ*uV26Bw{PEibBhfO5U2-&cJJP;Z;0FW0I-vEIvH^c^BCH~9_9me zF&j2P$Rl&-&eep>nl(#z6`52XJ$lqZ5V{};7&&qzXJ=>gt5>hQxdrE-W8S=Ze(l-# zOoGbDtVtnWf`AqZCp?8ho+ySxQ0vyMb>2X-btmBR@^S}37_#A9NlA&WFWm;$kZYi0 z^XAQtp&f1Y>eWjV3gW{gNPPYenFpobi6R8E=w=Fkbrr4_;SvNQ=Fgwcj~_qgNs}h& z>m56G)Q%uRY=VI4)2Hj9`|8!JylBxP$2CMCA`DT4xekcJ#1urM3!~l7Bxn~IE*x7P zSeRjY{YOuss1Sr4)S*KME-o(C$^Fx(PkLBhyLK%fK781ZAVkcSD_8i#hYxM0Db!OaS(|_-Lq#;$ILqf{FDqA z4ua6vFr=j^+$U2oq(mJpz$tXY`}gnbiKP2_gdx5@Br{42)_M~yLIr&~zsJ940p2DPW^ytxgsxp zkl`TU%T)*|Tzmsl72zH zr?9b1lGs-1i5v_#SZ~Rru80d1<3Ar{D3X3mCfUINL{u!zlWcp+l&urVd`%l2j2_YKdeH42O-qz5_<$FC%dibL z);T%0U^0om{cVLD)!(P9-%@fCBaYy=Hj#z8wk}(LscO4O{pOId1vZ7fzKWJeG-2|E zks?nS`*ZbMOy)^}+fg_ukc1ixc3HO`=q^@m6_R;n1#Qa&uwJ7km|zzG6EK7}jLlKu zp0D!3JTmLKyX5a=xPXp-KqqvQi5RqnHqo}@iD;~fY?t6}b|>@h(HF|0;g~}y=T@k{ zzb5m-DBzS2Q zdok!5a2nB&<^hgz4%biyWl;xpp(9~PsZNkY;V4ELY&jqrut^8t80T;eW$G>A{vR;y VqX_86|5E?}002ovPDHLkV1gS5VVnQ} literal 0 HcmV?d00001 diff --git a/player/apps/demo/src/main/res/mipmap-mdpi/ic_launcher.png b/player/apps/demo/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4ee0bb490536e205c833765769fe915bed713800 GIT binary patch literal 951 zcmV;o14#UdP)5Tj`%)r6WhxHu>(2+1Y|QN%%9939-mP0&Fn*HDauqJl(?q=WbX zMM2cbNl+)p(s#k7L-~6C=k{K%w>CbS-kZYx;D_Wq|Nrxy?+jkX%qZ=!aqI>I4UZ1% zFG?jIrAIx`&k1v$83gu*2089EArJ6y(wujkPX+@G&;m_}f+pn7HykQ4G8_vlU_dUG z0OviECVl7pFc@fnmJggDlj6?8M#UqdoG(n8gax!Gp*OOIubeoOLe_dK)zw8dn~hdhRwx(@YBw(i#WeuogM)+Q_xq`> zt&J`&E~uuaMlk?0fj~e|R#qmm;`Mr!`DjD-tX3;kR8)v@E|*L6`FuX|cs!y_mEGbR z$Ye6q-QBJH&StaJ(9j?Z)YjID3>_RC(Ek2DEiElkC={YxE~ki`n3$m6-d^hK>!YKi zBii2HruFr8VQ6h_jaFAzX>V_jVzJo2H_+77M370Ox4xZ;_B?|?1;Yd@^V^ST%?(q8ES8Dr$iz_CnqOj9PT@^ zQ&m-^j{$8B$iM)yfqRWC;Ka|*&y{|hJZht|vQiZ2^z<}MO-+fzz`bv2Y0<|(I2;zQ zhqfIXd6I$u`k%WmSm|aU!KAp;fc()Q z#QDgiNu~6G1D~6oGb!#gfWYS)7bnYpKMAABaonuHCnf|skqO+jJdK+A*0s{_U2Fql6UtQn;c ZKLN{(WBK3|5|#h}002ovPDHLkV1m&J#s2^R literal 0 HcmV?d00001 diff --git a/player/apps/demo/src/main/res/mipmap-mdpi/ic_launcher_round.png b/player/apps/demo/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..eb0d0b62af7452128a2973d146098ab3e55d210e GIT binary patch literal 1726 zcmV;v20{6WP)6(*Yt+0Y>rrYSs-Yys~m z{6m3qd5#AlOB}&7bO}AQ6ZXU|3Y1~h+!TlhAQQ6XcNDwOw4JcKK@S;$lE1Fvc*EAy zOF=D!cET>6k0@GbB4F$Lm;#$Z6}I&F7K#>@NZ5McB}4B16GhU4D=D&IUEzs>UD&UZ zp_u!Go%hlz?V_41hz;LRVB@4awuGmd%mjEp8{KBQkPQ;gZ;_#>R1%M}DA-}++2>~p zvE`OR4jGDwf_T(OhH)M3TJ0$`^d=1@ZaU<=W|6xg{x?` zAhvx*kw<*}%ZS6|BP%P5FI~FC=g*(#bLY-+Utb?LH8pX1dOB~}vW1TxJ?bi&Er@Mj zlhaCZyTX;vX7Z7flf!rK-sS4*YA!4++7`xNHaKd@!~}tt*)*voeZ|Lc`H_|;KPRx z^SpWUc=ztzJTNez=j__GOTR~CTU%SXzrUZmySrmKfID^xSyZO*tM@XL1ILda=Y9M3 z`3@8p7i&Y-tXacVRaJcX@@3w!V+U{FzFiwB^G=^W&3pIm<=Wa>9vT|rRjXEM2L}fS zdF9HLeDL5w9v>g)ZQHi-(xppdIe-rZFOo%Qp$~N6-o1O;;GH{n@`)2C`1Suz)XIxWHv)WqJYN7#hLNn>V?$vr{_)z%fJ(4z#zo zb82d;YrN}#v{FsQWWo~Dfr*I;Z8*ZeVZ#Q$^N0fl1qHgu5qd;oWMo7;0G?B)PH}T{ zGuPGCac^(09=m<}Heb7Tjjvp}qH*==RX%XwfOg=}p+h<)T{|q>ekPMOKA|8USj27$ z3B&>9I#xO@ElrOfIda5zVDaL`+|$#;XU?4AwQJYvF*w=S*yzhaO`v8%I)Ea6nk;%$ z;VXy9M;He-Z{EzKqoX?STUuIpczD=%0EVHkk@IM;30zeipp$K7{DMMXOAvGQ15bdXJ(HgS1*xqb&88!xe4$L%Hi?J>nQjp6_|eZ*d$GR(;m1>25xGB!qC z>OtZW{+cpIW0WMxlAR|4O{BT*cS7{mJpnHO5uGn zlM%kSwbf|=Iwf^JAY;>{spFDd)e0!_U#11NzO!U(flZ-4?t)Mc2vw;y9kioKt>mIPCIR45sC)_aq(HGR;Yu59ovQ! z-&Ant-BL0;)z6p6=zy-!1v?!=F(It^3QLqy?Akb7O;EH^Qvq{^JsCI&LuRNxKU0!( zc&0+0LWROQWk?&D!ozI|XOLahfpV+`-W176ILTUA00cL@=J5y)FWqJ#f`$o7C|S!LnDoEhi&{}jLT UMEqpAh5!Hn07*qoM6N<$f<`n%B>(^b literal 0 HcmV?d00001 diff --git a/player/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher.png b/player/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..b730fc313bb25b82c4e41894fa5f5102a312ba74 GIT binary patch literal 2032 zcmbVNdpOgL7vHkJvNojLHjTGj*6TWnGO{$+Tykec%C%ob#MNKIb`~lX}$21`b6)K_C#^&KB<~ z_Wr+g3m~@RO!9sZNV3)rf5`o!-(3DNTMr+lb_V&#^M88tT=wi>3sv&JWamgM`@6b0 zEBL~$UfnfQ(mwAfw?o0|DgKbbh;<_LYB;`@p(pE$R&`GoT<-{WaMOj;`ogr(};ak@;m zb5%c`-4EKlA`LI1NrzmEM+}Zcs5jfA>~E>x&%V*rgR>mnYeZ2HLI&01-)^reyP17P z3WK5=`v{msQ$pFWY6SGToxQnItsdsdBu~N5dxe^EnD?EOGMkdPq zmaChaZ^C&C#rB2TpXQpHDVh$pI>Dw9og@kf2q2N_-kkt+ zytz1KF7cIKCV;oH>g~?Qa%yTkOLY2|=SQ%nXU8ELnws|v%RKM9PU)Oz8uqk+#C>1I zj5NkE1Oh>SL|>_6G4WM}i;WHao=l@`fJ~Qg>Ulnv@wVwwS;e<8e$vvrR>_&!S=Pgc zhR2ZMs|@f~MLl-_7w8Pkjy4zac)aQHr)gEe&k=;i*nK+>oWrM5AELLbsygfH>e}O1 z{IVDf_TXq`0DT6`4C1%6wOQ}mhPxy$FW-@zk^;~UR0W2!^J~lOg)409TyWX zD@RTX|A>ljOIJq0U)PiMNyBw6%a9+)f{8~VJ+4Gz*=~(IEZOO1Rzbl$T9Pk@&c4v% za`g9vZz9q@Fw-pNVP}-u=5f_dLLuqMB#fj#5c_s&r*3+x`J#^SDOKTYZtfz;ascP}!8q%cyD#CL7;}cw( zPgXtHTX<3hhp_Z9>s8WTT8R1jfn!AD=)Xue@kb%mnj=Pd9{Fir3f+@SYO~>>bpGA> z#mUYN7}JYDnAwj~RAk3ON{hOaOj}xR=j0rw(P%s5W zIcowcKUVAHi1};Lz^P6@FJmXRG!}gBAdLD&iE=jIK=K2PPJaYmMIjLOGAOg7F~~&m z1__g)UAN{(n?Ltb>~b_YUwc2ykJOV$=}40dalr0=Iy)`CwF}EL9@Xf$wMC7fpdik< z4WtAV3hi(4ad!{IevmF`m>a6?A;Uj0R*X$|uH z@EwsT`oUgiax!FJu_aG%#IpRMN!aVL#bAZ0aUMqcap9_I%F6nwGzS}x{EJM@@-8%&l7_?PoL4JX1@6we)Y@JAS=`~MeK z6Z4=oQR065nHJ|?{Ml_j7V_?}1XmestPvTWTv=Rw`o;uQRjFuW!`!%pBj-8UKuaZ8 z8NZ%I`>eKrAjW&ra=&Yqcf@R0?b#nO{i0BX%Jnj0GHbANWmTpzda_}bbL7`JQEftXq*-Jv9@YLEGo#o^` zv=+66*xw$acIUJ5kg{c(J;yQiyMy9O=Eq0*OR|x2aj6;o4TUIYWv2ZcJ(i6Qit62QW OL3UP7_zFw^#Qy=P%&{{7 literal 0 HcmV?d00001 diff --git a/player/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/player/apps/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..321ef954b23446a9fb25eee5868bf05f637a1f33 GIT binary patch literal 4092 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 literal 0 HcmV?d00001 diff --git a/player/apps/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/player/apps/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..8a28555616449d19a4602659a281ace2acf8fb54 GIT binary patch literal 9195 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 + + #000000 + \ No newline at end of file diff --git a/player/apps/demo/src/main/res/values/themes.xml b/player/apps/demo/src/main/res/values/themes.xml new file mode 100644 index 00000000..54da8525 --- /dev/null +++ b/player/apps/demo/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +