From 21a768edd5829af527f9a78e030ccf6366428f60 Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 17 Jun 2026 18:58:41 +0300 Subject: [PATCH] init projects --- .gitignore | 23 + README.md | 93 ---- apps/call-blocker/.gitignore | 2 + apps/call-blocker/build.gradle.kts | 46 ++ .../call-blocker/src/main/AndroidManifest.xml | 50 ++ .../src/main/ic_launcher-playstore.png | Bin 0 -> 11545 bytes .../ru/shadowsparky/callblocker/AppModule.kt | 24 + .../data/ContentProviderContactReader.kt | 51 ++ .../callblocker/data/LRejectCallDelegate.kt | 28 ++ .../data/NonContactsRejectCallDelegate.kt | 39 ++ .../callblocker/data/PRejectCallDelegate.kt | 20 + .../data/PolicyBasedRejectDelegate.kt | 44 ++ .../data/PreferenceRejectPolicyManager.kt | 27 ++ .../callblocker/domain/ContactReader.kt | 11 + .../callblocker/domain/RejectCallDelegate.kt | 5 + .../callblocker/domain/RejectPolicyManager.kt | 24 + .../callblocker/presentation/App.kt | 19 + .../presentation/RejectCallReceiver.kt | 32 ++ .../presentation/RejectCallWorker.kt | 36 ++ .../presentation/ui/MainActivity.kt | 42 ++ .../presentation/ui/PermissionRequest.kt | 147 ++++++ .../presentation/ui/PolicyScreen.kt | 159 +++++++ .../res/drawable/ic_launcher_background.xml | 170 +++++++ .../res/drawable/ic_launcher_foreground.xml | 16 + .../res/drawable/ic_launcher_monochrome.xml | 17 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1557 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3323 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1088 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2148 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 2006 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4842 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 3109 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 7680 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 4452 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 11293 bytes .../src/main/res/values/colors.xml | 10 + .../res/values/ic_launcher_background.xml | 4 + .../src/main/res/values/strings.xml | 22 + .../src/main/res/values/themes.xml | 7 + .../src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + apps/experimentarium/androidApp/.gitignore | 4 + .../androidApp/build.gradle.kts | 41 ++ .../androidApp/src/main/AndroidManifest.xml | 28 ++ .../src/main/ic_launcher-playstore.png | Bin 0 -> 12291 bytes .../ru/shadowsparky/experimentarium/App.kt | 18 + .../experimentarium/MainActivity.kt | 15 + .../res/drawable/ic_launcher_foreground.xml | 16 + .../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 -> 764 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2002 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 554 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1240 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1012 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 2888 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 1522 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 4518 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 2066 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 6480 bytes .../res/values/ic_launcher_background.xml | 4 + .../src/main/res/values/strings.xml | 4 + apps/experimentarium/shared/.gitignore | 1 + apps/experimentarium/shared/build.gradle.kts | 20 + .../experimentarium/shared/Test.kt | 20 + apps/experimentarium/web/.gitignore | 4 + apps/experimentarium/web/build.gradle.kts | 32 ++ .../ru/shadowsparky/experimentarium/Main.kt | 26 + .../web/src/wasmJsMain/resources/index.html | 51 ++ apps/vbox/backend/.gitignore | 4 + apps/vbox/backend/Dockerfile | 6 + apps/vbox/backend/build.gradle.kts | 111 +++++ apps/vbox/backend/docker-compose.yml | 39 ++ apps/vbox/backend/postgres.env | 8 + apps/vbox/backend/readme.md | 9 + .../vbox/backend/BackendModule.kt | 35 ++ .../ru/shadowsparky/vbox/backend/Main.kt | 27 ++ .../data/BackendRecentlyWatchedRepository.kt | 103 ++++ .../backend/data/BackendRemoteEventHandler.kt | 43 ++ .../data/BackendSavedMovieRepository.kt | 79 ++++ .../backend/data/BackendSearchRepository.kt | 64 +++ .../vbox/backend/data/SessionCache.kt | 42 ++ .../data/auth/BackendAuthTokenRepository.kt | 169 +++++++ .../backend/data/auth/BackendLoginVerifier.kt | 26 + .../backend/data/http/ExternalBackendApi.kt | 137 ++++++ .../data/http/ExternalBackendMapper.kt | 199 ++++++++ .../vbox/backend/data/http/ExternalCache.kt | 66 +++ .../vbox/backend/data/http/ExternalConst.kt | 10 + .../vbox/backend/data/http/XmlReader.kt | 61 +++ .../data/tags/BackendMovieTagRepository.kt | 64 +++ .../data/tags/BackendUserTagRepository.kt | 72 +++ .../shadowsparky/vbox/backend/di/DbModule.kt | 33 ++ .../vbox/backend/di/EntryPoint.kt | 46 ++ .../vbox/backend/di/EventModule.kt | 32 ++ .../vbox/backend/di/HttpModule.kt | 13 + .../RecentlyWatchedRepositoryFactory.kt | 20 + .../di/factory/SavedMovieRepositoryFactory.kt | 23 + .../di/factory/SearchRepositoryFactory.kt | 21 + .../vbox/backend/presentation/Jwt.kt | 70 +++ .../vbox/backend/presentation/Routing.kt | 44 ++ .../backend/presentation/Serialization.kt | 12 + .../vbox/backend/presentation/WebSocket.kt | 67 +++ .../presentation/routing/AuthRouting.kt | 50 ++ .../presentation/routing/BaseRouting.kt | 32 ++ .../routing/RecentlyWatchedRouting.kt | 48 ++ .../presentation/routing/SavedMovieRouting.kt | 43 ++ .../presentation/routing/SearchRouting.kt | 42 ++ .../presentation/routing/TagsRouting.kt | 79 ++++ .../src/main/resources/application.conf | 8 + .../backend/src/main/resources/logback.xml | 12 + .../ru/shadowsparky/vbox/server/init.sq | 80 ++++ .../ru/shadowsparky/vbox/server/movie.sq | 10 + .../ru/shadowsparky/vbox/server/recent.sq | 23 + .../vbox/server/refresh_tokens.sq | 19 + .../shadowsparky/vbox/server/saved_movie.sq | 13 + .../ru/shadowsparky/vbox/server/search.sq | 14 + .../ru/shadowsparky/vbox/server/tags.sq | 47 ++ .../ru/shadowsparky/vbox/server/users.sq | 16 + apps/vbox/client/androidApp/.gitignore | 4 + apps/vbox/client/androidApp/build.gradle.kts | 37 ++ .../vbox/client/androidApp/proguard-rules.pro | 41 ++ .../androidApp/src/main/AndroidManifest.xml | 64 +++ .../src/main/ic_launcher-playstore.png | Bin 0 -> 7857 bytes .../videobox/multiplatform/App.kt | 34 ++ .../videobox/multiplatform/MainActivity.kt | 101 ++++ .../preview/PreferencePreview.kt | 12 + .../preview/SearchScreenPreview.kt | 64 +++ .../preview/SeasonOverviewScreenPreview.kt | 19 + .../preview/VideoScreenPreview.kt | 20 + .../res/drawable/baseline_saved_search_24.xml | 12 + .../drawable/baseline_video_library_24.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 16 + .../res/drawable/ic_launcher_monochrome.xml | 16 + .../drawable/ic_outline_remove_red_eye_24.xml | 10 + .../src/main/res/layout/activity_main.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1169 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3094 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 867 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 1929 bytes .../src/main/res/mipmap-xhdpi/ic_banner.png | Bin 0 -> 6393 bytes .../res/mipmap-xhdpi/ic_banner_foreground.png | Bin 0 -> 6915 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 1629 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4339 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 2649 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 7154 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 3703 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 10405 bytes .../main/res/values/ic_banner_background.xml | 4 + .../res/values/ic_launcher_background.xml | 4 + .../src/main/res/values/strings.xml | 5 + .../androidApp/src/main/res/values/themes.xml | 6 + .../src/main/res/xml-v25/shortcuts.xml | 14 + .../androidApp/src/main/res/xml/shortcuts.xml | 2 + apps/vbox/client/shared/.gitignore | 1 + apps/vbox/client/shared/build.gradle.kts | 51 ++ .../src/androidMain/AndroidManifest.xml | 5 + .../driver/android/AndroidSqliteDriver.kt | 388 +++++++++++++++ .../vbox/shared/data/AndroidOfflineStorage.kt | 134 ++++++ .../vbox/shared/data/OfflineDownloader.kt | 127 +++++ .../di/factory/DatabaseDriverFactory.kt | 15 + .../shared/di/factory/ImageLoaderProvider.kt | 49 ++ .../vbox/shared/domain/ActivityInfo.kt | 7 + .../vbox/shared/domain/OsType.android.kt | 5 + .../presentation/ShareManager.android.kt | 22 + .../vbox/shared/presentation/VideoLauncher.kt | 44 ++ .../search/SearchScreen.android.kt | 20 + .../composeResources/drawable/clear.xml | 10 + .../composeResources/drawable/clear_text.xml | 10 + .../composeResources/drawable/deselect.xml | 10 + .../drawable/download_done.xml | 10 + .../composeResources/drawable/eye.xml | 10 + .../composeResources/drawable/filter_list.xml | 10 + .../drawable/folder_check.xml | 10 + .../composeResources/drawable/home.xml | 12 + .../drawable/keyboard_arrow_down.xml | 10 + .../drawable/keyboard_arrow_up.xml | 10 + .../composeResources/drawable/more.xml | 10 + .../composeResources/drawable/pending.xml | 10 + .../composeResources/drawable/save_as.xml | 10 + .../composeResources/drawable/search.xml | 12 + .../composeResources/drawable/select_all.xml | 10 + .../composeResources/drawable/settings.xml | 12 + .../composeResources/drawable/share.xml | 10 + .../drawable/star-unchecked.xml | 10 + .../composeResources/drawable/star.xml | 10 + .../composeResources/values/strings.xml | 70 +++ .../vbox/shared/VBoxSharedModule.kt | 15 + .../vbox/shared/data/AppDatabaseProvider.kt | 32 ++ .../shared/data/AuthTokenRepositoryImpl.kt | 87 ++++ .../DefaultServerConfigurationRepository.kt | 30 ++ .../vbox/shared/data/HeathCheckImpl.kt | 22 + .../vbox/shared/data/HmacSha512Calc.kt | 20 + .../vbox/shared/data/KtorExtensions.kt | 37 ++ .../shared/data/PlayerSettingsStorgeImpl.kt | 31 ++ .../shared/data/RemoteEventListenerImpl.kt | 107 +++++ .../shared/data/RemoteSourceSynchronizer.kt | 52 ++ .../shared/data/SqlSavedTimeRepository.kt | 38 ++ .../vbox/shared/data/api/FallbackVideoApi.kt | 71 +++ .../data/api/PosterWithoutSuffixVideoApi.kt | 25 + .../vbox/shared/data/api/VideoApiImpl.kt | 44 ++ .../recent/RemoteRecentlyWatchedRepository.kt | 95 ++++ .../recent/SqlRecentlyWatchedRepository.kt | 78 +++ ...ncClientServerRecentlyWatchedRepository.kt | 125 +++++ ...PosterWithoutSuffixSavedMovieRepository.kt | 19 + .../data/saved/RemoteSavedMovieRepository.kt | 77 +++ .../data/saved/SqlSavedMovieRepository.kt | 74 +++ .../saved/SyncClientSavedMovieRepository.kt | 143 ++++++ .../data/search/RemoteSearchRepository.kt | 76 +++ .../shared/data/search/SqlSearchRepository.kt | 69 +++ .../SyncClientServerSearchRepository.kt | 119 +++++ .../data/sync/PendingOperationSynchronizer.kt | 30 ++ .../sync/SqlPendingOperationRepository.kt | 55 +++ .../shared/data/sync/SyncRepositoryImpl.kt | 39 ++ .../sync/strategy/MovieTagSyncStrategy.kt | 33 ++ .../strategy/RecentlyWatchedSyncStrategy.kt | 42 ++ .../sync/strategy/SavedMovieSyncStrategy.kt | 49 ++ .../data/sync/strategy/SearchSyncStrategy.kt | 37 ++ .../data/sync/strategy/UserTagSyncStrategy.kt | 44 ++ .../data/tag/RemoteMovieTagRepository.kt | 85 ++++ .../data/tag/RemoteUserTagRepository.kt | 75 +++ .../shared/data/tag/SqlMovieTagRepository.kt | 71 +++ .../shared/data/tag/SqlUserTagRepository.kt | 63 +++ .../shared/data/tag/SyncMovieTagRepository.kt | 137 ++++++ .../shared/data/tag/SyncUserTagRepository.kt | 128 +++++ .../data/token/RefreshTokenActionImpl.kt | 28 ++ .../shared/data/token/TokenStorageImpl.kt | 39 ++ .../shadowsparky/vbox/shared/di/Database.kt | 6 + .../vbox/shared/di/EventsModule.kt | 53 +++ .../shadowsparky/vbox/shared/di/HttpModule.kt | 31 ++ .../vbox/shared/di/SavedModule.kt | 40 ++ .../di/factory/DatabaseDriverFactory.kt | 7 + .../shared/di/factory/ImageLoaderProvider.kt | 7 + .../shared/domain/AddOfflineVideoUseCase.kt | 15 + .../shared/domain/AggregateSyncStrategy.kt | 9 + .../vbox/shared/domain/AuthTokenCacheExt.kt | 45 ++ .../vbox/shared/domain/LogoutUseCase.kt | 41 ++ .../vbox/shared/domain/OfflineStorage.kt | 40 ++ .../shared/domain/OfflineVideoInteractor.kt | 96 ++++ .../shadowsparky/vbox/shared/domain/OsType.kt | 12 + .../domain/PendingOperationRepository.kt | 25 + .../vbox/shared/domain/PlayerInteractor.kt | 191 ++++++++ .../shared/domain/PlayerSettingsStorge.kt | 9 + .../vbox/shared/domain/SavedTimeRepository.kt | 13 + .../domain/ServerConfigurationRepository.kt | 14 + .../vbox/shared/domain/SyncRepository.kt | 6 + .../vbox/shared/presentation/BaseScreen.kt | 118 +++++ .../vbox/shared/presentation/BottomBar.kt | 41 ++ .../vbox/shared/presentation/ClipboardExt.kt | 11 + .../vbox/shared/presentation/ComponentExt.kt | 55 +++ .../vbox/shared/presentation/MainScreen.kt | 136 ++++++ .../vbox/shared/presentation/RootComponent.kt | 76 +++ .../vbox/shared/presentation/ShareManager.kt | 8 + .../shared/presentation/UiConfiguration.kt | 13 + .../vbox/shared/presentation/UrlRoutes.kt | 270 +++++++++++ .../vbox/shared/presentation/VideoLauncher.kt | 5 + .../shared/presentation/list/VideoScreen.kt | 296 ++++++++++++ .../presentation/list/VideoScreenComponent.kt | 74 +++ .../presentation/loading/LoadingComponent.kt | 45 ++ .../presentation/loading/LoadingScreen.kt | 22 + .../vbox/shared/presentation/nav/Child.kt | 35 ++ .../vbox/shared/presentation/nav/Route.kt | 77 +++ .../vbox/shared/presentation/nav/RouteIds.kt | 24 + .../offline/OfflineVideoComponent.kt | 63 +++ .../offline/OfflineVideoScreen.kt | 289 +++++++++++ .../overview/OverviewComponent.kt | 189 ++++++++ .../presentation/overview/OverviewScreen.kt | 305 ++++++++++++ .../episode/EpisodeOverviewComponent.kt | 200 ++++++++ .../overview/episode/EpisodeOverviewScreen.kt | 282 +++++++++++ .../overview/episode/SessionOfflineStorage.kt | 110 +++++ .../season/SeasonOverviewComponent.kt | 69 +++ .../overview/season/SeasonOverviewScreen.kt | 92 ++++ .../presentation/player/PlayerComponent.kt | 128 +++++ .../presentation/player/PlayerScreen.kt | 133 ++++++ .../presentation/saved/SavedMovieScreen.kt | 124 +++++ .../saved/SavedMovieScreenComponent.kt | 83 ++++ .../presentation/search/SearchScreen.kt | 287 +++++++++++ .../search/SearchScreenComponent.kt | 56 +++ .../settings/SettingsComponent.kt | 98 ++++ .../presentation/settings/SettingsScreen.kt | 102 ++++ .../settings/auth/AuthComponent.kt | 88 ++++ .../presentation/settings/auth/AuthScreen.kt | 158 +++++++ .../settings/pass/EditPasswordComponent.kt | 65 +++ .../settings/pass/EditPasswordScreen.kt | 116 +++++ .../settings/register/RegisterComponent.kt | 69 +++ .../settings/register/RegisterScreen.kt | 24 + .../settings/serv/EditServComponent.kt | 90 ++++ .../settings/serv/EditServScreen.kt | 102 ++++ .../settings/tag/LinkTagBottomSheet.kt | 110 +++++ .../presentation/settings/tag/TagComponent.kt | 60 +++ .../presentation/settings/tag/TagScreen.kt | 206 ++++++++ .../state/TopAppBarStateHolder.kt | 35 ++ .../commonMain/sqldelight/migrations/1.sqm | 5 + .../commonMain/sqldelight/migrations/2.sqm | 9 + .../commonMain/sqldelight/migrations/3.sqm | 5 + .../commonMain/sqldelight/migrations/4.sqm | 7 + .../commonMain/sqldelight/migrations/5.sqm | 7 + .../commonMain/sqldelight/migrations/6.sqm | 20 + .../commonMain/sqldelight/migrations/7.sqm | 10 + .../ru/shadowsparky/vbox/domain/ApiCache.sq | 14 + .../shadowsparky/vbox/domain/HlamerCache.sq | 14 + .../ru/shadowsparky/vbox/domain/MovieTag.sq | 39 ++ .../shadowsparky/vbox/domain/OfflineVideo.sq | 48 ++ .../vbox/domain/PendingOperation.sq | 34 ++ .../vbox/domain/RecentlyWatched.sq | 27 ++ .../ru/shadowsparky/vbox/domain/SavedMovie.sq | 19 + .../ru/shadowsparky/vbox/domain/SavedTime.sq | 13 + .../ru/shadowsparky/vbox/domain/SearchInfo.sq | 22 + .../ru/shadowsparky/vbox/domain/UserTag.sq | 25 + .../di/factory/DatabaseDriverFactory.kt | 19 + .../shared/di/factory/ImageLoaderProvider.kt | 33 ++ .../vbox/shared/domain/OsType.desktop.kt | 5 + .../presentation/ShareManager.desktop.kt | 10 + .../vbox/shared/presentation/VideoLauncher.kt | 48 ++ .../search/SearchScreen.desktop.kt | 6 + .../factory/DatabaseDriverFactory.wasmJs.kt | 15 + .../di/factory/ImageLoaderProvider.wasmJs.kt | 22 + .../vbox/shared/domain/OsType.wasmJs.kt | 5 + .../presentation/ShareManager.wasmJs.kt | 14 + .../presentation/VideoLauncher.wasmJs.kt | 11 + .../search/SearchScreen.wasmJs.kt | 6 + apps/vbox/client/web/.gitignore | 4 + apps/vbox/client/web/build.gradle.kts | 35 ++ .../kotlin/ru/shadowsparky/vbox/Main.kt | 114 +++++ .../web/src/wasmJsMain/resources/icon-16.png | Bin 0 -> 663 bytes .../web/src/wasmJsMain/resources/icon-32.png | Bin 0 -> 1105 bytes .../web/src/wasmJsMain/resources/icon.png | Bin 0 -> 10405 bytes .../web/src/wasmJsMain/resources/index.html | 83 ++++ .../src/wasmJsMain/resources/manifest.json | 13 + .../src/wasmJsMain/resources/sqljs.worker.js | 58 +++ .../client/web/webpack.config.d/config.js | 15 + .../web/webpack.config.d/devServerConfig.js | 4 + .../web/webpack.config.d/sqljs-config.js | 17 + apps/vbox/common/.gitignore | 1 + apps/vbox/common/build.gradle.kts | 23 + .../ru/shadowsparky/vbox/shared/VBoxCommon.kt | 10 + .../shared/di/factory/DispatcherProvider.kt | 3 + .../vbox/shared/domain/AuthTokenRepository.kt | 35 ++ .../vbox/shared/domain/HeathCheck.kt | 9 + .../domain/RecentlyWatchedRepository.kt | 47 ++ .../vbox/shared/domain/RemoteEventHandler.kt | 51 ++ .../shared/domain/SavedMovieRepository.kt | 40 ++ .../vbox/shared/domain/SearchRepository.kt | 41 ++ .../vbox/shared/domain/ServerConfiguration.kt | 22 + .../shadowsparky/vbox/shared/domain/User.kt | 3 + .../vbox/shared/domain/VideoApi.kt | 23 + .../vbox/shared/domain/model/Episode.kt | 13 + .../vbox/shared/domain/model/File.kt | 10 + .../vbox/shared/domain/model/LoginInfo.kt | 16 + .../domain/model/RecentlyWatchedInfo.kt | 11 + .../vbox/shared/domain/model/Season.kt | 10 + .../domain/model/ServerExceptionInfo.kt | 6 + .../vbox/shared/domain/model/VideoDetails.kt | 34 ++ .../vbox/shared/domain/model/VideoItem.kt | 12 + .../shared/domain/model/VideoLinksResponse.kt | 10 + .../shared/domain/model/VideosResponse.kt | 12 + .../shared/domain/tag/MovieTagRepository.kt | 31 ++ .../shared/domain/tag/UserTagRepository.kt | 42 ++ .../shared/di/factory/DesktopFileProvider.kt | 47 ++ build-logic/.gitignore | 1 + build-logic/convention/.gitignore | 2 + build-logic/convention/build.gradle.kts | 78 +++ .../main/kotlin/ru/shadowsparky/AndroidExt.kt | 39 ++ .../main/kotlin/ru/shadowsparky/BuildFlags.kt | 5 + .../src/main/kotlin/ru/shadowsparky/Ext.kt | 79 ++++ .../kotlin/ru/shadowsparky/KspExtensions.kt | 48 ++ .../shadowsparky/plugin/AndroidApplication.kt | 53 +++ .../ru/shadowsparky/plugin/AndroidLibrary.kt | 42 ++ .../shadowsparky/plugin/BackendApplication.kt | 12 + .../kotlin/ru/shadowsparky/plugin/Compose.kt | 28 ++ .../main/kotlin/ru/shadowsparky/plugin/Jvm.kt | 22 + .../main/kotlin/ru/shadowsparky/plugin/Kmp.kt | 32 ++ .../kotlin/ru/shadowsparky/plugin/Koin.kt | 44 ++ .../ru/shadowsparky/plugin/KrpcClient.kt | 20 + .../ru/shadowsparky/plugin/KrpcServer.kt | 20 + .../ru/shadowsparky/plugin/KtorClient.kt | 30 ++ .../ru/shadowsparky/plugin/Serialization.kt | 18 + .../ru/shadowsparky/plugin/SqlDelight.kt | 28 ++ .../ru/shadowsparky/plugin/WebApplication.kt | 9 + build-logic/settings.gradle.kts | 15 + build.gradle.kts | 13 + gradle.properties | 13 + gradle/gradle-daemon-jvm.properties | 13 + gradle/libs.versions.toml | 173 +++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 172 +++++++ gradlew.bat | 84 ++++ libs/android-stub/.gitignore | 2 + libs/android-stub/build.gradle.kts | 10 + .../internal/telephony/ITelephony.java | 5 + libs/backend-base/.gitignore | 2 + libs/backend-base/build.gradle.kts | 8 + .../ru/shadowsparky/backend/BackendCommon.kt | 10 + .../shadowsparky/backend/data/EnvFetcher.kt | 11 + .../shadowsparky/backend/data/JwtPreparer.kt | 33 ++ .../ru/shadowsparky/backend/data/Logger.kt | 20 + .../backend/data/StringFetcher.kt | 20 + .../backend/data/TokenVerifier.kt | 27 ++ .../ru/shadowsparky/backend/domain/JwtInfo.kt | 8 + .../backend/domain/LoginVerifier.kt | 10 + .../backend/domain/VerifyTokenException.kt | 3 + libs/base/.gitignore | 2 + libs/base/build.gradle.kts | 17 + .../data/AndroidClipboardManager.kt | 18 + .../ru/shadowsparky/data/Log.android.kt | 17 + .../data/SettingsFactory.android.kt | 15 + .../kotlin/ru/shadowsparky/CommonModule.kt | 37 ++ .../data/DispatcherProviderImpl.kt | 14 + .../shadowsparky/data/KeyValueStorageImpl.kt | 110 +++++ .../ru/shadowsparky/data/SettingsFactory.kt | 7 + .../ru/shadowsparky/domain/AppConfig.kt | 6 + .../shadowsparky/domain/ClipboardManager.kt | 7 + .../shadowsparky/domain/DispatcherProvider.kt | 10 + .../ru/shadowsparky/domain/KeyValueStorage.kt | 15 + .../kotlin/ru/shadowsparky/domain/Log.kt | 6 + .../ru/shadowsparky/data/LogImpl.desktop.kt | 18 + .../data/SettingsFactory.desktop.kt | 13 + .../ru/shadowsparky/data/LogImpl.wasmJs.kt | 17 + .../data/SettingsFactory.wasmJs.kt | 12 + libs/http-client/.gitignore | 2 + libs/http-client/build.gradle.kts | 28 ++ .../http/data/HttpClientFactory.android.kt | 26 + .../kotlin/ru/shadowsparky/http/HttpModule.kt | 69 +++ .../http/data/HttpClientFactory.kt | 7 + .../http/data/HttpClientSettings.kt | 111 +++++ .../http/data/RefreshTokenRunner.kt | 39 ++ .../http/domain/ClientKtorExtension.kt | 7 + .../shadowsparky/http/domain/HttpException.kt | 8 + .../shadowsparky/http/domain/LogoutAction.kt | 5 + .../http/domain/RefreshTokenAction.kt | 7 + .../ru/shadowsparky/http/domain/TokenInfo.kt | 9 + .../shadowsparky/http/domain/TokenStorage.kt | 16 + .../http/domain/UserAgentProvider.kt | 5 + .../http/data/HttpClientFactory.desktop.kt | 23 + .../http/data/HttpClientFactory.wasmJs.kt | 12 + libs/ui/.gitignore | 2 + libs/ui/build.gradle.kts | 19 + .../ui/components/InsetLazyColumn.android.kt | 14 + .../ru/shadowsparky/ui/theme/Theme.android.kt | 26 + .../composeResources/drawable/back.xml | 12 + .../composeResources/drawable/check.xml | 12 + .../composeResources/drawable/visibility.xml | 10 + .../drawable/visibility_off.xml | 10 + .../kotlin/ru/shadowsparky/ui/BaseScreen.kt | 166 +++++++ .../kotlin/ru/shadowsparky/ui/Blur.kt | 45 ++ .../ru/shadowsparky/ui/WindowExtensions.kt | 30 ++ .../shadowsparky/ui/components/BackButton.kt | 20 + .../shadowsparky/ui/components/Expressive.kt | 131 +++++ .../shadowsparky/ui/components/LazyColumn.kt | 137 ++++++ .../ui/components/LazyVerticalGrid.kt | 47 ++ .../ui/components/ListPreference.kt | 66 +++ .../ru/shadowsparky/ui/components/Loading.kt | 18 + .../ui/components/PasswordTextField.kt | 63 +++ .../ui/components/PreferenceCategory.kt | 27 ++ .../ui/components/SwitchPreference.kt | 58 +++ .../ui/components/TextPreference.kt | 41 ++ .../shadowsparky/ui/nav/BaseRootComponent.kt | 76 +++ .../kotlin/ru/shadowsparky/ui/nav/Child.kt | 3 + .../shadowsparky/ui/nav/ComponentFactory.kt | 7 + .../kotlin/ru/shadowsparky/ui/nav/Nav.kt | 28 ++ .../kotlin/ru/shadowsparky/ui/nav/Route.kt | 8 + .../kotlin/ru/shadowsparky/ui/theme/Color.kt | 219 +++++++++ .../ui/theme/ComponentDefaults.kt | 47 ++ .../kotlin/ru/shadowsparky/ui/theme/Theme.kt | 111 +++++ .../kotlin/ru/shadowsparky/ui/theme/Type.kt | 5 + .../ui/components/InsetLazyColumn.desktop.kt | 6 + .../ru/shadowsparky/ui/theme/Theme.desktop.kt | 10 + .../ru/shadowsparky/ui/PageLifecycle.kt | 80 ++++ .../ui/components/InsetLazyColumn.wasmJs.kt | 6 + .../ru/shadowsparky/ui/theme/Theme.wasm.kt | 10 + libs/video-player/.gitignore | 2 + libs/video-player/build.gradle.kts | 25 + .../videoplayer/AndroidPlayerController.kt | 213 +++++++++ .../videoplayer/ExternalPlayer.android.kt | 34 ++ .../videoplayer/ui/PictureInPicture.kt | 57 +++ .../videoplayer/ui/VideoCanvas.android.kt | 115 +++++ .../composeResources/drawable/lock_24px.xml | 10 + .../drawable/lock_open_24px.xml | 10 + .../drawable/music_note_24px.xml | 10 + .../composeResources/drawable/pause_24px.xml | 10 + .../drawable/play_arrow_24px.xml | 10 + .../composeResources/drawable/replay_24px.xml | 10 + .../composeResources/values/strings.xml | 6 + .../videoplayer/AudioTrackInfo.kt | 9 + .../shadowsparky/videoplayer/CanvasEvent.kt | 5 + .../shadowsparky/videoplayer/ControlsState.kt | 7 + .../videoplayer/ExternalPlayer.kt | 5 + .../videoplayer/PlayerController.kt | 19 + .../shadowsparky/videoplayer/PlayerEvent.kt | 8 + .../shadowsparky/videoplayer/PlayerState.kt | 18 + .../videoplayer/VideoPlayerModule.kt | 10 + .../videoplayer/VideoScaleType.kt | 7 + .../videoplayer/ui/DefaultControls.kt | 447 ++++++++++++++++++ .../videoplayer/ui/VideoCanvas.kt | 15 + .../videoplayer/ui/VideoPlayer.kt | 170 +++++++ .../videoplayer/ExternalPlayer.desktop.kt | 8 + .../videoplayer/ui/VideoCanvas.desktop.kt | 17 + .../videoplayer/ExternalPlayer.wasmJs.kt | 11 + .../videoplayer/WasmPlayerController.kt | 139 ++++++ .../videoplayer/ui/VideoCanvas.wasmJs.kt | 78 +++ settings.gradle.kts | 48 ++ 505 files changed, 19998 insertions(+), 93 deletions(-) create mode 100644 .gitignore delete mode 100644 README.md create mode 100644 apps/call-blocker/.gitignore create mode 100644 apps/call-blocker/build.gradle.kts create mode 100644 apps/call-blocker/src/main/AndroidManifest.xml create mode 100644 apps/call-blocker/src/main/ic_launcher-playstore.png create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/AppModule.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/ContentProviderContactReader.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/LRejectCallDelegate.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/NonContactsRejectCallDelegate.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PRejectCallDelegate.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PolicyBasedRejectDelegate.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PreferenceRejectPolicyManager.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/ContactReader.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectCallDelegate.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectPolicyManager.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/App.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallReceiver.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallWorker.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/MainActivity.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PermissionRequest.kt create mode 100644 apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PolicyScreen.kt create mode 100644 apps/call-blocker/src/main/res/drawable/ic_launcher_background.xml create mode 100644 apps/call-blocker/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 apps/call-blocker/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 apps/call-blocker/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 apps/call-blocker/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 apps/call-blocker/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 apps/call-blocker/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 apps/call-blocker/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 apps/call-blocker/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 apps/call-blocker/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 apps/call-blocker/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 apps/call-blocker/src/main/res/values/colors.xml create mode 100644 apps/call-blocker/src/main/res/values/ic_launcher_background.xml create mode 100644 apps/call-blocker/src/main/res/values/strings.xml create mode 100644 apps/call-blocker/src/main/res/values/themes.xml create mode 100644 apps/call-blocker/src/main/res/xml/backup_rules.xml create mode 100644 apps/call-blocker/src/main/res/xml/data_extraction_rules.xml create mode 100644 apps/experimentarium/androidApp/.gitignore create mode 100644 apps/experimentarium/androidApp/build.gradle.kts create mode 100644 apps/experimentarium/androidApp/src/main/AndroidManifest.xml create mode 100644 apps/experimentarium/androidApp/src/main/ic_launcher-playstore.png create mode 100644 apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/App.kt create mode 100644 apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/MainActivity.kt create mode 100644 apps/experimentarium/androidApp/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 apps/experimentarium/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 apps/experimentarium/androidApp/src/main/res/values/ic_launcher_background.xml create mode 100644 apps/experimentarium/androidApp/src/main/res/values/strings.xml create mode 100644 apps/experimentarium/shared/.gitignore create mode 100644 apps/experimentarium/shared/build.gradle.kts create mode 100644 apps/experimentarium/shared/src/commonMain/kotlin/ru/shadowsparky/experimentarium/shared/Test.kt create mode 100644 apps/experimentarium/web/.gitignore create mode 100644 apps/experimentarium/web/build.gradle.kts create mode 100644 apps/experimentarium/web/src/wasmJsMain/kotlin/ru/shadowsparky/experimentarium/Main.kt create mode 100644 apps/experimentarium/web/src/wasmJsMain/resources/index.html create mode 100644 apps/vbox/backend/.gitignore create mode 100644 apps/vbox/backend/Dockerfile create mode 100644 apps/vbox/backend/build.gradle.kts create mode 100644 apps/vbox/backend/docker-compose.yml create mode 100644 apps/vbox/backend/postgres.env create mode 100644 apps/vbox/backend/readme.md create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRecentlyWatchedRepository.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRemoteEventHandler.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSavedMovieRepository.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSearchRepository.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/SessionCache.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendAuthTokenRepository.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendLoginVerifier.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendApi.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendMapper.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalCache.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalConst.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/XmlReader.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendMovieTagRepository.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendUserTagRepository.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/DbModule.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EventModule.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SearchRepositoryFactory.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Jwt.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Serialization.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/RecentlyWatchedRouting.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SavedMovieRouting.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SearchRouting.kt create mode 100644 apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/TagsRouting.kt create mode 100644 apps/vbox/backend/src/main/resources/application.conf create mode 100644 apps/vbox/backend/src/main/resources/logback.xml create mode 100644 apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq create mode 100644 apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/movie.sq create mode 100644 apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/recent.sq create mode 100644 apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/refresh_tokens.sq create mode 100644 apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/saved_movie.sq create mode 100644 apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/search.sq create mode 100644 apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/tags.sq create mode 100644 apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/users.sq create mode 100644 apps/vbox/client/androidApp/.gitignore create mode 100644 apps/vbox/client/androidApp/build.gradle.kts create mode 100644 apps/vbox/client/androidApp/proguard-rules.pro create mode 100644 apps/vbox/client/androidApp/src/main/AndroidManifest.xml create mode 100644 apps/vbox/client/androidApp/src/main/ic_launcher-playstore.png create mode 100644 apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt create mode 100644 apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/MainActivity.kt create mode 100644 apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt create mode 100644 apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt create mode 100644 apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SeasonOverviewScreenPreview.kt create mode 100644 apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/VideoScreenPreview.kt create mode 100644 apps/vbox/client/androidApp/src/main/res/drawable/baseline_saved_search_24.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/drawable/baseline_video_library_24.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/drawable/ic_outline_remove_red_eye_24.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/layout/activity_main.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_banner.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_banner_foreground.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 apps/vbox/client/androidApp/src/main/res/values/ic_banner_background.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/values/ic_launcher_background.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/values/strings.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/values/themes.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/xml-v25/shortcuts.xml create mode 100644 apps/vbox/client/androidApp/src/main/res/xml/shortcuts.xml create mode 100644 apps/vbox/client/shared/.gitignore create mode 100644 apps/vbox/client/shared/build.gradle.kts create mode 100644 apps/vbox/client/shared/src/androidMain/AndroidManifest.xml create mode 100644 apps/vbox/client/shared/src/androidMain/kotlin/app/cash/sqldelight/driver/android/AndroidSqliteDriver.kt create mode 100644 apps/vbox/client/shared/src/androidMain/kotlin/ru/shadowsparky/vbox/shared/data/AndroidOfflineStorage.kt create mode 100644 apps/vbox/client/shared/src/androidMain/kotlin/ru/shadowsparky/vbox/shared/data/OfflineDownloader.kt create mode 100644 apps/vbox/client/shared/src/androidMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DatabaseDriverFactory.kt create mode 100644 apps/vbox/client/shared/src/androidMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/ImageLoaderProvider.kt create mode 100644 apps/vbox/client/shared/src/androidMain/kotlin/ru/shadowsparky/vbox/shared/domain/ActivityInfo.kt create mode 100644 apps/vbox/client/shared/src/androidMain/kotlin/ru/shadowsparky/vbox/shared/domain/OsType.android.kt create mode 100644 apps/vbox/client/shared/src/androidMain/kotlin/ru/shadowsparky/vbox/shared/presentation/ShareManager.android.kt create mode 100644 apps/vbox/client/shared/src/androidMain/kotlin/ru/shadowsparky/vbox/shared/presentation/VideoLauncher.kt create mode 100644 apps/vbox/client/shared/src/androidMain/kotlin/ru/shadowsparky/vbox/shared/presentation/search/SearchScreen.android.kt create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/clear.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/clear_text.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/deselect.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/download_done.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/eye.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/filter_list.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/folder_check.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/home.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/keyboard_arrow_down.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/keyboard_arrow_up.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/more.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/pending.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/save_as.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/search.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/select_all.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/settings.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/share.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/star-unchecked.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/drawable/star.xml create mode 100644 apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/VBoxSharedModule.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/AppDatabaseProvider.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/AuthTokenRepositoryImpl.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/DefaultServerConfigurationRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/HeathCheckImpl.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/HmacSha512Calc.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/KtorExtensions.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/PlayerSettingsStorgeImpl.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteEventListenerImpl.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteSourceSynchronizer.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/SqlSavedTimeRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/api/FallbackVideoApi.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/api/PosterWithoutSuffixVideoApi.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/api/VideoApiImpl.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/recent/RemoteRecentlyWatchedRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/recent/SqlRecentlyWatchedRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/recent/SyncClientServerRecentlyWatchedRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/saved/PosterWithoutSuffixSavedMovieRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/saved/RemoteSavedMovieRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/saved/SqlSavedMovieRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/saved/SyncClientSavedMovieRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/search/RemoteSearchRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/search/SqlSearchRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/search/SyncClientServerSearchRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/sync/PendingOperationSynchronizer.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/sync/SqlPendingOperationRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/sync/SyncRepositoryImpl.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/sync/strategy/MovieTagSyncStrategy.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/sync/strategy/RecentlyWatchedSyncStrategy.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/sync/strategy/SavedMovieSyncStrategy.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/sync/strategy/SearchSyncStrategy.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/sync/strategy/UserTagSyncStrategy.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/tag/RemoteMovieTagRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/tag/RemoteUserTagRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/tag/SqlMovieTagRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/tag/SqlUserTagRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/tag/SyncMovieTagRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/tag/SyncUserTagRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/token/RefreshTokenActionImpl.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/token/TokenStorageImpl.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/Database.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/EventsModule.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/HttpModule.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/SavedModule.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DatabaseDriverFactory.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/ImageLoaderProvider.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/AddOfflineVideoUseCase.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/AggregateSyncStrategy.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/AuthTokenCacheExt.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/LogoutUseCase.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/OfflineStorage.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/OfflineVideoInteractor.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/OsType.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/PendingOperationRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/PlayerInteractor.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/PlayerSettingsStorge.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SavedTimeRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/ServerConfigurationRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SyncRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BottomBar.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/ClipboardExt.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/ComponentExt.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/MainScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/RootComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/ShareManager.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/UiConfiguration.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/UrlRoutes.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/VideoLauncher.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/list/VideoScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/list/VideoScreenComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/loading/LoadingComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/loading/LoadingScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/Child.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/Route.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/RouteIds.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/offline/OfflineVideoComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/offline/OfflineVideoScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/OverviewComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/OverviewScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/episode/EpisodeOverviewComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/episode/EpisodeOverviewScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/episode/SessionOfflineStorage.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/season/SeasonOverviewComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/overview/season/SeasonOverviewScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/player/PlayerComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/player/PlayerScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/saved/SavedMovieScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/saved/SavedMovieScreenComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/search/SearchScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/search/SearchScreenComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/auth/AuthComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/auth/AuthScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/pass/EditPasswordComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/pass/EditPasswordScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/register/RegisterComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/register/RegisterScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/serv/EditServComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/serv/EditServScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/tag/LinkTagBottomSheet.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/tag/TagComponent.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/tag/TagScreen.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/state/TopAppBarStateHolder.kt create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/migrations/1.sqm create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/migrations/2.sqm create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/migrations/3.sqm create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/migrations/4.sqm create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/migrations/5.sqm create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/migrations/6.sqm create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/migrations/7.sqm create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/ru/shadowsparky/vbox/domain/ApiCache.sq create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/ru/shadowsparky/vbox/domain/HlamerCache.sq create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/ru/shadowsparky/vbox/domain/MovieTag.sq create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/ru/shadowsparky/vbox/domain/OfflineVideo.sq create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/ru/shadowsparky/vbox/domain/PendingOperation.sq create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/ru/shadowsparky/vbox/domain/RecentlyWatched.sq create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/ru/shadowsparky/vbox/domain/SavedMovie.sq create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/ru/shadowsparky/vbox/domain/SavedTime.sq create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/ru/shadowsparky/vbox/domain/SearchInfo.sq create mode 100644 apps/vbox/client/shared/src/commonMain/sqldelight/ru/shadowsparky/vbox/domain/UserTag.sq create mode 100644 apps/vbox/client/shared/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DatabaseDriverFactory.kt create mode 100644 apps/vbox/client/shared/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/ImageLoaderProvider.kt create mode 100644 apps/vbox/client/shared/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/domain/OsType.desktop.kt create mode 100644 apps/vbox/client/shared/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/presentation/ShareManager.desktop.kt create mode 100644 apps/vbox/client/shared/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/presentation/VideoLauncher.kt create mode 100644 apps/vbox/client/shared/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/presentation/search/SearchScreen.desktop.kt create mode 100644 apps/vbox/client/shared/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DatabaseDriverFactory.wasmJs.kt create mode 100644 apps/vbox/client/shared/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/ImageLoaderProvider.wasmJs.kt create mode 100644 apps/vbox/client/shared/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/shared/domain/OsType.wasmJs.kt create mode 100644 apps/vbox/client/shared/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/shared/presentation/ShareManager.wasmJs.kt create mode 100644 apps/vbox/client/shared/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/shared/presentation/VideoLauncher.wasmJs.kt create mode 100644 apps/vbox/client/shared/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/shared/presentation/search/SearchScreen.wasmJs.kt create mode 100644 apps/vbox/client/web/.gitignore create mode 100644 apps/vbox/client/web/build.gradle.kts create mode 100644 apps/vbox/client/web/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/Main.kt create mode 100644 apps/vbox/client/web/src/wasmJsMain/resources/icon-16.png create mode 100644 apps/vbox/client/web/src/wasmJsMain/resources/icon-32.png create mode 100644 apps/vbox/client/web/src/wasmJsMain/resources/icon.png create mode 100644 apps/vbox/client/web/src/wasmJsMain/resources/index.html create mode 100644 apps/vbox/client/web/src/wasmJsMain/resources/manifest.json create mode 100644 apps/vbox/client/web/src/wasmJsMain/resources/sqljs.worker.js create mode 100644 apps/vbox/client/web/webpack.config.d/config.js create mode 100644 apps/vbox/client/web/webpack.config.d/devServerConfig.js create mode 100644 apps/vbox/client/web/webpack.config.d/sqljs-config.js create mode 100644 apps/vbox/common/.gitignore create mode 100644 apps/vbox/common/build.gradle.kts create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/VBoxCommon.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DispatcherProvider.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/AuthTokenRepository.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HeathCheck.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RecentlyWatchedRepository.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SavedMovieRepository.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SearchRepository.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/ServerConfiguration.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/User.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/VideoApi.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Episode.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/File.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/LoginInfo.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/RecentlyWatchedInfo.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Season.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/ServerExceptionInfo.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoDetails.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoItem.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoLinksResponse.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideosResponse.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/MovieTagRepository.kt create mode 100644 apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/UserTagRepository.kt create mode 100644 apps/vbox/common/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DesktopFileProvider.kt create mode 100644 build-logic/.gitignore create mode 100644 build-logic/convention/.gitignore create mode 100644 build-logic/convention/build.gradle.kts create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/AndroidExt.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/BuildFlags.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/Ext.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/KspExtensions.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidApplication.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidLibrary.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/BackendApplication.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Compose.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Jvm.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Kmp.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Koin.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcClient.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcServer.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KtorClient.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Serialization.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/SqlDelight.kt create mode 100644 build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/WebApplication.kt create mode 100644 build-logic/settings.gradle.kts create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.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 100644 libs/android-stub/.gitignore create mode 100644 libs/android-stub/build.gradle.kts create mode 100644 libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java create mode 100644 libs/backend-base/.gitignore create mode 100644 libs/backend-base/build.gradle.kts create mode 100644 libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/BackendCommon.kt create mode 100644 libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/EnvFetcher.kt create mode 100644 libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/JwtPreparer.kt create mode 100644 libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/Logger.kt create mode 100644 libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/StringFetcher.kt create mode 100644 libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/TokenVerifier.kt create mode 100644 libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/JwtInfo.kt create mode 100644 libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/LoginVerifier.kt create mode 100644 libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/VerifyTokenException.kt create mode 100644 libs/base/.gitignore create mode 100644 libs/base/build.gradle.kts create mode 100644 libs/base/src/androidMain/kotlin/ru/shadowsparky/data/AndroidClipboardManager.kt create mode 100644 libs/base/src/androidMain/kotlin/ru/shadowsparky/data/Log.android.kt create mode 100644 libs/base/src/androidMain/kotlin/ru/shadowsparky/data/SettingsFactory.android.kt create mode 100644 libs/base/src/commonMain/kotlin/ru/shadowsparky/CommonModule.kt create mode 100644 libs/base/src/commonMain/kotlin/ru/shadowsparky/data/DispatcherProviderImpl.kt create mode 100644 libs/base/src/commonMain/kotlin/ru/shadowsparky/data/KeyValueStorageImpl.kt create mode 100644 libs/base/src/commonMain/kotlin/ru/shadowsparky/data/SettingsFactory.kt create mode 100644 libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt create mode 100644 libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/ClipboardManager.kt create mode 100644 libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/DispatcherProvider.kt create mode 100644 libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/KeyValueStorage.kt create mode 100644 libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/Log.kt create mode 100644 libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/LogImpl.desktop.kt create mode 100644 libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/SettingsFactory.desktop.kt create mode 100644 libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/LogImpl.wasmJs.kt create mode 100644 libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/SettingsFactory.wasmJs.kt create mode 100644 libs/http-client/.gitignore create mode 100644 libs/http-client/build.gradle.kts create mode 100644 libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/LogoutAction.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/RefreshTokenAction.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenInfo.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenStorage.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/UserAgentProvider.kt create mode 100644 libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt create mode 100644 libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt create mode 100644 libs/ui/.gitignore create mode 100644 libs/ui/build.gradle.kts create mode 100644 libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.android.kt create mode 100644 libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/theme/Theme.android.kt create mode 100644 libs/ui/src/commonMain/composeResources/drawable/back.xml create mode 100644 libs/ui/src/commonMain/composeResources/drawable/check.xml create mode 100644 libs/ui/src/commonMain/composeResources/drawable/visibility.xml create mode 100644 libs/ui/src/commonMain/composeResources/drawable/visibility_off.xml create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/BaseScreen.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/Blur.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/WindowExtensions.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/BackButton.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyColumn.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyVerticalGrid.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/ListPreference.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Loading.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PasswordTextField.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PreferenceCategory.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/SwitchPreference.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/BaseRootComponent.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Child.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/ComponentFactory.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Route.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Color.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Theme.kt create mode 100644 libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Type.kt create mode 100644 libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.desktop.kt create mode 100644 libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/theme/Theme.desktop.kt create mode 100644 libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt create mode 100644 libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.wasmJs.kt create mode 100644 libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/theme/Theme.wasm.kt create mode 100644 libs/video-player/.gitignore create mode 100644 libs/video-player/build.gradle.kts create mode 100644 libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt create mode 100644 libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.android.kt create mode 100644 libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/PictureInPicture.kt create mode 100644 libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.android.kt create mode 100644 libs/video-player/src/commonMain/composeResources/drawable/lock_24px.xml create mode 100644 libs/video-player/src/commonMain/composeResources/drawable/lock_open_24px.xml create mode 100644 libs/video-player/src/commonMain/composeResources/drawable/music_note_24px.xml create mode 100644 libs/video-player/src/commonMain/composeResources/drawable/pause_24px.xml create mode 100644 libs/video-player/src/commonMain/composeResources/drawable/play_arrow_24px.xml create mode 100644 libs/video-player/src/commonMain/composeResources/drawable/replay_24px.xml create mode 100644 libs/video-player/src/commonMain/composeResources/values/strings.xml create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/AudioTrackInfo.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/CanvasEvent.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ControlsState.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerController.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerEvent.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerState.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoPlayerModule.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoScaleType.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.kt create mode 100644 libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt create mode 100644 libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.desktop.kt create mode 100644 libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.desktop.kt create mode 100644 libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.wasmJs.kt create mode 100644 libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/WasmPlayerController.kt create mode 100644 libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.wasmJs.kt create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7702bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/shared/build +.idea +androidApp/build +cache +.kotlin +kotlin-js-store +release.keystore +keystore.properties diff --git a/README.md b/README.md deleted file mode 100644 index 4844ad4..0000000 --- a/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# android-projects - - - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files -* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin https://gitlab.com/pornking/android-projects.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools - -* [Set up project integrations](https://gitlab.com/pornking/android-projects/-/settings/integrations) - -## Collaborate with your team - -* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/) -* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/) -* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically) -* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/) -* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/) -* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/) -* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/) -* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/) -* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README - -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. diff --git a/apps/call-blocker/.gitignore b/apps/call-blocker/.gitignore new file mode 100644 index 0000000..348c102 --- /dev/null +++ b/apps/call-blocker/.gitignore @@ -0,0 +1,2 @@ +/build +/bin diff --git a/apps/call-blocker/build.gradle.kts b/apps/call-blocker/build.gradle.kts new file mode 100644 index 0000000..05d0cac --- /dev/null +++ b/apps/call-blocker/build.gradle.kts @@ -0,0 +1,46 @@ + +import com.android.build.api.dsl.ApplicationExtension + +plugins { + alias(libs.plugins.convention.android.app) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.koin) +} + +extensions.getByType().apply { + namespace = "ru.shadowsparky.callblocker" + defaultConfig { + applicationId = namespace + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + } + } + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.datastore.preferences) + implementation(libs.koin.android) + implementation(libs.koin.androidx.compose) + implementation(libs.activity.compose) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.appcompat) + + implementation(project(":libs:base")) + implementation(project(":libs:ui")) + + debugImplementation(libs.ui.tooling) + compileOnly(libs.ui.tooling.preview.android) + compileOnly(project(":libs:android-stub")) + testImplementation(libs.koin.test) + testImplementation(libs.mockk) + testImplementation(kotlin("test-junit")) +} diff --git a/apps/call-blocker/src/main/AndroidManifest.xml b/apps/call-blocker/src/main/AndroidManifest.xml new file mode 100644 index 0000000..da8dff8 --- /dev/null +++ b/apps/call-blocker/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/call-blocker/src/main/ic_launcher-playstore.png b/apps/call-blocker/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..01f49b3674061db8afdecda03d4f75dbb6d8689a GIT binary patch literal 11545 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 C%8@?+ literal 0 HcmV?d00001 diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/AppModule.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/AppModule.kt new file mode 100644 index 0000000..6693d7a --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/AppModule.kt @@ -0,0 +1,24 @@ +package ru.shadowsparky.callblocker + +import android.app.Application +import android.os.Build +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Module +import ru.shadowsparky.callblocker.data.LRejectCallDelegate +import ru.shadowsparky.callblocker.data.PRejectCallDelegate +import ru.shadowsparky.callblocker.domain.RejectCallDelegate + +@Module +@ComponentScan +class AppModule { + + @Factory + fun provideAlwaysRejectCallDelegate(app: Application): RejectCallDelegate { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PRejectCallDelegate(app) + } else { + LRejectCallDelegate(app) + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/ContentProviderContactReader.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/ContentProviderContactReader.kt new file mode 100644 index 0000000..736700c --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/ContentProviderContactReader.kt @@ -0,0 +1,51 @@ +package ru.shadowsparky.callblocker.data + +import android.content.Context +import android.database.Cursor +import android.provider.ContactsContract +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Factory +import ru.shadowsparky.callblocker.domain.ContactEntry +import ru.shadowsparky.callblocker.domain.ContactReader + +@Factory +class ContentProviderContactReader( + private val context: Context +) : ContactReader { + + override suspend fun read(): Set { + val projection = arrayOf( + ContactsContract.Contacts.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER + ) + return withContext(Dispatchers.IO) { + context.contentResolver.query( + ContactsContract.Data.CONTENT_URI, + projection, + null, + null, + null + ).use { queryContacts(it ?: return@withContext emptySet()) } + } + } + + private fun queryContacts(cursor: Cursor): Set { + if (!cursor.moveToFirst()) return emptySet() + val result = mutableSetOf() + do { + val name = cursor.getString(ContactsContract.Contacts.DISPLAY_NAME) + val phone = cursor.getString(ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER) + if (phone != null && name != null) { + result.add(ContactEntry(name, phone)) + } + } while (cursor.moveToNext()) + return result + } + + private fun Cursor.getString(column: String): String? { + val idx = getColumnIndex(column) + if (idx < 0) return null + return getString(idx) + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/LRejectCallDelegate.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/LRejectCallDelegate.kt new file mode 100644 index 0000000..3b91d02 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/LRejectCallDelegate.kt @@ -0,0 +1,28 @@ +package ru.shadowsparky.callblocker.data + +import android.app.Application +import android.content.Context +import android.telephony.TelephonyManager +import android.util.Log +import com.android.internal.telephony.ITelephony +import ru.shadowsparky.callblocker.domain.RejectCallDelegate + +class LRejectCallDelegate(private val app: Application) : RejectCallDelegate { + + override suspend fun reject() { + getTeleService()?.endCall() + } + + private fun getTeleService(): ITelephony? { + val tm = app.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + try { + val c = Class.forName(tm.javaClass.name) + val m = c.getDeclaredMethod("getITelephony") + m.isAccessible = true + return m.invoke(tm) as ITelephony + } catch (e: Exception) { + Log.e("LRejectCallDelegate", "err", e) + } + return null + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/NonContactsRejectCallDelegate.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/NonContactsRejectCallDelegate.kt new file mode 100644 index 0000000..b7848a3 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/NonContactsRejectCallDelegate.kt @@ -0,0 +1,39 @@ +package ru.shadowsparky.callblocker.data + +import android.util.Log +import ru.shadowsparky.callblocker.BuildConfig +import ru.shadowsparky.callblocker.domain.ContactReader +import ru.shadowsparky.callblocker.domain.RejectCallDelegate + +class NonContactsRejectCallDelegate( + private val contactReader: ContactReader, + private val number: String?, + private val wrapper: RejectCallDelegate +) : RejectCallDelegate { + + override suspend fun reject() { + log("reject called with number $number") + if (number == null) { + log("number is null!!! exiting...") + return + } + val contacts = contactReader.read() + log("contacts found ${contacts.joinToString()}") + if (!contacts.any { it.phoneNumber == number }) { + log("calling number not found in contacts. rejecting") + wrapper.reject() + } else { + log("calling number found in contacts!") + } + } + + private fun log(msg: String) { + if (BuildConfig.DEBUG) { + Log.d(TAG, msg) + } + } + + companion object { + private const val TAG = "NonContactsCallDelegate" + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PRejectCallDelegate.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PRejectCallDelegate.kt new file mode 100644 index 0000000..ab5bc51 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PRejectCallDelegate.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.callblocker.data + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.telecom.TelecomManager +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import ru.shadowsparky.callblocker.domain.RejectCallDelegate + +@RequiresApi(Build.VERSION_CODES.P) +class PRejectCallDelegate(private val context: Context) : RejectCallDelegate { + @SuppressLint("MissingPermission") // пофиг + @Suppress("DEPRECATION") // еще больше пофиг, пока работает будем использовать! + override suspend fun reject() { + Log.e("PRejectCallDelegate", "reject called", RuntimeException()) + context.getSystemService()?.endCall() + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PolicyBasedRejectDelegate.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PolicyBasedRejectDelegate.kt new file mode 100644 index 0000000..e070554 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PolicyBasedRejectDelegate.kt @@ -0,0 +1,44 @@ +package ru.shadowsparky.callblocker.data + +import org.koin.core.annotation.Factory +import ru.shadowsparky.callblocker.domain.ContactReader +import ru.shadowsparky.callblocker.domain.RejectCallDelegate +import ru.shadowsparky.callblocker.domain.RejectPolicy +import ru.shadowsparky.callblocker.domain.RejectPolicyManager + +@Factory +class PolicyBasedRejectDelegateFactory( + private val rejectPolicyManager: RejectPolicyManager, + private val contactReader: ContactReader, + private val rejectDelegate: RejectCallDelegate +) { + fun create(callingNumber: String?): PolicyBasedRejectDelegate { + return PolicyBasedRejectDelegate( + rejectPolicyManager, + callingNumber, + contactReader, + rejectDelegate + ) + } +} + +class PolicyBasedRejectDelegate( + private val rejectPolicyManager: RejectPolicyManager, + private val callingNumber: String?, + private val contactReader: ContactReader, + private val rejectDelegate: RejectCallDelegate +) : RejectCallDelegate { + override suspend fun reject() { + val wrapper = when (rejectPolicyManager.get()) { + RejectPolicy.All -> rejectDelegate + RejectPolicy.NonContacts -> NonContactsRejectCallDelegate( + contactReader, + callingNumber, + rejectDelegate + ) + + RejectPolicy.None -> null + } + wrapper?.reject() + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PreferenceRejectPolicyManager.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PreferenceRejectPolicyManager.kt new file mode 100644 index 0000000..bf54cf4 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PreferenceRejectPolicyManager.kt @@ -0,0 +1,27 @@ +package ru.shadowsparky.callblocker.data + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Factory +import ru.shadowsparky.callblocker.domain.RejectPolicy +import ru.shadowsparky.callblocker.domain.RejectPolicyManager +import ru.shadowsparky.domain.KeyValueStorage + +@Factory +class PreferenceRejectPolicyManager( + private val keyValueStorage: KeyValueStorage +) : RejectPolicyManager { + override val flow = keyValueStorage.getInt(POLICY_KEY).map { + RejectPolicy.findByCode(it ?: RejectPolicy.None.code) + } + + override suspend fun set(newPolicy: RejectPolicy) { + keyValueStorage.putInt(POLICY_KEY, newPolicy.code) + } + + override suspend fun get(): RejectPolicy = flow.first() + + companion object { + private const val POLICY_KEY = "policy" + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/ContactReader.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/ContactReader.kt new file mode 100644 index 0000000..c0374c2 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/ContactReader.kt @@ -0,0 +1,11 @@ +package ru.shadowsparky.callblocker.domain + +interface ContactReader { + + suspend fun read(): Set +} + +data class ContactEntry( + val name: String, + val phoneNumber: String +) diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectCallDelegate.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectCallDelegate.kt new file mode 100644 index 0000000..68d0cc6 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectCallDelegate.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.callblocker.domain + +interface RejectCallDelegate { + suspend fun reject() +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectPolicyManager.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectPolicyManager.kt new file mode 100644 index 0000000..cd2d546 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectPolicyManager.kt @@ -0,0 +1,24 @@ +package ru.shadowsparky.callblocker.domain + +import kotlinx.coroutines.flow.Flow + +interface RejectPolicyManager { + val flow: Flow + + suspend fun set(newPolicy: RejectPolicy) + suspend fun get(): RejectPolicy +} + +sealed class RejectPolicy(val code: Int) { + data object None : RejectPolicy(0) + data object All : RejectPolicy(1) + data object NonContacts : RejectPolicy(2) + + companion object { + val values: Set get() = setOf(None, All, NonContacts) + + fun findByCode(code: Int): RejectPolicy { + return values.first { it.code == code } + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/App.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/App.kt new file mode 100644 index 0000000..1e14e72 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/App.kt @@ -0,0 +1,19 @@ +package ru.shadowsparky.callblocker.presentation + +import android.app.Application +import org.koin.android.ext.koin.androidContext +import org.koin.core.annotation.KoinApplication +import org.koin.plugin.module.dsl.startKoin +import ru.shadowsparky.callblocker.AppModule + +@KoinApplication(modules = [AppModule::class]) +object CallBlocker + +class App : Application() { + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@App) + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallReceiver.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallReceiver.kt new file mode 100644 index 0000000..c0b05fc --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallReceiver.kt @@ -0,0 +1,32 @@ +package ru.shadowsparky.callblocker.presentation + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.telephony.TelephonyManager +import android.util.Log +import ru.shadowsparky.callblocker.BuildConfig + +class RejectCallReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val keys = intent?.extras?.keySet() ?: return + val values = keys.map { it to intent.extras?.get(it).toString() } + + if (BuildConfig.DEBUG) { + Log.d("RejectCallReceiver", "start") + values.forEach { + Log.d("RejectCallReceiver", "key ${it.first}; value: ${it.second}") + } + Log.d("RejectCallReceiver", "end") + } + + if (intent.action == TelephonyManager.ACTION_PHONE_STATE_CHANGED && context != null) { + val state = intent.getStringExtra(TelephonyManager.EXTRA_STATE) + if (state == TelephonyManager.EXTRA_STATE_RINGING) { + @Suppress("DEPRECATION") + val number = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER) + RejectCallWorker.start(context, number) + } + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallWorker.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallWorker.kt new file mode 100644 index 0000000..c9063f7 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallWorker.kt @@ -0,0 +1,36 @@ +package ru.shadowsparky.callblocker.presentation + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import ru.shadowsparky.callblocker.data.PolicyBasedRejectDelegateFactory +import ru.shadowsparky.koin + +class RejectCallWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + private val delegateFactory by koin.inject() + + override suspend fun doWork(): Result { + val callingNumber = inputData.getString(NUMBER_KEY) + delegateFactory.create(callingNumber).reject() + return Result.success() + } + + companion object { + private const val NUMBER_KEY = "number" + + fun start(context: Context?, number: String?) { + context ?: return + val wm = WorkManager.getInstance(context) + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(NUMBER_KEY to number)) + .build() + wm.enqueue(request) + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/MainActivity.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/MainActivity.kt new file mode 100644 index 0000000..f6e1f8d --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/MainActivity.kt @@ -0,0 +1,42 @@ +package ru.shadowsparky.callblocker.presentation.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import ru.shadowsparky.callblocker.R +import ru.shadowsparky.callblocker.domain.RejectPolicyManager +import ru.shadowsparky.koin +import ru.shadowsparky.ui.BaseScreen + +class MainActivity : ComponentActivity() { + private val rejectPolicyManager by koin.inject() + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val permissionRequest = newPermissionRequest() + setContent { + BaseScreen( + appBar = { + TopAppBar(title = { Text(stringResource(R.string.app_name)) }) + } + ) { + CompositionLocalProvider(LocalPermissionRequest provides permissionRequest) { + Box(modifier = Modifier.padding(it)) { + PolicyContent(rejectPolicyManager) + } + } + } + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PermissionRequest.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PermissionRequest.kt new file mode 100644 index 0000000..0ce66db --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PermissionRequest.kt @@ -0,0 +1,147 @@ +package ru.shadowsparky.callblocker.presentation.ui + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.core.content.ContextCompat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import ru.shadowsparky.callblocker.R + +interface PermissionRequest { + val hasPermission: StateFlow + + fun getTitle(): String + + fun getDescription(): String + + fun requestPermission() +} + +abstract class MutablePermissionRequest(private val hasPermissionCallback: () -> Boolean) : + PermissionRequest { + override val hasPermission = MutableStateFlow(hasPermissionCallback()) + + init { + initState() + } + + override fun requestPermission() { + if (!hasPermission.value) requestPermissionInternal() + } + + private fun initState() { + if (!hasPermission.value) { + this.hasPermission.tryEmit(hasPermissionCallback()) + } + } + + protected abstract fun requestPermissionInternal() +} + +class MultiplePermissionRequest(private val requests: List) : PermissionRequest { + override val hasPermission + get() = getLatestDeclinedRequest()?.hasPermission ?: MutableStateFlow(true) + + override fun getTitle(): String { + return getLatestDeclinedRequest()?.getTitle() ?: "null" + } + + override fun getDescription(): String { + return getLatestDeclinedRequest()?.getDescription() ?: "null" + } + + private fun getLatestDeclinedRequest(): PermissionRequest? { + return requests.firstOrNull { !it.hasPermission.value } + } + + override fun requestPermission() { + getLatestDeclinedRequest()?.requestPermission() + } +} + +val LocalPermissionRequest: ProvidableCompositionLocal = + staticCompositionLocalOf { throw IllegalStateException("request must be initialized here") } + +fun ComponentActivity.newPermissionRequest(): PermissionRequest { + val list = mutableListOf( + ReadContactsPermissionRequest(this), + ReadCallLogPermissionRequest(this), + newAnswerPhonePermissionRequest(), + ReadPhoneStatePermissionRequest(this), + ) + return MultiplePermissionRequest(list) +} + +abstract class AndroidPermissionRequest( + private val activity: ComponentActivity, + private val permission: String, + hasPermissionCallback: () -> Boolean = { + ContextCompat.checkSelfPermission( + activity, + permission + ) == PackageManager.PERMISSION_GRANTED + } +) : MutablePermissionRequest(hasPermissionCallback) { + private val launcher = + activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { + hasPermission.tryEmit(it) + } + + override fun requestPermissionInternal() { + launcher.launch(permission) + } + + protected fun getString(code: Int, vararg args: Any): String { + return activity.getString(code, args) + } +} + +class ReadContactsPermissionRequest( + activity: ComponentActivity +) : AndroidPermissionRequest(activity, Manifest.permission.READ_CONTACTS) { + override fun getDescription(): String = getString(R.string.read_contacts_desc) + override fun getTitle(): String = getString(R.string.read_contacts) +} + +class ReadCallLogPermissionRequest( + activity: ComponentActivity +) : AndroidPermissionRequest(activity, Manifest.permission.READ_CALL_LOG) { + override fun getDescription(): String = getString(R.string.read_call_log_desc) + override fun getTitle(): String = getString(R.string.read_call_log) +} + +class ReadPhoneStatePermissionRequest( + activity: ComponentActivity +) : AndroidPermissionRequest(activity, Manifest.permission.READ_PHONE_STATE) { + override fun getDescription(): String = getString(R.string.read_phone_state_desc) + override fun getTitle(): String = getString(R.string.read_phone_state) +} + +private fun ComponentActivity.newAnswerPhonePermissionRequest(): PermissionRequest { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AnswerPhoneCallPermissionRequest(this) + } else { + CallPhonePermissionRequest(this) + } +} + +class CallPhonePermissionRequest( + activity: ComponentActivity +) : AndroidPermissionRequest(activity, Manifest.permission.CALL_PHONE) { + override fun getDescription(): String = getString(R.string.answer_phone_calls_desc) + override fun getTitle(): String = getString(R.string.answer_phone_calls) +} + +@RequiresApi(Build.VERSION_CODES.O) +class AnswerPhoneCallPermissionRequest( + activity: ComponentActivity +) : AndroidPermissionRequest(activity, Manifest.permission.ANSWER_PHONE_CALLS) { + override fun getDescription(): String = getString(R.string.answer_phone_calls_desc) + override fun getTitle(): String = getString(R.string.answer_phone_calls) +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PolicyScreen.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PolicyScreen.kt new file mode 100644 index 0000000..dbf3d0d --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PolicyScreen.kt @@ -0,0 +1,159 @@ +package ru.shadowsparky.callblocker.presentation.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import ru.shadowsparky.callblocker.R +import ru.shadowsparky.callblocker.domain.RejectPolicy +import ru.shadowsparky.callblocker.domain.RejectPolicyManager +import ru.shadowsparky.ui.components.ListPreference + +@Composable +fun PolicyContent(policyManager: RejectPolicyManager) { + val state = policyManager.flow.collectAsState(null) + if (state.value != null) { + InitializedPolicyContent(state, policyManager, LocalPermissionRequest.current) + } +} + +@Preview(showBackground = true) +@Composable +fun InitializedPolicyScreenPreview() { + val state = remember { mutableStateOf(RejectPolicy.None) } + InitializedPolicyContent( + state = state, + manager = object : RejectPolicyManager { + override val flow = MutableStateFlow(RejectPolicy.None) + override suspend fun set(newPolicy: RejectPolicy) = flow.emit(newPolicy) + override suspend fun get(): RejectPolicy = flow.value + }, + MultiplePermissionRequest( + listOf( + NoOpPermissionRequest("1"), + NoOpPermissionRequest("2"), + NoOpPermissionRequest("3") + ) + ) + ) +} + +private class NoOpPermissionRequest(private val prefix: String) : PermissionRequest { + override val hasPermission = MutableStateFlow(false) + + override fun getTitle(): String { + return "$prefix noop" + } + + override fun getDescription(): String { + return "$prefix noop" + } + + override fun requestPermission() { + hasPermission.tryEmit(true) + } +} + +@Composable +private fun InitializedPolicyContent( + state: State, + manager: RejectPolicyManager, + permissionRequest: PermissionRequest +) { + val coroutineScope = rememberCoroutineScope() + Column { + val items = RejectPolicy.values.associateWith { it.getTitle() } + val item = remember { mutableStateOf(state.value!!) } + val hasPermission by permissionRequest.hasPermission.collectAsState() + if (!hasPermission) { + MissingPermissionCard( + permissionRequest, + modifier = Modifier.padding(4.dp), + ) + } + ListPreference( + title = stringResource(id = R.string.selected_policy), + items = items, + selectedItem = item.value, + onItemSelected = { + item.value = it + coroutineScope.launch { + manager.set(it) + } + } + ) + } +} + +@Composable +private fun RejectPolicy.getTitle(): String { + val code = when (this) { + RejectPolicy.All -> R.string.reject_all + RejectPolicy.NonContacts -> R.string.reject_non_contacts + RejectPolicy.None -> R.string.reject_none + } + return stringResource(code) +} + +@Preview +@Composable +fun MissingPermissionCardPreview() { + MissingPermissionCard(NoOpPermissionRequest("1")) +} + +@Composable +fun MissingPermissionCard( + request: PermissionRequest, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + stringResource(id = R.string.warning), + style = MaterialTheme.typography.titleMedium + ) + Text( + stringResource( + id = R.string.mission_permission_text, + request.getTitle(), + request.getDescription() + ) + ) + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = { request.requestPermission() }, + ) { + Text(stringResource(id = R.string.grant_permission)) + } + } + } + } +} diff --git a/apps/call-blocker/src/main/res/drawable/ic_launcher_background.xml b/apps/call-blocker/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/apps/call-blocker/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/call-blocker/src/main/res/drawable/ic_launcher_foreground.xml b/apps/call-blocker/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..cb40282 --- /dev/null +++ b/apps/call-blocker/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/call-blocker/src/main/res/drawable/ic_launcher_monochrome.xml b/apps/call-blocker/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..1305eda --- /dev/null +++ b/apps/call-blocker/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..26260d87857c8b1586704d9d5977f1d4d32168dd GIT binary patch literal 1557 zcmV+w2I~2VP)-Y=);Kg|r28BHeH-Apn&AN-iv$vG$U{X8>s&Y8`N z7iy@Xh8k*wb`U}|6s^M02=nwSXhUJbV@in+&W+yy-G4>-h4O~-8|8I~fpf;WBZpT{ z<>*te73o$-8Gn3yB;euUA>?u8GVX9Vbo^W;2jN|xw4n(!cz=I?rzDUQ74#2Hk?jc5 z?@6?<7TfFV>mOJ-!oNbOL-V@3yu2h!OG`iUbCwjY!WyU3`KwX|QSNSUZ^_Ea%J0b4 zA3B3QF(nE@PBb8>6}kE&NNcy-|705zet`mD1X-=tHstEBpk_3ta6#{ot6zc+ z4i4U;F(nIHTwMGMxvGN1sDiwp3i5&~$P4lcqGKqsySppm=;#RTiOS1>e1e*qnn+4Y z3W%z{n@4xVTs(L`Zpgxu{-%kxx)< zZLLU%ki5J+a&~qmsvAJ$6V%bsLBhksMN;_0#>QebPgFO6$R}uSZjKRvrk*1vCWb65 zEQsm`5cvc-91fD1nJJRO5)lzW>g($X)`VT{lkM$ovb?;^fPHqfeRFd|*4Nj$&yJ6e zx$nVfojty`w#Jkdo{IE-r}4Wa7$U zAII?7*47qDPfur#hlPcawzf8|UQbUCR|nG=$P}K32jmrm?w^~R%O#-EXh=mx1-ZJq z5|xFFl9Cb<85zldeZC;QUe6rI6s5hrovYW|+soBSN=gbXL7=s@mHDfqNo?_S6P5uu z(dp@F(%09=fPEapXXtGsBO}c5{{DWlxw*;JgAlgP@bEAvRU+~WIy^iiSy@?JFG@{K zb-VY-jQoN?XJ@C|n5eqC+SjMery@86VYFOSRKy9w{UQh^svtN70gJ^#bUGa;DLFY= z?m;359zi&fmX;Roh7Gc_vzfu2uxtPc9zg*2owBkr=5dd&07j!xk|Yco(c^43+sB;X z2A3d!X+mLPp{OT$y4>*8!?Xl*H(aJz1;A1X){_PX2AFvtmT&!7;|@MSU}tBCnL6+# z;huysj_~P*F%YIDn4Yj@p8^9&ya-_DZxRVkK>(7_llYe#o^BWdu;V0*xG^p9CWi%Y zOYllb_+1_dUO@nt8}3Q`o&?aFFyh9v1amk3XPyYTxtJ7iN#W_n9|8bK zNn)IY!KAlUNKYtM5DOmP-QC^H+)a4dNkQ>~STMoGDgc&BgzHJ3_-q8RU_A+oZdjSb z@-5c5JzaunX=%QWg+f~pA3XXoAjH!RuaxlK4KFA01{ce)kmJXTNaYCf1bU5+J%{D< z5cC14g1n##@`5VJ3#uS5sDixkQ3NUew;EK?JLKw@AiLeJ*dBzOAjoF3`QL-~_xBr> z{)4)inVGl9)h|I)Q&T^&jotgUoEg+07yKj>Q<$!+e9%i!{f?`Z2Q@+(|UBrJ7#|j`C4rOpzVWq%g zu~>~paT zT2BR)6hV7a4BhHzqp_h3a&YA${if_gz_t=lLk%_52#NR)yM3l4>j@;z00000NkvXX Hu0mjf%(>v| literal 0 HcmV?d00001 diff --git a/apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..4e3e8be2f87ea2914aabc6150af816205edead15 GIT binary patch literal 3323 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 FV1gG(H6H)~ literal 0 HcmV?d00001 diff --git a/apps/call-blocker/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/call-blocker/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..06aeb537ca5eb0d0c769a46fb9b984b9f449c11f GIT binary patch literal 1088 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 SjYET{Cje5%NV`T86aGJxE*}E` literal 0 HcmV?d00001 diff --git a/apps/call-blocker/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/call-blocker/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..f631722b0bd0859e797364781611267cc6db4034 GIT binary patch literal 11293 zcmX|nby$;q*!DJR^cXEQx`F5K>?)(jBZ3FB?JLU>5v8q>5%SLx|{cY zzT8fl^>584 ze9Fw4Y7BBw2`tD+3T~bM`_ab|ne&|GImh$mg1?B)bGMkMGyl{QRy@H%l8QKOe%P<7 zGx>`O_JVz&I0=2sU5|2@8%^0ZZHN&VSPrxV$^aF@Fk%+yFBk$k?w3U__?mFtM_%AXl z*cp6~M+~y4O+ZMvtAZcov;3t_d%#uKb}7ffPjGa`x^++wVzqWL1;D zPuZ?UJv~&Ki6PXy7DcZZt^RDL-v!mKj zNcdVXl|C_u!N-(f(k$Xsu%{Jd^kMVb&^#^;=7VS6Vlu2>s(b47h^~|)KS#8G_8(YW zLdCMMy$&isCJQ4z(&C%LorpJwvfhcw@P45kVl3Pyf5rIj!vUC|rP(TN_dVQ}TTE4l z|F5B$8kZ(sQeX*5s+|f`zmAh%ju~a%yS3h&1N+#&i{-n^G%ib6g1N&{Pt55t!uGpt zC&X6;J^ETUCq0$e!Ff0Szsl6TQ{v4;`^Cr-9%zcX_7Qq}{vl0mRy4m2St|Rh`szW7 zA;n%#;avY!zp^f8lN7DyWs|^ z8@+q?PFgcVin4^!nwv-ad75#RQ%(M2!1CJXvu3;Cbqh~)G8!{jtRZdpn^fmW55ig&8) zm%Jnx_}a$Cg2d~UPJ|oUBYPzrAwPX1OEH1_nJu6Ttrg_RHa$IkfX@>PhaQ=?_%|`! z`l}?iSV5kmGl2_{OiM|b#!o+Z&kiH)S-lC9yv*w`Rs?q^KeJdL%~NaHnQyF3No6(T zBp`ij*6b^ZX7p`jYr#e&EzQ+?G>SRTP~>hzPKLWTrmL{OU+PM~8Qn>+08#hwXlZ21 zHAPFXxQG4xiXG)H-T5Md@$9jNzaEqMEE$}VYD%ak(Y%0O+H;584cWmkM7i!+cZ}5V z{|3U%KxqF6eB&U|)tCJ^><%*Mw&z_6J^ElPuh3$?v8MV+Y_~%V)yho1d8|J= zHr5y<%~*s@;AMkKa~uoaU0eI?+0w}ncwld4_Qm~Ztyh+iTAN6pBt)+a|qUTb`!Uu2ClB{I7%_r=EWM7n=iclY7g3~rYSEjW)NyffQC&a#p;C0Fn2xU_vp(zE@&W>ZP>A-@ z7Q86>B<#6kK>u%S-n_lA+{zd5jJL9qlJ$5o$UFvV&tz7f+yn2Hu8j+U)itmB?MnCc zr+J>cGg6~cSe14|e}GZ-FdwWcg5qyN7UTk(Uwn{1rtdz!I%DeW-FNS%p4|+_B_1W4 zFx;B1p4?8db|nhdcmmVmC|V@k;iY|RK*qy zDk&>ti`l;ch6t1sAgoU|GXoY5f6=+lz5G!E*;7l~@HAMb6LGGA9Tq+X8%mW{a_~EO6Voa~?yo zQaMtDUh{81CnhE3I)-761H)l@fyKqee)qTg0a$!ugq^3~;*)O+g&LOb zn>965Sa#r=O0$_jY6x1XQ5qVI$W`s>buwn5>o_{`QmN^?!t{HA+FKmAbduKrq63r# zCiN8+?@t~68-wLWT-17Zd+kL`^ALRr5irGpJ4?U**n9cLcukd9Inb!eKAZKsNT9lq zGw^TRU)AKC8e9<(5szQV@NhObcp20)oR=ACX~i$?O`B(_eHM^Plp;6xJN;Nn3@^o! zI0v3Gx6j#*q2?-Bb?VVIr2R}D2v^i0Ey*)tm;dOtpUPZqef@8YYs+m7Fh(GRk~hH9 z)6-u`3G)4SJqJsta|w@)SHPfMK&`TWmBL+I7rI~&vX}21jBy0-F>AjDe8a>3*>{O~ zQq)>afQpI=*P|tx%v<$r&{x&CKMCV0-dovWZ=B=Z_*-SM75wimH+4m9`|Y2u>b$!; z*&d97vd>Ut5lFUpJF=4VdZFfGxOKm6q5eLz5T}{WpYcT1QSh;)e)#a!X|C?IXpGuN zWjo1^Nd6Q2i}>Tl$m7M`!Lrsk`MUzvLJ=|w3e1P6z$34tT0*2RpPZa1KluEB$~ac* zGGCSLX!?HYhxP80f2B)77I=Z@)=H})M+8H?V^78C*a%Phx_>&NNSGe_rYoED@>BPnXV=mCiixhsnqWs@;By~I7nXW`> zUJAyjPq=%9cE`|l_h+^R<4q!*)z)^zLymdfPPVl3oM&qQDfbNYShwx7X*Bz(v|mSL7Z2D{J{X!?NsN zE*tnbjKnlU+RH^#?OmFp?{N{2Bwg9-W4s-4c~+qHx=Y{|rdU#)b`E@!$C+4P>>)UmG>Nc~#s+ zMa*OD0(;FMd2ayr?fw0wV5Q}3<6B@+rP6DuXJ3dP;hQOINX8CEN?@_%s@c z@@0MA^6q#;YS3RdRUT5-eDysxcTgD!UrXNGGtA3I2q;7Zst39RbBl^qLtk8An@K&X zn!bOrZI?8$cGd!lw61qotuw5&Mg1DBe%yWv692*b({-^qJ%XIGCi}3^h(jJ7FwH3~ z-AKw2&0~Ygw8bicqJ=L^n&kZ$aew)y%yf7s!koL#4*YmbSnC|;-4#CfT;z|l;4Xk zaBDHp3rzTf?sX;vOdIBqBnP&dI_QBkap_0&kx5a2sci50a4wx@x_#0wF?y@IByv%F zcCFGXPz8LJPpY;#gob}QyEcN=8?ck?p3f6!g9>O8Qd1kgBBQ9hd(8?dL6t2-Kx%%x z-OjiSB21Tgr}JvQp*ptiC63cwsc9n*V6P+dm29ozg~qj|d#OhMe+8O{7U;cem5WXJ z`@-u|vQPZR-<}ugsh|7tr;5I^nxW@fhT7TKto;N+`pCFhW?h{`H-n|+{r*DJwPM58 zi)E0#tG$Ya>cQ!xxgDmOhREVopsBe{hQq!cX)dIW$mwykUX!D_?;+AW%2~eEQnhZh z*s${Z*pNea9FyStd9qSxx5v7WkF#2B16B{wd5W2Gep;!!>N0Wu?jqB$5vLw zkZ|mgV}wZ6`$CGPkNReXD)NpdfEf$xgNn{sq8+;rJKXQh;G_vRvC=?&*IN}IQnsSy<*T%LQmuBMR77jGHS5p_G~tz?d6JS z`_&nDYksDGpHkN0;oV4#1AsH`$JL$@#pX>ENgK_64y!D(IY_XETlsjwo+j41`|B`=Qrp?i14B-z40i zdpC6*&>DFD02fE$Y-RE8Oq^jl3d<~{!F5q?{w-TSsT-xu&xQmCZEK znMAlUOq$fXn2NImG>dbA&?a9}A36T*altNzc;kmQomFDqAt=y#=pZ05Uc@a4tN(q} z=1kYM&-XC|a&t1KsfCtt)dneJ-0T@+OS{k~MoS)J-_*xURomyZxE1756dZ8M|CxYz z9NMstjDJ`m^;rT+Yk_sfL!o$yYpG(+-<=klO}!ZKkCpO3;&;cH_a|7N>+x+eAqS+_ z{Du{|BBsN<=@yU51ua~ap<4jmnC|e0^FTQzczJof+A;V|ZR>!eU+aUiLC2dzEtvw| zBeN8s{m*^}ZFp~$1xd81c8HS?X|gbkN?^by1zS%47#C3OCPU(nyPstN7F9uPx>F^- zgH@n7l*4CK)w4HmiI9*;W~dl3Q)^E`BO<-__5<#H^xbIpYe2QM9cu9`I9cngQi6hl zv$GTmONZS|UOf~JW8a9)y1q8XVzyg#m_7Fw{M>uunRnY>hEZezDPRhE?6Z+#*KR+( zxL-nm&J|~CF(d+RSve#g?YTY5K3~oD42O60$>``PiR{fm$3WZq-h89APOT=m(~?GM z7;v51_eBtpZobCL{p$)E*aFl*Xi1sXRmF!N-vhl9z)O(pJX7^bFvgJ8tsaQ8J+2-o z@RBIDS-pD*L-?T{PHS&AwV8Jtvo4e}yXEf65m<4KG+9myL?3F@;x9|mi5>Vmeaw>^ z9{Tl zH`o`CK1;Pk>rL}nDqH}MH~Q_x!E(E%{Ow8E(g7NbRSuE(25Ry-SD`2v6kqf`h7t4m zh?h@r%+S53Tq2dYYIoKa86OF%hiiamaTWlg=W6{c`W+Gd-a?bKk1-Ljco6A}y}5c3 zF_Ihe_uF|}Y2vPT5S)WQsCX(M@a;GiccFK>6pOKqYh5NxhbR?jVRO>V%!-%GVN6qC zgtAgqW)a8&Z>5({cDl@5J{|82FqtM)aJd>7@$qX;PB|^EeAe>%@6Y7*5CyC%@e`?5 zCWR1epCJ;c0zOO~$nCXiUmr_s9smwm=`r-kVcf#G&Dj0@*-l|JgY>5+IVuh2q|USf zQAV)UKE83K?SSXsuwP4V-rjZdX%D$g|oVXD3K=vz?zgr6vU@CpUccBssW_)DYZookpAsazhhp zw}0`Sj5zt}fa5SX(@M^th6A&28?ukdYw8#^dNU?fB zZAp2iUhNy|DWn{HQs=^-VWOcah?neNh^(~fg?oP2n57~QWWX?o3~+`pDh-hXIA1mS z&~qOQwIpb5+H3#BAQ06jG9;tR;{jDDJUd?uSlsJ#HClSw8CZ?=YO zPKGJ}kZNIZk0@d5n(fUwv+~s&< zfb;5XcQ#~(_W2An9<~AP2+avBRc#5|SX<*;z6QfgS$W_VzlYK#bqh@!Yhn?xS%7ZU zcXj$NYCcQp=UpHkVd(Zc=7?VZTIlq zjha0{x`e)pG+5;YKWfyJWY74(fZ4M4VJ zH-toN3y^)(HkE!mhM@H*pvHC4|MgV4C00QUF@Obe7GF%==jni;$5=r@LFG)K7y?ca zdvzp97!}@HCacm~&*jPrddwD;KYTaO_wWNLqCN87oX#1k3vC3f~~Bq_E_^*?dD8U71^=ar}2m%JpV4k zBGOJofUpgq_n$7eeC&CBwkxeO8$|cW0sA=r*|H|9r7di^bZcwN;)?bM05XRzcq}Hw zG}>vnnt~WiHO+}(IKsgYv=2Z+v~7pJ-11lR26>QAXRh1cLAiy4QP<0Om_(7Ao{zlx z0%{^X{uaYabO;^HPex5svi@?2#o6Ymd_Yu8j9>Z;7024EEBuk3zk>elC zH{?rO+h506aGWmZbu}6+%f{M;_fZT|KWO6n``v0GZpZmcYrP-(+CvB@b<%QLpSX?b z#EvkPl&cFVl^h(nNjz8v0G7`edf8bc8*)=?_DDPqQhJxRSzbCZrvlbg!(|to)*VuI zAaI49F5cgrn-{1rTUuHYZ`;*7`BQQt)2sj|4;7(TvVRZc&KO_Hc6{?kg4OGJe{6#pR21igjGFJr+8um-(!K6e$f^ma8X7 zB8@+ncbSv;d?Fe1Cddv4kg9W|X+>0CO_nn7 znp+7W3Mxl(kc6M9C*YO$g}V9W!rzRXDg&$s6~}+PO+7C9n3G|tl%W#ypzSX5>Y#(v zJS&eoPMjvk2O4n%e#Qm^vy=W1?(FO=uf#6xlYSuPlw7Jli0c#} z$ZyPiFpS)9zs*`UZh{CUtXGC4`HN|Jd3m=zgcZW7$ZHsUunoX|)8JHq{qdIlJR;!z zxK%s>rd&@1Cjy$av$yp=w)oT(Hbb!#Rb6IcUp-anMrkWfKZsHR0Sy!9>3haYu8e|;8uyH z@BMpovOT7{J)Dgt-3?=47#NgfogpV9YrDg(#1mLFz}v>0sWP`AKDmP!zat4LXM%0cF8rVPyL%dYXZQlBd|R62D%}m(P?$?wa~Q zz3lex?#_Gr?T3c9|Jqj+*1FegT8uh*5DVNUn#GVc7^$rPjknw)bLbYMhSG`#>>Fv0 zdX?2WAb33JD$SAXF8@~)dsBx5BU)yu5uynGuA_0^)4vmyWW1q*2z_vb4(LNCIT^a~u20xa<G%C{lFEJJtR@&&0&y!&Jj&H>JAX>^qF8uxz z#fsVSLfsbJFp1Ccdqgghk5((Iay-H>-9tiaIXG>}&+&4(*|hmE+mH2vg#+$e!Hv{KZl*nW3R!efe&#JR&=^{QAdYd{WYz z)d2uZGo&xiwH+E)Z(9>|*Gi0}C}HA($(F}8egjgQA9_6|lL5UY^*F~$F`mgsWwypy zmqYW*4R>9;WH7PdSel%XcA63&52lkH5LjijiU1HC;I%U)v?I|slLO~_8R}29GH(Z{ zzy^?5G;e1L)Gf@T=j2d21&f=z9NUMS* zF@nyyjcg$FvvJ~lACH4BG>xiYf{~tvrspQGprAm*=HG6oO4yr7UV){Pobm}kM2IJ& zrJbgnTcQt~Cmk#h|0(knj8MW?{>62S*YhKgdwj(*k>9w6zVlL}5Sx+|A-_-vV`94h z{g_mCoI2fps+^e*+w#2e(OBhA<$t8*tVw{Cw7#zSG$eaImkCfm4a5mjN-JxZ4SyfY z>%E~i?-8B{P>%`!?!qt08LxsB>zzAqo?dVZ*0z%66BVsuzjdOdrWTiAXJ=RCQS1Ws z#Py>odG*q>@ed5$5NvF@loDDQFDgYEqEGE_-0w-dUyLlxX<{p2} zKft_?)6Xb(oXH^dX6u;7N8`@zw{yNe!~XdNV0<&&RB1NeOW^>%5@8Pq(i(i)m8OA1GUQV&j;R}Qh2*ka8uLl3x1B0itT<{HFMR0z((6zE! zQGD|pD1%Wl*bS$^86htF)n&4hFyn4c`~ z!m9RWs{eHq>6Oex92l6HG5(Wr?tYrgWEJ)~7ZzsJe1CT>3nQph641-JCMzT* zBsO4CZArMI`k zl3Huo5lRP?Uv6lu!+)$<^EllRX!{g!uD5Yqo|7bURl z!O=(~{LWaRBPA^?;YcSG6>|_gnnq|P4}-z@aeWS};GHt7?VD(2*BZFODL{nD+=UQQ z+m0p^bxc`AUl=gw(OQQIQAqzxykQU5f+R>|a=FIQHf7c<#uYeE1>0+i0TL3HB4Rg~ zJ%OM$43W_8+y%N!o+L4(UlhmT7u&GV&kxDp-$*}*(x4%aCjJGr?&>B2rhLA6hC2F; za9~$ZL5x}9OZ4$QO7v=Qqa7)-|Cu> ziVwvk<}?M34lF+x67!8#&cy>}Oj`5O!oUkymEE!|)Pz5T+opc9dW4v}?*$TPgUft_ zh=eJ27*TAB30WVm<>psG?XTp1jqdAxB22yqZO{K6lYBR1V%D%cvWR}D_ZJn%7Ql^@ z5lTYg57v1Z@`OT>D-v$W@qR5q6}SJ3tj`m7_oGMj&D~jt;3@|^{dO8eQ{;u-^C%U= z(-=B&voxTqpkkrP=l)6m)s{~*n2GSec`;3btg`C}qZZI_9;;qx5pz3i{olXYpidS= z1bV@q9bu&R@tKhW?IBd5A} zEYxaX@$8}4=z>>}y*djS&x5jD6&z^w)qC=ou*Yi-A6;}6}fP4R!fTm1sJD6T?MT4Jth?TXe)3#YJL#ZmK+SZ3T~y3ruJI^VzF9Fn_sx~h2j z+kc_iuaS~O#GG=R1Fnm!sRzXxt@P%w^|??v*{NMwU9FV6yI4(WdVhLV8G3=TMrXv$ zo1Cy;QTXw)$jlskV{4&PZhBxpz(QY6bqChP=3B)kdZ>$CVb=(t)r}Yc6_y!Q+NzU0 zb?>-aw;a!zs?F!ZfR_!wLm08|NMqBiTCj(n@-FiT6L)8kVjqr(&yACHQhUl>={WB7 z7%nR3m=Tw=u@Z$bpq81k{yeMe5-aFeg%T0j#AaxP0utnT=0nSNADjD9>ON36tzcAe z3N~3~fgbZ*Hbtrr^}s>;$qEW^!^e`1^D-{}se9sCn5B z)2x(E2!IX6Ce6N= z*;Q3l|0Mo|-X^iY6oUnJjo^v%MkS+gk&z@nVzXbxUsre79E@qhy z>HVL_0ZuRt=&s!j3=BvQ>4Q#gXhTwh-8im5 zet~j|i>MW!2#S1Yxiwj>3L6%kM7xYt5hNTO)XJyjT#6rJ&jg2$-U4ZZDbnyQAm}cB zU`x=kah|I?yaC#}JQP*v2?9UhuLC+4i!My!i@588%|_CQ13DmhN{emk-$Qc?|K$NG zgLhd@1|gn1QweDR4*SycCB)||vHfqLFX5bJM#cH3IgHV{TV%;lVw-G$tWp-}aEp~w zFwfE)7Ein|<1j+P1}mVMKyz7VZ3n*zEp#9xCHIgvQV$9%1rpc1g;-ZtR}9@V<3&gdnPS6P#N!rPMqV4-w1b#+)Dig;;nM(B-ozPUjMQ4 zTt}N1!a}1`1QDjOybnTHy=?tOO5b5C8eD^2*b{B>4I8BgxPqcgwGOm(1NVtEkb~d+ zhx{@D#Y6TfHsg4aepwq(01tq-?qAT|k!H5p9WvaRTGke|?S%msB=bHvW9E-N>rCHl zbj>R50iQRA4BeLz`x<@Kw`_+^CK#3i;7Y2i`_P7P4ha3@sov#JvjlB=(Bx+ry}4!= zHId`n?Ck9G;ADWB?1~bs!2Y+P(plq;=bulrhgPBbM(K z*2Ae5w&=j)fQSWHa1w?n7?LK#!&8+FoH~Oe!1-{SP9h?!6^j71QUq$qf*Xh4YC|Bh z1#874eJOdsvUb&|j-xcjXUVLNb1z0Li{JhOLS`mX?CY*KF8}trlOU3_iyP}1_Ob{x z1jdUEZ?n)tdp{I<_%A3rIZ(0REh;8Ayb8)r)*4+BdUbzb=!>^VTsu`7;RGtPJQrl4U1y0kj zQ9cc#H7Z7^0NPRyaX7813HA9ymPWE=_U$|gWOR~J5si{0wt@i|HU<4VRM5g(n$O4x + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/apps/call-blocker/src/main/res/values/ic_launcher_background.xml b/apps/call-blocker/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/apps/call-blocker/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/call-blocker/src/main/res/values/strings.xml b/apps/call-blocker/src/main/res/values/strings.xml new file mode 100644 index 0000000..03dd6e0 --- /dev/null +++ b/apps/call-blocker/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + Блокировка звонков + Права не выданы + Выключено + Отменять любые звонки + Отменять звонки не из контактов + Режим работы + чтения контактов + распознавания находится ли номер в списке контактов или нет + Внимание! + Не выданы права для %1$s. Приложению эти права необходимы для %2$s. Пожалуйста, выдайте права, иначе приложение может работать некорректно" + просмотра номера звонящего + просмотра информации о звонке + получения уведомлений о звонках + чтение состояния телефона + сброса входящего звонка + ответа на звонок + Выдать права + Настройки + О приложении + Версия + \ No newline at end of file diff --git a/apps/call-blocker/src/main/res/values/themes.xml b/apps/call-blocker/src/main/res/values/themes.xml new file mode 100644 index 0000000..1de0704 --- /dev/null +++ b/apps/call-blocker/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/call-blocker/src/main/res/xml/backup_rules.xml b/apps/call-blocker/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/apps/call-blocker/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/apps/call-blocker/src/main/res/xml/data_extraction_rules.xml b/apps/call-blocker/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/apps/call-blocker/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/apps/experimentarium/androidApp/.gitignore b/apps/experimentarium/androidApp/.gitignore new file mode 100644 index 0000000..0265f6b --- /dev/null +++ b/apps/experimentarium/androidApp/.gitignore @@ -0,0 +1,4 @@ +keystore.properties +release.keystore +*.keystore +build \ No newline at end of file diff --git a/apps/experimentarium/androidApp/build.gradle.kts b/apps/experimentarium/androidApp/build.gradle.kts new file mode 100644 index 0000000..372f29e --- /dev/null +++ b/apps/experimentarium/androidApp/build.gradle.kts @@ -0,0 +1,41 @@ + +import com.android.build.api.dsl.ApplicationExtension + +plugins { + alias(libs.plugins.convention.android.app) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.koin) +} + +extensions.getByType().apply { + buildFeatures { buildConfig = true } + namespace = "ru.shadowsparky.experimentarium" + defaultConfig { + applicationId = namespace + minSdk = libs.versions.proj.min.get().toInt() + targetSdk = libs.versions.proj.target.get().toInt() + versionCode = 1000 + versionName = "1.0" + } +} + +koinCompiler { + compileSafety = false + unsafeDslChecks = false +} + +dependencies { + implementation(libs.activity.compose) + implementation(libs.material) + implementation(project(":apps:experimentarium:shared")) + implementation(project(":libs:video-player")) + implementation(project(":libs:ui")) + implementation(project(":libs:base")) + debugImplementation(libs.ui.tooling) + compileOnly(libs.ui.tooling.preview.android) + testImplementation(libs.koin.test) + testImplementation(libs.mockk) + testImplementation(kotlin("test-junit")) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.runner) +} diff --git a/apps/experimentarium/androidApp/src/main/AndroidManifest.xml b/apps/experimentarium/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2d9509d --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/apps/experimentarium/androidApp/src/main/ic_launcher-playstore.png b/apps/experimentarium/androidApp/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..5495ac5df9386ba075effd6db8024fda11713e44 GIT binary patch literal 12291 zcmeHtXIE1}7w&;b0O?&rFM>*u5=45DsuUHFCQVxCB27V>g3<&;r8hz8y$T3KktPHP z5PAR+=}oGFfjjYi*In!WgZttA0$JzG?3pt&d++CY_C(#%*P=O#I151#jgGeZ9S9-? zKS?19IQX~sXygcju6)x`S2cQMwSgzEWzlGQZhIk;;t|E!$fqQ4d3eZg^@TpYcsVkT zfz!cWl=wrFw3=OJh=lQ4Zs20_|N2rvN!br=MNzCeMW+w?TxFIO%J1fj1Z z!OusL#3|BY!Jj$a|26*KYW#nEHU!33aV2_dqh)%SG-*9tv9aFU%f{=cBO$FzS%G~= z`)l}9Hi*lt!6VaotQ_yL6lvkvg6rqi7DI%dG5_?a%6Y23tmtl8T2FVk_@AAhQpa0u zZ4;ht1fmJC+qvOIRxM49+h3?KkqrEO`FiQoA#fGaqR|(0+#X^d=SbzrQDWnGj(3gM zy(1SHF_*NtfL-jBqc9kGT3?^8#mMEwpbxj+sV)Bg5icV%^C&M>Ii&U2fK;0v_nSs$ z+Q!wYZmXZXIfmHx89Cim_N#6WPIbPXqft>SydLuRSC`(08*fR;GTs(Wd5#pmN|p01 zo@n$gI39pSO5kc;nr+*k*N@_l25ul5d^RR=B9=`fge3+;Bt$9h)X(8PX6c{GA9s!K zR_%n&=ciyYWCHe3-bX71oX>rM=qItlS#PwEr~CPd_;! zY=sn*)|}e8j1<~#F)s!~JXuD~#kRO=i`mAPiyRn5g&Q11vj-auvuR)x;L%bn*gTbn z+2H0>8PBX^Cz7wBE+*1{kADOe>DM@7)?3ITuOLFtt&LS=Uoxw<&k;9#n-{M%NqtTn z?KazrCk#VFDK2;GN)Uc)a>m%{JXwlg8ZdghmVo`U%nrqQ?4$*{sq-h!kh9y)LeD{` zazmCOCr0(3&6r%0L7K3NPdm|n_Q$O8%5*$5MR%=(|2SAQdngvjA)#NIz|w6b^zF}5 z_5f#_if-Xu;~Edn7YvBdM|mWXRby6xqP6T#PRRvGS<0!u_R7Hn zDyi7QsWGZ&wd>-cdwG*aFExLySz~91IKqGDl0-_hjMiwybLBty{v~Xln*Zmww^9R& zha5LUNu4#Ni$LopUv^F85^8he)8m@vOOarzDeTI@pPZ*ZdgZ9Je{)E-?6eD0NxQ9A zd=(!+Biz(;i|Q5c3<_eL8#Yj`yR!Ts1{uqI>CV@`yV<7;EpsYqgAYF)&W5y==`~oj zM=l~TvXRQbF$NQ}-6v})nO#g}kJoE3-LLrZ7$||MCx4v$r66@}=|XcOy(V9TCbA1vt|~bQ34EQbbKQsz zx~+ExS7IXL`RNzjlj_V)q)UZCwo*MS{)n1C&Eo$y?tZWHS{K9%SOojs^424ln)@AR ziFQ?DP72}pCTii-)KtGX)rY`tBp2Dxaa&%1$o6(SolK^@|5bUv^#?L!Dv?IsnR338 zFF5^7bHug#`Q^$A4j*fI@xLnPA1$*OD>bX>#8X3N+q(nG>7c!A;;}zc2<0(1 z(q@f<6m~%zNQcp0;}Itbe>L9`lis}5I)y(2nGKd(Hg|I!4`*z5uvvdh4-L;F*^VvW zu=K?l=i7XK8m7a8>q)+nU39Ne(vtyJt>gdQm6r>bU|~`ew@8Yiymxp=M-%!YUpJL; zB9N3;ZkcDe+TkV$ChHx(W0p>?U1bI0xs58`q<^o&=6HRNIOTjI7kqP@>P_q2%yvQS zYECDyk@2}~CC${)B9{i^3jS>yObCpIqYcf?M2Zb#n_3@zk6@GRfsS(!*nrd9(*=2x zV;q$}D|h^*<|q+Rd<)2n`RY-OVNtxX%RY|TNI{ z*e@&a5bv+o`0ytwllnRAZXfVJKN%8qh~lrWOs^DnD+(891Gg0YndZ(TpaH_sTEziF zPN4_I$RtF?Dl!)g&B+u;WCtJhj6S!JvQ@7uPczwQ2Z^LI2DakBe|y@uwAOj}c@O#T zmoDE&Ej(#Pbk5y=Su$$83(-KOQy$Ht)$F7!imUGrnII)YnnKp;BMRZ-UsIlKcs%u^ zXgD-Vr~KDuj5B^7E}#)FS7zNdgQrFd7l-Zk%U`+04F)VjWJ|jyHJ{&Rrh!fh?7HIo zNErR+DTD#QxN1O#wm=*glQf8ZiFUD&ru`cShfedX`PnReZrg8hDenzl-v}gqL|D#R zVgA+mk{ttl9s|w~CPUV{4+K|F;Qy%3I8IjsZaVg>eNXnVS_j7EFmhUI=_B?di=*_T zsY$qdOuf!jzWYeMOM2ti8Yjf=QL7Qm!!n!!=QEtIC%g7s`5g&5dv6)r&Jnm?6-Fsg z>a#YAnbOhm4_SkYbBdUhnL8L@TzVGXU4N0rd;R%1HJ8Hp-X?xZVYg3e@iRF$vdp|r z(&V031jrRtvOnJ#>H-XgVbJ2R9rKQ#-+6iR{3PjQky0qbMqhE?s9AJU&^^8W_EfR^ zX#MGl>|W~L#NPRRv;EZliJ%~ax}DYR>tn)m7MxFoRm(IcbO{B~KWL|sNy*iSfeXG8 z&ov(CjOX?Q&}7j=Gk2LSduB_?u9I5IWBS@I&cr$u#NhE~3+bM%&O`?u&efo5M!GUn zNojBf3AUWyNpmfiFi&s)M*C(zO7Q}X>zt>6;q;*ujm$}kB>|eNbXLbby4JNNn6+1$bg$Lym zHro##_$EiN2meMLS5&;rjQb*R71*FWoYqZEQXMD18R;gI_Byc3mXiC8g`6QV0o+ zi(2N5_~I{?3kwH`+>+7wup3@(eJE^Fj@(@uXecQ0+%Xej{V`lxQzKHFI zkkb=KaK1Jj7bx{cWWgdV>JDZ>ze9Pchp z|M3E*+KSqNp^fUWe$!<<`{uz57F@Z@xp5{0wEk*yXtZd((CSuh^EV-btc(Jt`e4x& zL>Vnl`wO}T|9Y+cYJtQiRCb*QesadXFsLXfEx9dRI$yeef8=>{nXS5X$)rP%`#KE` zba12X+kVVgrp=e=P1^W}xuaLq2`vD`br55@!r$%ogXgt{VnGSpc|ngCUTLLiaXtRJ zX}96Jv3v0f+j8RMT16YzcztEA6dw#u{Xhoxn>fF>tln78-rJdEgGIig;!LAv5TQAM z`sS9EH3)ty6Ey`vaVYhHL%+SXdR4z3dht=tX-ZP=FoBo*Ob9N*RiN zYwpccGBzshC;dFHDMYib2!UBWAVEUil6ciJYux5nrn^2%2d!dcA{O<13a5W}^Yt_3 z4lpj$eOh0lJCi!cb%ZkQtsZ?RlRr4nEKysHVmzFsUbA=d@KBMY$<@N(>~f+yAcY%6lZC+!47pYL5(QNShMNJYuR0gFMXg(> z6^@qkUg6z}wS4!-t?v$`kf^~z-9VlK;6ufR^sMkv$A|pudk8b>Gtw?2m9ITt@oA8r z2C3!7&ZHGt|3SPaBFGYxih4U^;S=Zerc**??wGh%V=z213=U1?vsEKKXB5OKBi;fc zJLR+wntIU6E+ljZ8#5&0P`az36%xf&<{(z0Lxf5TS&d(-_+cTQDW9g@9--{m^_PxK z2MF9OpFjiF^yR7*GocJX4!D>qWTpxW<-k&z)J~?z^D?OA#$*ND7A2Ma_cTwlssC}G zMAsR?3u@ZGi%hF>%T&3Juj>QDU3=%=cqly-5(F=>J710im>I^|5zs<3SJ;qVz~+KF z1?3e0<;C-~;{AFVx><>@tjZ?KEM%;*Z)&UGE-P9z3RLkJgm}gPT4Y!WB&GNzTP%&^ zNU#7qD&2B3XfQ|h;CzsOHDCC9F>_ayy8JZQ!x%VpNy2~go_DWk!x=#lHSN_eb|Kmq z5YYRt2OCqE!rOU$MAVv6OTpV{0O)d`Q+7}%XaO+6`@X~rHy?>4BqR*A1_fH!sT6~; zFN~I3x;_U4-lJ(HAN^H>q9@46qF4flNH_~-!BVEGq07>JKdfIp*ZyaLCUY zThVqhX;gIQg18sGTH@gu z@#dl}F(12Tcaae^VRM&6xr<$&fpX|PrS41-bWI7b1{u^gNaU02o(hm&*cpW7Dwv?Tpz3G4d?2-$T zs71D6GXQrWlh;CXgZAUCLCxnC18Rdl#j_A7c!6{S$1pT06w1PSv|tGj0;ZSZq+Gs4A^_OWQ)lBgpj@O8flPD;z1qQ>`yJ7#iZz!bX6)3@S=eRJ0tV0gt_(lK@&2$R3Q_VL+6Y=zn&b1G1@-$zr|3`!1 zEgO=)|CM4Yq9z}#&6S5}67q3wp0pTKS@A3sbboQoIz*Z~Xz!KmT4{~xa5jBn0hZ%^ zqqcYAqTp958kJZ#05tZtW@j^kk5)=o3@CJBP+~(FNE0&kx1734;yPCDTOj=&C?&`b zn`Ui)!RnvCCN&Hn@2w3(Le7r8 zNurM1bDuK~gu(^*nmBSy-gx4haG9G4T_|WMl6B~uq&)?Bh?;h8uIvUNl@e3+?%oB8 z3%p>!cOQ^iaU9MS>Ay0-QtDtc@&#>13U7ywcVj`!iPbpLK%?J$4%{HCybH8mRRgeHaq~ypa{- z8xWSTsI(x82vw*BxFXQPrcFLY@}hidpr^X9y`@(N%VN$m+Oy2$yx{_h z8qv%WqUNp*>TA;tj>RSMo#b#I%lIL^nCJn@5&x*PcEIP_j;G2uEb zB{PgS{5LBmB8^E}hR+-cF{Fb7c?kgT9105FuVX0vk?BYj76?GX1TRcBz1makbfGLG z7VPh!?F-IMHRW*n>U5Nch)H>dygQ#dFmTHxaHDSQ;kHnCLW{b$Jy>;FCVH@I2`e_-w-v59#y1&77#Q<*f{tQq4a@U*>YtMZA z{ROwKqobn@Jrgi)wl`{I#hnp=gf1b0u5|pVP5u!jZ}@^(2w}~7!_Zip6Lc|a38;H~ zFyZugA)a1MP5ak;2L^}}o=fKM;WR2|myMIGgZAGR0YS-TkP)0&J^gFn&qx8y78vFO zhQ-|{k;CtGn7ID%hbPeI$sRnV607tUdl(PrNd}_LeeOTPw{lc$Dv5pFgF}!B`y|A7 zY(5AVkOdY${)nTZzEdr{I_|qs4p3me`&4~CAiwz_CpKe%V(8BGWLq_`W*|x^tGnd2 z&U&82y&6Y7Z@ll=L{*u@X58^?-sG*9g?EjlGHy?MUp6kbS0d~gijgfa(x`~(rXhRo zJ^9rU_IV``>9yjht!Hnb@GHE|F2+aNOxW$~f1eYjBo^zO%v}dnC zLX&-OcuJ?ZmjHzD5F4ALjB1D8v~-WAo|7T0deW5dr({tJM*t%ghXKwYs_H2)ItB4< z8A_cOC^whb)x@06(oPSLLO;!y2nW}dt&r2^GSNfNBMxAn?IUvnafBRy%v@UP(YB4e5dSd>NMZ$bW9=5?J5Y6HBl_4J> z4?hu2XOJBh8<(a>_DODKmOl7IFlH(RqDgu!{(eY#dcNnpBwUXnf=Y*;=@*Kcdt z`6m3>@5p_zFE!t?+0T#9uOU7`JWs-ZtL1RgKV*Rnm*HX{-5sQTJRv0NW0q7vTKxi8 z0`St7d&!GD+Y?(stEbQscsR3WiMgGk7+9Yf*$WWTxW3w#$j-FRj7}@7I~y$rldkow zvP_lPjVDKI8yqi7^GLe+GW`N~dW7_%7%z~*1{zTKXC~G`I70MJtl)iosBtX{i>_x( z{Dz8kF8#Ux`83`~FtkqlpvK#KtlW~njpMu%5;EKSnQWa#l!L^VpWFn;ZXMqZoWQp& zS3%lqUVRvd!}>202Xm`%rDjs{qeDFa7_JAvz>?6p)p%kP*8{FlRX>OGP=OF=+Z;Sg zCuWXY2eQM>sRpm2ZC}wryocJISD?^z%$Mf#UWneu?8_les!pJJ(FHRxE`taS8q~Yc z)PEPCVzkY!s8iPC{67n?4W=tDx-Tc+Iz1sgz96OZ7@6<$+N>$_&k;W^pOx=n1HNIo ztP~(KIhiw#Bs6HHzI2&^dmxO4sV-XFND^_jdh$!-a7{O*5Ac%9Et{HcLNB8uOO#Igtpuw{{y6p^y_qrNOAO&n;;Hv*!i#Mm*hKF zz2Vb{KBENVm5u7Wb2+Ilj@01t#ti^L^p^TFWvMFZkWiomC}uT(dn5nxNervv(0ygE zcQsIxSucSjd_f@j-b0-%#jR{6bAiZYrww!7Ti(|WV&OJ)wS$^KME}(G%K&?);C6*y zk_CuCKPOO$Yba`Gk&dYTyBR?T6JZ1M7D#_r+JTaYZ}Xv`n-97K2$eRs{;=wW4k#U$ zPSm@nAG3U@qhu0%u=0FC4sjiE(<>L2v*i( zlw!c%gSOn-hDX2d105tCy~HaZkL7m&J@e9j23=^*ZOjNH@r#Gs$kcwbB$oJKJzdLz zgKJlRXqJTSZ%oN-Kc?u>2ds~+_w7%knm*g+++@LyUs}4%D2QW~8I+%HlyjdZxt2F} z>ZV`C5Yq0eR=E2q)A3G3b>8AAax`w2%E)+=4$&B_xANBL*;*-XpiQCVnRV0%tcQ=k0=6>+k!ouUNODp&ei+QA`_bGwa%UNRen8yojw6TQKF%%S>r{yHpw9J=v?9aFaR}p4gh4UOrF(kgt zc<_a?${gy}fNd|KJjU3s|2WY0P5~W=5!Mi>-sqfPie4U2tzRIKL1jlJl)eXq#PMK# zVzV-u1`%rWElJc>&S!Zp*mx$F_kF!7)xpTOPt<5Y^C8<+2v zqQ*w!g+U)=m#f3@HKob(a75_o>S$TI?0h6Y>9dHh%_J$34$+5OT0*i0RrfpkMv4sa zwk+&OsM!FJ9=*d&Jiem$=8O!~#O1TuxRmykj0z4vJ2B8odac~md6yaL(eqjQz0mU- z^rC8-vaEpnDKkwB^zWzbHnN4n(Mp%wGfxBi6%VKFSVhejg59goa*#kn)S6vru^F!X z_;GhRsy}{r0%P>6l>|l9TIWJxq_FVy=*^eES4%3UD?~Snn#ZUzrw6WU`{hb7cd;xO z)f8JE?j~4bGb$Qq{jFpm^DF7rr-AY1$2L9sK7foByK^ zinvmjF@nOvsW(yh0s{9SVfE9zJ1pmI^`7oU&%VjPvn-eGtw8JXGB|K)8g(;C#H;N~ zEwQdU`F47GKuz24VK?uK6yR$g+l9+k_B%Ev>!_ZvG6Fq<4S(IE;d&K z1_LHoupqTB2S+&HyrPDUQJx-9)z;rxuNmlEjW7k$oVanxU79DfjImt%f4h`Vz6VFl zY*&-qdQrNjtiYGFukd^BSyk#8-=+GyUf#+e0`&mdgd@F+#5j+r;LJ%S;I+CSQC8Ll zsf?m|2iV|;n{0-k9{obw@C5cd7i2mr{aIi-EYXz*Y(Sb=621gr(VP^0;%k&h%h76y zpWBCKAeyHb8YoI{pYP7fvr`4R0bemhH6slLD6D^FGO(YRb<>P-ng$BS!MaY<5JsWt zAY|0ciL5?5VfwvQOTd9us86e{?!ZZd`&81ROggqor7QfJ(q^g!umND@PnIfKB5~{k zs&{I=FiO<455W|PSeUYgLL ziAwH2r13{!?e;s63Hi!q66>9Z1$V*IlqTObcztq$k>=qSVpYPg?*moK{th^#i{MU0 zo?ZLDtRRE^6LlT4TKy!xXn zUPjcSp4yF@*H+U`-_F2JyrS^H=vlFb|FQN#p53#g=Z8@;G@)5EOCOYrFz;S)xd`!O zK8-!vM3oJcr~B=>_Tk0Ua=WchcG#qz{NC>7rZXxFIwc&|10$P$uQIfHg$z<9I|$#H zdeh-5p20)MEqG4-hsn=q<+_1zH?0)hVA~EqbTyFG3l5mpsqj()MHAT6b?rA;{(^wA z(oTD3y-F8dAaV_xA8Z~ZOlx#S0+>Eam}KgWfvd=@Z{LsI&NXxbyDPc%X*Vv&E80qv z5l$EyYQp=^JQj+7m{=R7|9KXa`~2LV`(9O%{xBJEywRZN=qQ*hc|CTMOuHamq5aKo zxJpD`vmZWF_YQZ9g=(9w&$BmKAGJ03bF7WQvP%(_o{}nOv%7~|jES^Z!;2t$RgNu6 zI~savsz|>#zoWN;@dccY@~>s$(GzefX4bF*lB@1_gTGY-U@^or|T$7ugy?f|+7F)Gx$3$?3=(~?cD`E5+0pV9TE zCp{}CD8mmoSOV9zo782XRWztQv5Yqgq@kiP4}X43T`Og-N6C%G?h!Gnd=kAH#e{`V&4)N%oup-W=s6@fmF8B2 zzyC#EKqTKTJNBVWq4LR+oJ9`~=n1gc4h{5#0Ap_w0Wzl8OLSgHQXt~UZV>|r?_ zS0C+igNM_aU5uQDi5Ix zASh@2Sc&`c?Fq<$2Pv=5M4_O&pw^iM)FpHrA8`zDSL$(Rsa4CQB6Y_dK;)f=^CwKM zHpxd5hjAcYz=)UCNr;5ofXZDy&@jU|HNdRjOUjKd1AA4TkdLS%h_?AEVWj`htMFs! z{w%c2i3vJBpjj6HODPb$w>CDw6*w<;KodHHod&g5G%axsU%H993E%{aTygh>4rtIH zy?*>t0>~LaH(Mg!i87_*IiP{oPJuQMB^e-*FhfP}U*!S$ZB9EYo&5fR?gyW#_9Zq> zQ4Zc=R>}_H!+(KNH<=$bc+TiJ6MXDmW-7<^zHA z;2g6PFiX77wAJb3|DQ2|w(1nG>czSSx0yqSg=K=q;pz;W&*o#nWfr1*f`WFsffP(s z$4TYE>?xx70QHjw)ek>9gDQq8xJs}1&KU?Ckx$WCXMP_{dE(m*-M-e73>GDW3n*w> z;^cE9<5ay`sp+TSb9nz*X(HucC(cVCdimUYyW96V>PeQT8Y=$r`4_|&x7E9NIAi87 zC()omSwRmdf~GJqAMGrSt{iZ4uYG;Is$EgHLrsJ|ln5ok^+K^qDdIW3m}mDYPREP6 z7j6TH@?upN7u7liainiP>*LtcdyPZ??I!EJm;$aJN>}p#>o#2PZXpHg8i(R6R8?+J zEgT)eW_50y0Zd-|4Y;;r<~Bwe=>QY=S-xcmbX0U45Am~z_m(h~w(ZmHMj~IUNe1oM zI%3%9{yX5C%f{b`XX(EK&a?zhnMgFrKP9fAq&byJ*ZXVH0LFloMC%i+?eNJ7b}-O@ zrKC@c0b(2qeB^%#xL jJpI4h;s3oWPvEwlpnn=nx1|Q01JcpZS1(tydHR0<(!=GM literal 0 HcmV?d00001 diff --git a/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/App.kt b/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/App.kt new file mode 100644 index 0000000..be32409 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/App.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.experimentarium + +import android.app.Application +import org.koin.android.ext.koin.androidContext +import org.koin.core.annotation.KoinApplication +import org.koin.plugin.module.dsl.startKoin + +@KoinApplication +object Experimentarium + +class App : Application() { + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@App) + } + } +} diff --git a/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/MainActivity.kt b/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/MainActivity.kt new file mode 100644 index 0000000..f34e550 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/MainActivity.kt @@ -0,0 +1,15 @@ +package ru.shadowsparky.experimentarium + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import ru.shadowsparky.experimentarium.shared.TestPlayer + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TestPlayer() + } + } +} diff --git a/apps/experimentarium/androidApp/src/main/res/drawable/ic_launcher_foreground.xml b/apps/experimentarium/androidApp/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..336f278 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4d409ae75722164cfb9e22e72da4c5fa290806c9 GIT binary patch literal 764 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 literal 0 HcmV?d00001 diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..d0b9b9088235be95696ab7e0f7da748476353171 GIT binary patch literal 554 zcmV+_0@eLeNk&E@0ssJ4MM6+kP&iB#0ssInFTe{BRmi!v+q9i|ZM1jX(zY2^wo|3H zZFEllz*QHcwQaMqjkWd)HrCuxJLg&vkOKiA*u=JNV~X^f^~w#5sjR2Zf!j!m0bK? zYtp{?5Ba9sHbNjWoh-sfJ;b^o&?RwNeS}%r0}Sy&05cTW4!3!bXgj&Q@sM@dhx(Cl zZb(<f#}G8!m&5%oB#8q2 zvc~<=XSqwmUJ(GM2&7>nfNL!c3+2N#SNOk6{|CSCcmCKStV!qAO@(P|=j)3jjQ=U+ zWYvn+qP+2du?9ZHX}SUvu)eTY<__47u(i# zeiT33Z=o}gBuPe0RZx)FDi0 zgxGPv|7KHG;2pg19xJjjf8n_Q;Y9f56nhuY%xs0ThU6@Oz3{@m0B2Ie(E+8yzZZfP z*p*lBy3@<7#bVrnpegrItU(EP^56ro?WcRFTKr&l6SuN3YcqnR!5Cfq$M-x>%t2yu z$tWAJFgN3F0h#{sVWaix(!@fA=J6}BC&1-=$VKeTVz9$S*@=tzfUDr$nMUGLnHoQ@ zAoS3osT10J{vHdnILiTOT^6%2@1nc+f`86}NGL5m*D=hQECSHfg*lx;G;sebaEabY zszlrf-eeU3Pp-`CwA|0uaO|3NqHhc9Z{0k>iU1y6ftH)eclVTmMPF7nKD&68l>j{3 zei;{2U!JTZdav5WcPs|r!{QrU+}lX>LP_>N2P}eM{Dn=4wE-*^;$tM- z#lmQ7P9*#(ivn22CG3j2S|I@}biwUEWAhM;1K6HLH~EFI)^o{wIEQ&SlJ7&pvEYY` z;05S`_J$M1<`$GHn_u}sqoB3a05FIY+F1Y zmOQKuXBK=wDY_zClsN)T0C8_`YM_iX?Ar)Ug=e-yT;Pmjkl)uiJ8|r{ZYZM19cNgD z;FfVS{}e*`rJ>TXf0s=*UwZC13~4X#t%Am9V2z6sPaVwB)x`P39VqzZQVrC;mbhKz zovTnlM)w^756Zu|Rs~7?q_#i40Ttjq?&!Z$5J2Szm&+hR3geAAla_ed&oRk-;}(A6r3puim#chq@vO zFSY&sxqt^L1(ZSk>MeT?K4?g2I-C@9X@EBQ6vaF4PyXY63h|H#$#E-53B96uBLn~r C3TPhy literal 0 HcmV?d00001 diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..27e1836b8330d52c5e240847001514edd6ea7619 GIT binary patch literal 1012 zcmVxY7ZQHhO9Q*u8no<9zNms+LJzI{z%P4OP>^J|%bplZ|#PclrV#%vj(Oi_)Ue)Y!faGWU3X#PGk2=XZesK( zm;?`1_EmAWpUXQXrNQHt2A~>l7td{K0Gf%@=zR?V_41RjvZMC<#m{nMT^8nh0cz-dI?-9G0J;!I#1!nd z*|}C3uZ=o@Y&L<*x7eeA;fO8(RK;01t#)exsJZtrPdH6SRZv8irB7ns(+p9*n_je2 zbwDGvR1HZrt&9Ls#y91F@&>JNhv1=%ETw(Dn2#Mw5~o zGy{q@ccT(5ineF~l+Cwt8kFr)3n=XKOudIR07^IXFy%hb5-4szq}Tz&KzJ3^#F4u& z8twqKE>%rPedqer*B;W7tX@)^pR1mbYA&PHdzymKYZP!qpJ`1_YB{2UtA$Wg?^9_S z2yN+S91$_+kaI4>5z$Xt2%%{ddO{sxlN|8}x|W>laDTKutd2~bsUWr)HB>;Gm2gDg=}1l|-{FY-CQxAwsVbL%N^3daHx9U8Um&@!`~Y`8UkP1G zkrSG!Nbweo8Xp0TKcdEkbh?NVMGNS(kW*p5wsdM|KNWsfolYiK;ai|}rovvA(Aukz zg#U!I`-_wZh7x7>*GfU=7&zpOXQyoDlk*WqGe|m@p>6E^o%?S+eEsqJZ{L6QTIc?oPuXn;qZiQD zu^b5ki3pL|r6$Ed^Vu1TOV<{!YHobAyJMU-! literal 0 HcmV?d00001 diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..4a0e201d514e03a04e10d84e286c69e649a50cdb GIT binary patch literal 2888 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 literal 0 HcmV?d00001 diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..d24046251429c1bd6f7d1ce1181d833f455b7583 GIT binary patch literal 1522 zcmVAvt(G^eYNty^a zwyhm|zE{6(JGN~M!A_OR8i7+KbID4X-lgsI&WAezl&Ebx9!YPUwr$(CZQHhO?%XTe zHpjMYyX~a^6_fvcXWM*Af7hRi=>G)BUuuaECFc5t?;xLeAuJZL?~qtT$Usp_Qo`l4 zJQ9)E5vjy9M!R%FpY%_EE-zKO@uC<~>VQfe6Fqi%_f5RnT)pA)i>(Q5eeYPL~)6EVoQ34)eGR!M$` zOH&|;i2|&=M^G{^bw6-xAABD(;DeN`>W-jPUNVjQPgA`he&vV;XR`E{hSGScBRu=w zPy)7(RqikcKT)7%ZpxZ{A#bXw-!2S5)E4P~exA!K>mUqd(C;hdXnBLbRA*v$8AnSS zf2&~N35I{AxVx=nmE$26;S*I$Zhb1y_5dOE85ZZ_2>vYs^(6q+|6%ZNO#;wYp!q7- z3I?lPs(AwalAiz+{K|4)7a{<8eijIuRTvYpm_3fv>lKM^*H>kJ)ncz@QPpuo!fP%9 z(E6UG650{~*UO4b=%w-RHl9k@wj>QvOh0@e_c? z@uK0Sj0B+W*zzt!J0UivJ^{$^f+8Ens4P8Fk?#8<7AyR%c*hG_tl&}MzV1ZU@9<2r zb=inx*0o~&+s#WnA9wplruQom--=HK3cr|#IOl%RktTFJJMngo*s_EVWg+fQ4?3cp z%S!wc&Pwu54w5kY+j4xFpJdGUxfHjWla!5b3NgklhCND#mxE+HP0OxMZI>kyYB^3^ zk}(8ckl;!QGT!Kw+X^mi%;kW@4gV={UTtg)#V_*<%_HOWeo-_}wSlpWCl$@#8W_Wz z5W6pujQKAry7ORchNAm?z*vUQx!vhY#%o{FJB$`El=cO^EA@<_=N)FRk;$H6z3&S| zDc?7G&NCCPS9sYy%yhb|_j&Fp<@{LhUS|{NZnJvFZ>GS{ddIv`dVEyxmqKQIZ?YOn zW?gIUYu&C((XMq%XKjKR4B_;30aHl3Q{(PrPt;Y6wEK#|UtQZvf@t7!;mn^yRT9YXT^2&kM7~y`uuTfTb(aJX* zIe@1MYG9%n?$gV^pcGch(8@EE)?}o`07!p5oZYjY-{(qZ#MAN*9DV7M2WM{shyjQJ z9z-`zK7|)~TzFb0T*Bg6L=y-T;IMRyAiK132VbF;P^o2&!7L2F<095>N!KVW^#%mA zfV*QF1uJGxygb5ZXb?F3hQ~PYx?l+f9n)~b01C+<2tWE2Dph8&?rnNEexic#F4l)?J2Q=tTT0cOpJ4GP%cMFh!4InDFh z6uN%aC_75wg{=rIB-1v44yFih_z*xaLCDpU#KI@A$IyX+Kr(M_0O7+36U-hguI_np Y>e~RsiX*KBO#%j!#9DE%Wl;W93KMGRKmY&$ literal 0 HcmV?d00001 diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b62cf810b5b81a729c4aea39060b5718bf05f884 GIT binary patch literal 4518 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 E2Pw$wpa1{> literal 0 HcmV?d00001 diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..805c2810b4666769fffb2fc1bae9a9d08801906d GIT binary patch literal 2066 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 literal 0 HcmV?d00001 diff --git a/apps/experimentarium/androidApp/src/main/res/values/ic_launcher_background.xml b/apps/experimentarium/androidApp/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/experimentarium/androidApp/src/main/res/values/strings.xml b/apps/experimentarium/androidApp/src/main/res/values/strings.xml new file mode 100644 index 0000000..13c85a8 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Experimentarium + \ No newline at end of file diff --git a/apps/experimentarium/shared/.gitignore b/apps/experimentarium/shared/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/apps/experimentarium/shared/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/apps/experimentarium/shared/build.gradle.kts b/apps/experimentarium/shared/build.gradle.kts new file mode 100644 index 0000000..bdb2e8b --- /dev/null +++ b/apps/experimentarium/shared/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) +} +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":libs:base")) + implementation(project(":libs:video-player")) + } + androidMain.dependencies { + } + } + android { + namespace = "ru.shadowsparky.experimentarium.shared" + } +} diff --git a/apps/experimentarium/shared/src/commonMain/kotlin/ru/shadowsparky/experimentarium/shared/Test.kt b/apps/experimentarium/shared/src/commonMain/kotlin/ru/shadowsparky/experimentarium/shared/Test.kt new file mode 100644 index 0000000..7edff23 --- /dev/null +++ b/apps/experimentarium/shared/src/commonMain/kotlin/ru/shadowsparky/experimentarium/shared/Test.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.experimentarium.shared + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import ru.shadowsparky.koin +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.ui.VideoPlayer + +private val controller = koin.get() + +@Composable +fun TestPlayer() { + VideoPlayer(controller, modifier = Modifier.fillMaxSize()) + LaunchedEffect(controller) { + controller.load("https://samplelib.com/mp4/sample-5s.mp4") + controller.play() + } +} diff --git a/apps/experimentarium/web/.gitignore b/apps/experimentarium/web/.gitignore new file mode 100644 index 0000000..51ebf63 --- /dev/null +++ b/apps/experimentarium/web/.gitignore @@ -0,0 +1,4 @@ +build +videobox.db +cache +bin diff --git a/apps/experimentarium/web/build.gradle.kts b/apps/experimentarium/web/build.gradle.kts new file mode 100644 index 0000000..0bcd67e --- /dev/null +++ b/apps/experimentarium/web/build.gradle.kts @@ -0,0 +1,32 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.koin) +} + +kotlin { + + sourceSets { + val wasmJsMain by getting { + dependencies { + implementation(project(":libs:base")) + implementation(project(":libs:ui")) + implementation(project(":libs:video-player")) + implementation(project(":apps:experimentarium:shared")) + } + } + } + + wasmJs { + browser { + commonWebpackConfig { + outputFileName = "composeApp.js" + } + } + binaries.executable() + } +} diff --git a/apps/experimentarium/web/src/wasmJsMain/kotlin/ru/shadowsparky/experimentarium/Main.kt b/apps/experimentarium/web/src/wasmJsMain/kotlin/ru/shadowsparky/experimentarium/Main.kt new file mode 100644 index 0000000..b0b7aa8 --- /dev/null +++ b/apps/experimentarium/web/src/wasmJsMain/kotlin/ru/shadowsparky/experimentarium/Main.kt @@ -0,0 +1,26 @@ +package ru.shadowsparky.experimentarium + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.doOnResume +import kotlinx.browser.document +import org.koin.core.context.startKoin +import org.koin.ksp.generated.module +import org.w3c.dom.HTMLElement +import ru.shadowsparky.CommonModule +import ru.shadowsparky.experimentarium.shared.TestPlayer +import ru.shadowsparky.ui.hideElementById +import ru.shadowsparky.ui.hookPageVisibility + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + val lifecycle = LifecycleRegistry() + startKoin { modules(CommonModule().module) } + val composeRoot = document.getElementById("compose-root") as HTMLElement + hookPageVisibility(lifecycle) + lifecycle.doOnResume { hideElementById("loader") } + ComposeViewport(viewportContainer = composeRoot) { + TestPlayer() + } +} diff --git a/apps/experimentarium/web/src/wasmJsMain/resources/index.html b/apps/experimentarium/web/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..450b55d --- /dev/null +++ b/apps/experimentarium/web/src/wasmJsMain/resources/index.html @@ -0,0 +1,51 @@ + + + + + Test Layers + + + + +
+
+
+
+ +
+
+ + diff --git a/apps/vbox/backend/.gitignore b/apps/vbox/backend/.gitignore new file mode 100644 index 0000000..dd86a94 --- /dev/null +++ b/apps/vbox/backend/.gitignore @@ -0,0 +1,4 @@ +/build +/videobox +/bin +back.env \ No newline at end of file diff --git a/apps/vbox/backend/Dockerfile b/apps/vbox/backend/Dockerfile new file mode 100644 index 0000000..d8d5e16 --- /dev/null +++ b/apps/vbox/backend/Dockerfile @@ -0,0 +1,6 @@ +FROM bellsoft/liberica-openjdk-alpine:21 + +WORKDIR /usr/local/app +COPY ./build/libs/server-fat-1.0.jar ./server-fat-1.0.jar +EXPOSE 8181 +CMD ["java", "-jar", "server-fat-1.0.jar"] diff --git a/apps/vbox/backend/build.gradle.kts b/apps/vbox/backend/build.gradle.kts new file mode 100644 index 0000000..6673620 --- /dev/null +++ b/apps/vbox/backend/build.gradle.kts @@ -0,0 +1,111 @@ +plugins { + alias(libs.plugins.convention.backend.app) + alias(libs.plugins.ktor) + alias(libs.plugins.sqldelight) + alias(libs.plugins.serialization) + alias(libs.plugins.convention.koin) +} + +sqldelight { + databases { + create("AppDatabase") { + packageName.set("ru.shadowsparky.vbox.backend") + dialect("app.cash.sqldelight:postgresql-dialect:2.0.2") + } + } +} + +dependencies { + implementation(project(":apps:vbox:common")) + implementation(project(":libs:backend-base")) + implementation(project(":libs:base")) + implementation(project(":libs:http-client")) + + implementation(libs.ktor.server.core.jvm) + implementation(libs.ktor.server.host.common.jvm) + implementation(libs.ktor.server.status.pages.jvm) + implementation(libs.ktor.server.content.negotiation.jvm) + implementation(libs.ktor.serialization.kotlinx.json.jvm) + implementation(libs.ktor.server.netty.jvm) + implementation(libs.ktor.server.auth) + implementation(libs.ktor.server.auth.jwt) + implementation(libs.ktor.server.websockets.jvm) + implementation(libs.ktor.server.cors) + + implementation(libs.androidx.datastore.preferences.core) + implementation(libs.kxml2) + implementation(libs.jsoup) + implementation(libs.jdbc.driver) + implementation(libs.postgresql) + implementation(libs.coroutines.extensions) + + testImplementation(libs.koin.test) + testImplementation(libs.mockk) + testImplementation(libs.junit) +} + +java { + sourceCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get()) +} + +group = "ru.shadowsparky.vbox" +version = "1.0" + +val mainClassName = "ru.shadowsparky.vbox.backend.MainKt" + +application { + mainClass.set(mainClassName) + + val isDevelopment: Boolean = project.ext.has("development") + applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") +} + +tasks { + register("fatJar", Jar::class) { + archiveBaseName = "server-fat" + manifest { + attributes["Implementation-Title"] = "Videobox Server" + attributes["Implementation-Version"] = version + attributes["Main-Class"] = mainClassName + } + from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) + duplicatesStrategy = DuplicatesStrategy.WARN + with(jar.get() as CopySpec) + } + + "build" { + dependsOn("fatJar") + } + + register("deployRemote") { + dependsOn("fatJar") + commandLine( + getCommandLine( + "docker context use remote", + "docker build . -t videobox-server", + "docker context use default" + ) + ) + notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache") + } + + register("deployLocal") { + dependsOn("fatJar") + commandLine( + getCommandLine( + "docker compose down", + "docker compose up -d --build --force-recreate" + ) + ) + notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache") + } +} +private fun getCommandLine(vararg commands: String): List { + val osName = System.getProperty("os.name").lowercase() + return if (osName.contains("win")) { + listOf("cmd", "/c") + commands.joinToString(" && ") + } else { + listOf("sh", "-c") + commands.joinToString(" && ") + } +} diff --git a/apps/vbox/backend/docker-compose.yml b/apps/vbox/backend/docker-compose.yml new file mode 100644 index 0000000..ff2f4b2 --- /dev/null +++ b/apps/vbox/backend/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3' + +services: + postgres: + image: postgres:16-alpine + restart: always + env_file: + - postgres.env + ports: + - "5432:5432" + networks: + - serv-network + volumes: + - pgdata:/var/lib/postgresql/data + - ./src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq:/docker-entrypoint-initdb.d/init.sql:ro + + videobox: + container_name: videobox + build: + context: . + dockerfile: Dockerfile + env_file: + - postgres.env + - back.env + ports: + - 8181:8181 + restart: always + volumes: + - ./videobox/cache/:/usr/local/app/cache:rw + networks: + - serv-network + depends_on: + - postgres + +networks: + serv-network: + driver: bridge +volumes: + pgdata: diff --git a/apps/vbox/backend/postgres.env b/apps/vbox/backend/postgres.env new file mode 100644 index 0000000..19a03ff --- /dev/null +++ b/apps/vbox/backend/postgres.env @@ -0,0 +1,8 @@ +POSTGRES_DB=videobox +POSTGRES_USER=login +POSTGRES_PASSWORD=password + +VIDEOBOX_SERVER_URI=postgres +VIDEOBOX_SECRET=144b5446caea8d7d1a33cc87b0447f3be608f0b12bef3d130ec37264375df912468411e37b432299d7f037db81f099b4b3572d4898fa6cc154e6931d7d12f78f + +ALLOW_REGISTRATION=1 diff --git a/apps/vbox/backend/readme.md b/apps/vbox/backend/readme.md new file mode 100644 index 0000000..170bc66 --- /dev/null +++ b/apps/vbox/backend/readme.md @@ -0,0 +1,9 @@ +# Videobox Server + +## Deploy + +### Remote + +Сначала нужно добавить remote context +`docker context create remote --docker "host=ssh://root@localhost"`. +Далее публикация происходит таской gradle: `gradle :apps:vbox:backend:deployRemote` diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt new file mode 100644 index 0000000..04ab935 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt @@ -0,0 +1,35 @@ +package ru.shadowsparky.vbox.backend + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.backend.domain.JwtInfo +import ru.shadowsparky.vbox.backend.di.DbModule +import ru.shadowsparky.vbox.backend.di.EventModule +import ru.shadowsparky.vbox.backend.di.HttpModule + +@Module +class VboxModule { + + @Single + fun provideJwtInfo(envFetcher: EnvFetcher): JwtInfo { + return JwtInfo( + envFetcher.get("VIDEOBOX_SECRET", "secret"), + "https://shadowsparky.ru/", + "https://box.shadowsparky.ru/", + "server side data storage" + ) + } +} + +@Module( + includes = [ + DbModule::class, + HttpModule::class, + EventModule::class, + VboxModule::class + ] +) +@ComponentScan +class BackendModule diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt new file mode 100644 index 0000000..2ba644c --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt @@ -0,0 +1,27 @@ +package ru.shadowsparky.vbox.backend + +import io.ktor.server.application.Application +import org.koin.core.annotation.KoinApplication +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.plugin.module.dsl.startKoin +import ru.shadowsparky.vbox.backend.presentation.configureJwt +import ru.shadowsparky.vbox.backend.presentation.configureRouting +import ru.shadowsparky.vbox.backend.presentation.configureSerialization +import ru.shadowsparky.vbox.backend.presentation.configureWebSocket + +@KoinApplication +object VBoxBackend + +fun main(args: Array) { + startKoin() + io.ktor.server.netty.EngineMain.main(args) +} + +fun Application.module() { + val koin = object : KoinComponent {} + configureSerialization() + configureJwt(koin.get()) + configureWebSocket(koin.get()) + configureRouting(koin.get(), koin.get()) +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRecentlyWatchedRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRecentlyWatchedRepository.kt new file mode 100644 index 0000000..5a76984 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRecentlyWatchedRepository.kt @@ -0,0 +1,103 @@ +package ru.shadowsparky.vbox.backend.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo +import java.sql.SQLException + +class BackendRecentlyWatchedRepository( + private val db: AppDatabase, + private val userId: Long, + private val dispatcherProvider: DispatcherProvider, + private val remoteEventHandler: RemoteEventHandler +) : RecentlyWatchedRepository { + + override suspend fun remove(info: RecentlyWatchedInfo) = + withContext(dispatcherProvider.io) { + db.recentQueries.deleteRwByIds(info.movieId, info.episode, info.season, userId) + notifyChanged(info.movieId) + } + + override suspend fun write(info: RecentlyWatchedInfo) = + withContext(dispatcherProvider.io) { + try { + db.recentQueries.insertRecentlyWatched( + userId, + info.movieId, + info.episode, + info.season + ) + notifyChanged(info.movieId) + } catch (ignored: SQLException) { + } + } + + suspend fun toggleInfo(info: RecentlyWatchedInfo) = + withContext(dispatcherProvider.io) { + val hasInfo = + db.recentQueries.selectByIds(info.movieId, info.episode, info.season, userId) + .executeAsOneOrNull() != null + if (hasInfo) { + remove(info) + } else { + write(info) + } + } + + override fun queryRecentlyWatched( + movieId: Long, + seasonId: Long? + ): Flow> = flow { + val rsp = if (seasonId == null) { + db.recentQueries.selectByMovieId(movieId, userId) + .executeAsList() + .map { + RecentlyWatchedInfo( + it.movie_id, + it.episode, + it.season + ) + } + } else { + db.recentQueries.selectByMovieIdAndSeason(movieId, userId, seasonId) + .executeAsList() + .map { + RecentlyWatchedInfo( + it.movie_id, + it.episode, + seasonId + ) + } + } + emit(rsp) + } + + override suspend fun getAllRecentlyWatched(): List { + return withContext(dispatcherProvider.io) { + db.recentQueries.selectAll(userId).executeAsList() + .map { + RecentlyWatchedInfo( + it.movie_id, + it.episode, + it.season + ) + } + } + } + + private suspend fun notifyChanged(seasonId: Long) { + remoteEventHandler.notify( + RemoteEvent.OnRecent( + System.currentTimeMillis(), + userId, + seasonId + ) + ) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRemoteEventHandler.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRemoteEventHandler.kt new file mode 100644 index 0000000..453c86a --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRemoteEventHandler.kt @@ -0,0 +1,43 @@ +package ru.shadowsparky.vbox.backend.data + +import kotlinx.serialization.json.Json +import org.koin.core.annotation.Factory +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler + +val eventLogger: Logger = LoggerFactory.getLogger("events") + +@Factory +class BackendRemoteEventHandler( + private val json: Json, + private val sessionCache: SessionCache +) : RemoteEventHandler { + + suspend fun put(userId: Long, socketSession: SessionCache.Writer) { + sessionCache.put(userId, socketSession) + } + + suspend fun remove(userId: Long, socketSession: SessionCache.Writer) { + sessionCache.remove(userId, socketSession) + } + + override suspend fun notify(eventInfo: RemoteEvent) { + val json = json.encodeToString(eventInfo) + val writers = sessionCache.get(eventInfo.userId) + if (writers.isNullOrEmpty()) { + eventLogger.info("unable to notify $eventInfo. sessions not found, cache $sessionCache") + } else { + writers.forEach { + eventLogger.info("notify[$eventInfo]. session $it", RuntimeException("called")) + try { + it.writeText(json) + } catch (_: Exception) { + eventLogger.error("unable to notify $it. delete session") + remove(eventInfo.userId, it) + } + } + } + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSavedMovieRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSavedMovieRepository.kt new file mode 100644 index 0000000..a772d28 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSavedMovieRepository.kt @@ -0,0 +1,79 @@ +package ru.shadowsparky.vbox.backend.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import ru.shadowsparky.backend.data.Logger +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.server.Saved_movie +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository +import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails +import java.sql.SQLException + +class BackendSavedMovieRepository( + private val db: AppDatabase, + private val userId: Long, + private val dispatcherProvider: DispatcherProvider, + private val logger: Logger, + private val eventHandler: RemoteEventHandler +) : SavedMovieRepository { + + override fun getAll(): Flow> { + val request = db.saved_movieQueries.selectByUserId(userId) + return flow { + emit(request.executeAsList().parse().reversed()) + } + } + + private suspend fun List.parse(): List = + withContext(dispatcherProvider.io) { + mapNotNull { + val movie = db.movieQueries.selectMovie(it.movie_id) + .executeAsOneOrNull() ?: return@mapNotNull null + VideoDetails( + movie.movie_id, + movie.poster_url, + movie.description, + movie.title, + (movie.flags and SERIAL_FLAG) != 0 + ) + } + } + + override suspend fun save(details: VideoDetails) { + logger.debug(TAG, "save(${details.id}) ${details.title}") + withContext(dispatcherProvider.io) { + try { + db.saved_movieQueries.addSavedMovie( + details.id, + userId + ) + eventHandler.notify(RemoteEvent.OnSaved(userId, details.id)) + } catch (_: SQLException) { + } + } + } + + override fun isSaved(id: Long): Flow { + return flow { + val request = db.saved_movieQueries.selectByUserIdAndMovieId(userId, id) + emit(request.executeAsOneOrNull() != null) + } + } + + override suspend fun remove(id: Long) { + logger.debug(TAG, "remove(${id})") + withContext(dispatcherProvider.io) { + db.saved_movieQueries.removeSavedMovie(userId, id) + eventHandler.notify(RemoteEvent.OnSaved(userId, id)) + } + } + + private companion object { + const val TAG = "BackendSavedMovieRepository" + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSearchRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSearchRepository.kt new file mode 100644 index 0000000..b1caec9 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSearchRepository.kt @@ -0,0 +1,64 @@ +package ru.shadowsparky.vbox.backend.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.SearchRepository +import java.sql.SQLException + +class BackendSearchRepository( + private val db: AppDatabase, + private val userId: Long, + private val dispatcherProvider: DispatcherProvider, + private val eventHandler: RemoteEventHandler +) : SearchRepository { + + override fun search(query: String): Flow> { + return flow { emit(searchSingle(query)) } + } + + private suspend fun searchSingle(query: String): List = withContext(Dispatchers.IO) { + if (query.trim().isEmpty()) { + db.searchQueries.selectAll(userId) + } else { + db.searchQueries.selectByQuery(query.addWilcard(), userId) + }.executeAsList().map { it.query } + } + + private fun String.addWilcard(): String { + return if (this.endsWith("%")) this else "$this%" + } + + override suspend fun addToSearch(query: String) { + withContext(dispatcherProvider.io) { + try { + db.searchQueries.insertSearchInfo(query, userId) + notifyChanged() + } catch (_: SQLException) { + } + } + } + + override suspend fun deleteFromSearch(query: String) { + withContext(dispatcherProvider.io) { + db.searchQueries.deleteSearchInfo(query, userId) + notifyChanged() + } + } + + override suspend fun clear() { + withContext(dispatcherProvider.io) { + db.searchQueries.deleteAll(userId) + notifyChanged() + } + } + + private suspend fun notifyChanged() { + eventHandler.notify(RemoteEvent.OnSearch(System.currentTimeMillis(), userId)) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/SessionCache.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/SessionCache.kt new file mode 100644 index 0000000..83aa08d --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/SessionCache.kt @@ -0,0 +1,42 @@ +package ru.shadowsparky.vbox.backend.data + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Factory +import java.util.Collections + +typealias SessionMap = MutableMap> + +@Factory +class SessionCache { + private val sessionMap: SessionMap = Collections.synchronizedMap(hashMapOf()) + + private val mutex = Mutex() + + suspend fun put(userId: Long, socketSession: Writer) { + eventLogger.info("put[$userId]=$socketSession") + mutex.withLock { + sessionMap[userId] = (sessionMap[userId] ?: mutableListOf()) + listOf(socketSession) + } + } + + suspend fun get(userId: Long): List? { + mutex.withLock { + return sessionMap[userId] + } + } + + suspend fun remove(userId: Long, session: Writer) { + eventLogger.info("remove[$userId]=$session") + mutex.withLock { + val sessionFromMap = sessionMap[userId]?.toMutableList() + val item = sessionFromMap?.firstOrNull { it == session } ?: return + sessionFromMap.remove(item) + sessionMap[userId] = sessionFromMap + } + } + + fun interface Writer { + suspend fun writeText(text: String) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendAuthTokenRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendAuthTokenRepository.kt new file mode 100644 index 0000000..02d004d --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendAuthTokenRepository.kt @@ -0,0 +1,169 @@ +package ru.shadowsparky.vbox.backend.data.auth + +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Factory +import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.backend.data.JwtPreparer +import ru.shadowsparky.backend.data.StringFetcher +import ru.shadowsparky.backend.data.StringResource +import ru.shadowsparky.backend.domain.LoginVerifier +import ru.shadowsparky.domain.Log +import ru.shadowsparky.http.domain.BadRequestException +import ru.shadowsparky.http.domain.TokenInfo +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.server.Refresh_tokens +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository +import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest +import ru.shadowsparky.vbox.shared.domain.TokenRequest +import ru.shadowsparky.vbox.shared.domain.model.LoginInfo +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.concurrent.TimeUnit +import kotlin.io.encoding.Base64 + +@Factory +class AuthTokenRepositoryFactory( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + private val jwtPreparer: JwtPreparer, + private val loginVerifier: LoginVerifier, + private val log: Log, + private val stringFetcher: StringFetcher, + private val envFetcher: EnvFetcher +) { + + fun create(userId: Long = -1): AuthTokenRepository { + return BackendAuthTokenRepository( + db, + dispatcherProvider, + jwtPreparer, + loginVerifier, + userId, + log, + stringFetcher, + envFetcher + ) + } +} + +class BackendAuthTokenRepository( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + private val jwtPreparer: JwtPreparer, + private val loginVerifier: LoginVerifier, + private val userId: Long, + private val log: Log, + private val stringFetcher: StringFetcher, + private val envFetcher: EnvFetcher +) : AuthTokenRepository { + private val random = SecureRandom() + + override suspend fun register(loginInfo: LoginInfo): TokenInfo { + return if (envFetcher.get("ALLOW_REGISTRATION", "0") == "1") { + registerInternal(loginInfo) + } else { + throw BadRequestException(stringFetcher.get(StringResource.REG_BLOCKED)) + } + } + + private suspend fun registerInternal(loginInfo: LoginInfo): TokenInfo { + return withContext(dispatcherProvider.io) { + val user = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull() + if (user != null) throw BadRequestException(stringFetcher.get(StringResource.ALREADY_REGISTERED)) + db.usersQueries.addUser( + loginInfo.login, loginInfo.passwordHash, System.currentTimeMillis(), null + ) + TokenInfo("") + } + } + + override suspend fun login(loginInfo: LoginInfo): TokenInfo = + withContext(dispatcherProvider.io) { + val userInfo = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull() + ?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER)) + if (loginInfo.passwordHash != userInfo.password_hash) { + throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER)) + } + create(loginInfo.login) + } + + override suspend fun update(refresh: TokenRequest): TokenInfo { + val info = db.refresh_tokensQueries.selectByHash(refresh.token.toHash()) + .executeAsOneOrNull() + ?: throw BadRequestException("Token not found") + val userInfo = db.usersQueries.selectUserByUserId(info.user_id).executeAsOneOrNull() + ?: throw BadRequestException("User not found") + val newTokens = create(userInfo.login) + revokeInternal(refresh) + return newTokens + } + + override suspend fun revoke(tokenRequest: TokenRequest) { + revokeInternal(tokenRequest) + } + + private suspend fun revokeInternal(request: TokenRequest) { + log.d("BackendAuthTokenRepository", "revoke ${request.token.toHash()}") + val info = getTokenAndCheck(request) + db.refresh_tokensQueries.revokeToken(info.token_id).await() + } + + private suspend fun getTokenAndCheck(tokenRequest: TokenRequest): Refresh_tokens = + withContext(dispatcherProvider.io) { + val info = db.refresh_tokensQueries.selectByHash(tokenRequest.token.toHash()) + .executeAsOneOrNull() + ?: throw BadRequestException("Token not found") + if (info.revoked) throw BadRequestException("Token already revoked") + if (info.expires_at < System.currentTimeMillis()) { + throw BadRequestException( + "Refresh token expired. " + + "Expires at ${info.expires_at} < ${System.currentTimeMillis()}" + ) + } + info + } + + private suspend fun create(login: String): TokenInfo = withContext(dispatcherProvider.io) { + loginVerifier.verify(login) + val userInfo = db.usersQueries.selectUserByLogin(login).executeAsOneOrNull() + ?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER)) + val rsp = jwtPreparer.prepare(login, userInfo.user_id) + val refresh = ByteArray(32) + random.nextBytes(refresh) + val refreshStr = refresh.toHexString() + db.refresh_tokensQueries.insertToken( + userInfo.user_id, + refreshStr.toHash(), + System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30) + ).executeAsOneOrNull() + log.d( + "BackendAuthTokenRepository", + "${refreshStr.toHash()} created for user ${userInfo.user_id}" + ) + TokenInfo(rsp, refreshStr) + } + + override suspend fun changePassword(request: ChangePasswordRequest): Unit = + withContext(dispatcherProvider.io) { + val user = db.usersQueries.selectUserByUserId(userId).executeAsOneOrNull() + ?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER)) + if (user.password_hash != request.oldPasswordHash) { + throw BadRequestException(stringFetcher.get(StringResource.INVALID_PASSWORD)) + } else if (user.password_hash == request.newPasswordHash) { + throw BadRequestException(stringFetcher.get(StringResource.NO_CHANGES_PASS)) + } + db.usersQueries.updatePassword(request.newPasswordHash, userId).await() + } + + private fun String.toHash(): String { + val md = MessageDigest.getInstance("SHA-256") + md.update(SALT.toByteArray()) + val result = md.digest(this.toByteArray()) + return Base64.encode(result) + } + + private companion object { + const val SALT = "G0yCPoy7gCj1a5OxPaeYLeJm69NvPQ50HbV0ZVYVqahUPdKun4MKgg86u9HbWq2e" + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendLoginVerifier.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendLoginVerifier.kt new file mode 100644 index 0000000..e640302 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendLoginVerifier.kt @@ -0,0 +1,26 @@ +package ru.shadowsparky.vbox.backend.data.auth + +import org.koin.core.annotation.Factory +import ru.shadowsparky.backend.data.StringFetcher +import ru.shadowsparky.backend.data.StringResource +import ru.shadowsparky.backend.domain.LoginVerifier +import ru.shadowsparky.backend.domain.VerifyTokenException +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.domain.FLAG_USER_BLOCKED + +@Factory +class BackendLoginVerifier( + private val db: AppDatabase, + private val stringFetcher: StringFetcher +) : LoginVerifier { + override suspend fun verify(login: String) { + val user = db.usersQueries.selectUserByLogin(login) + .executeAsOneOrNull() + ?: throw VerifyTokenException(stringFetcher.get(StringResource.UNKNOWN_USER)) + user.flags?.let { + if ((it and FLAG_USER_BLOCKED) != 0) { + throw VerifyTokenException(stringFetcher.get(StringResource.USER_BLOCKED)) + } + } + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendApi.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendApi.kt new file mode 100644 index 0000000..6c14d39 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendApi.kt @@ -0,0 +1,137 @@ +package ru.shadowsparky.vbox.backend.data.http + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.url +import io.ktor.client.statement.bodyAsText +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.koin.core.annotation.Factory +import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.backend.data.StringFetcher +import ru.shadowsparky.backend.data.StringResource +import ru.shadowsparky.http.domain.NotFoundException +import ru.shadowsparky.vbox.shared.domain.VideoApi +import ru.shadowsparky.vbox.shared.domain.model.Season +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails +import ru.shadowsparky.vbox.shared.domain.model.VideoItem +import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse +import ru.shadowsparky.vbox.shared.domain.model.VideosResponse + +private const val FALLBACK_SEASON_ID = -1L +private const val SEASON_FALLBACK_NAME = "Сезон 1" + +@Factory(binds = [ExternalBackendApi::class]) +class ExternalBackendApi( + private val client: HttpClient, + private val mapper: ExternalBackendMapper, + private val cache: ExternalCache, + private val envFetcher: EnvFetcher, + private val stringFetcher: StringFetcher +) : VideoApi { + private val notFoundException = + NotFoundException(stringFetcher.get(StringResource.VIDEO_NOT_FOUND)) + private val endpoint = envFetcher.get(BACKEND_HOST) + + override suspend fun fetchNewVideos(query: String?): VideosResponse { + return coroutineScope { + val movies = async { fetchFavoritesInternal("movie", query) } + val series = async { fetchFavoritesInternal("series", query) } + val anime = async { fetchFavoritesInternal("anime", query) } + val result = (series.await() + movies.await() + anime.await()) + result.map { + async { cache.put(it) } + }.awaitAll() + return@coroutineScope VideosResponse(false, result, 1) + } + } + + private suspend fun fetchFavoritesInternal(request: String, search: String?): List { + val rsp = client.get { + url("https", endpoint, path = "${envFetcher.get(FAVORITES_PREFIX)}$request") + if (search != null) { + parameter("search", search) + } + } + val rspText = rsp.bodyAsText() + return mapper.movieRspToItems(rspText, request) + } + + override suspend fun fetchDetails(id: Long): VideoDetails { + val info = cache.get(id) ?: throw notFoundException + return VideoDetails( + poster = mapper.mapPoster(info.poster), + id = info.id, + title = info.title, + desc = info.description ?: "", + isSerial = info.isSerial + ) + } + + override suspend fun fetchVideoLinks( + id: Long, + seasonId: Long? + ): VideoLinksResponse { + val isSerial = cache.get(id)?.isSerial ?: throw notFoundException + return if (isSerial) { + val seasons = mutableListOf() + if (seasonId == null) { + getSerialSeasonIds(id).forEach { seasons.add(Season(emptyList(), it.id, it.title)) } + if (seasons.isEmpty()) { + seasons.add(Season(emptyList(), FALLBACK_SEASON_ID, SEASON_FALLBACK_NAME)) + } + } else { + if (seasonId == FALLBACK_SEASON_ID) { + seasons.add(getSerialSeasonFallback(id)) + } else { + val entry = getSerialSeasonIds(id).firstOrNull { it.id == seasonId } + ?: throw notFoundException + seasons.add(getSeason(entry)) + } + } + if (seasons.isEmpty()) throw notFoundException + VideoLinksResponse(seasons = seasons) + } else { + val rsp = client.get { + url("https", endpoint, path = envFetcher.get(LINKS_PATH)) + parameter("id", id) + parameter("movie", "") + }.bodyAsText() + VideoLinksResponse(files = mapper.parseMovie(rsp) ?: throw notFoundException) + } + } + + private suspend fun getSerialSeasonIds(id: Long): List { + val rsp = client.get { + url("https", endpoint, path = envFetcher.get(SEASON_IDS_PATH)) + parameter("id", id) + } + val text = rsp.bodyAsText() + return mapper.categoryRspToSeasonInfo(text) + } + + private suspend fun getSerialSeasonFallback(id: Long): Season { + val rsp = client.get { + url("https", endpoint, path = envFetcher.get(LINKS_PATH)) + parameter("id", id) + } + val text = rsp.bodyAsText() + return mapper.parseSeason( + text, + ExternalBackendMapper.SeasonEntry( + SEASON_FALLBACK_NAME, + FALLBACK_SEASON_ID + ) + ) + } + + private suspend fun getSeason(entry: ExternalBackendMapper.SeasonEntry): Season { + val rsp2 = client.get { + url("https", endpoint, path = envFetcher.get(SEASON_PATH)) + parameter("id", entry.id) + }.bodyAsText() + return mapper.parseSeason(rsp2, entry) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendMapper.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendMapper.kt new file mode 100644 index 0000000..1f94cad --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendMapper.kt @@ -0,0 +1,199 @@ +package ru.shadowsparky.vbox.backend.data.http + +import org.jsoup.Jsoup +import org.koin.core.annotation.Factory +import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.vbox.shared.domain.model.Episode +import ru.shadowsparky.vbox.shared.domain.model.File +import ru.shadowsparky.vbox.shared.domain.model.Season +import ru.shadowsparky.vbox.shared.domain.model.VideoItem +import java.net.URI + +@Factory +class ExternalBackendMapper( + private val envFetcher: EnvFetcher +) { + private val readerFactory: () -> XmlReader = { XmlReader() } + + fun movieRspToItems(rsp: String, rootTag: String): List { + val reader = readerFactory().apply { setInput(rsp) } + return readRoot(reader, rootTag).toItems() + } + + fun categoryRspToSeasonInfo(rsp: String): List { + val reader = readerFactory().apply { setInput(rsp) } + val movies = readRoot(reader, "category").unit + return movies.map { SeasonEntry(it.title!!, it.id.toLong()) } + } + + fun parseSeason(rsp: String, seasonEntry: SeasonEntry): Season { + val reader = readerFactory().apply { setInput(rsp) } + val videos = readRoot(reader, "video").unit + val episodes = mutableListOf() + videos.forEachIndexed { index, hlamerUnit -> + episodes.add(hlamerUnit.toEpisode(index + 1L)) + } + return Season(episodes, seasonEntry.id, seasonEntry.title.parseHtml()) + } + + fun parseMovie(rsp: String): List? { + val reader = readerFactory().apply { setInput(rsp) } + val files = readRoot(reader, "video").unit.firstOrNull() ?: return null + return listOf(File(false, -1, files.file?.loadMp4FromSparky() ?: return null)) + } + + private fun ExternalUnit.toEpisode(epNumber: Long): Episode { + val hasTitle = title?.trim()?.isNotEmpty() == true + val splitTitle = title?.split(""") + ?.lastOrNull { it.isNotEmpty() } + ?: title?.removeSurrounding(""") + val episodeText = "Серия" + return Episode( + epNumber, + listOf(File(false, -1, file!!.loadMp4FromSparky())), + if (hasTitle) "$epNumber. $splitTitle" else "$episodeText $epNumber" + ) + } + + private fun readRoot(reader: XmlReader, rootTag: String): Movie { + reader.require(XmlReader.Tag.START, rootTag) + val entries = mutableListOf() + while (reader.next != XmlReader.Tag.END) { + if (reader.eventType != XmlReader.Tag.START) { + continue + } + if (reader.name == "unit") { + val entry = readUnitEntry(reader) + entries.add(entry) + } + } + return Movie(entries) + } + + private fun readUnitEntry(reader: XmlReader): ExternalUnit { + reader.require(XmlReader.Tag.START, "unit") + val id = reader.getAttributeValue("id") + var title: String? = null + var description: String? = null + var thumb: String? = null + var section: String? = null + var file: String? = null + var image: String? = null + while (reader.next != XmlReader.Tag.END) { + if (reader.eventType != XmlReader.Tag.START) { + continue + } + when (reader.name) { + "title" -> title = readText(reader) + "description" -> description = readText(reader) + "thumb" -> thumb = readText(reader) + "section" -> section = readText(reader) + "file" -> file = readText(reader) + "image" -> image = readText(reader) + else -> readText(reader) // skip + } + } + return ExternalUnit(id, title, description, thumb, section, file, image) + } + + private fun readText(reader: XmlReader): String { + var result = "" + if (reader.next == XmlReader.Tag.TEXT) { + result = reader.text + reader.nextTag() + } + return result + } + + private fun Movie.toItems(): List { + return unit.mapNotNull { it.toVideoItem() } + } + + private fun ExternalUnit.toVideoItem(): VideoItem? { + val poster = thumb?.loadPosterFromSparky() ?: return null + if (poster.endsWith("/_180.jpg")) { + return null + } + val isMovie = section == "movie" + return VideoItem( + id = id.toLong(), + title = title?.parseHtml() ?: return null, + poster = poster, + description = description?.parseHtml(), + isSerial = !isMovie + ) + } + + private fun String.loadMp4FromSparky(): String { + return mapExternalToBox(this) ?: this + } + + private val hostRegex = Regex("""^m(\d+)\.${envFetcher.get(REGEX_HOST)}\.ru$""") + + private fun mapExternalToBox(sourceUrl: String): String? { + val uri = try { + URI(sourceUrl) + } catch (_: Exception) { + return null + } + val host = uri.host ?: return null + + val m = hostRegex.matchEntire(host) ?: return null + val idx = m.groupValues[1] + + val rawPath = uri.rawPath ?: return null + if (!rawPath.startsWith("/video")) return null + + val rest = rawPath.removePrefix("/video") + val newPath = "/video/m$idx$rest" + + return URI( + "https", + null, + "box.shadowsparky.ru", + uri.port, + newPath, + uri.rawQuery, + uri.rawFragment + ).toString() + } + + fun mapPoster(poster: String): String { + return poster.loadPosterFromSparky() + } + + private fun String.loadPosterFromSparky(): String { + val imageEndpoint = envFetcher.get(IMAGE_ENDPOINT) + if (startsWith(imageEndpoint)) { + return replace(imageEndpoint, SPARKY_IMAGE_ENDPOINT) + } + return this + } + + data class SeasonEntry( + val title: String, + val id: Long + ) + + private data class Movie( + val unit: List + ) + + private data class ExternalUnit( + val id: String, + val title: String?, + val description: String?, + val thumb: String?, + val section: String?, + val file: String?, + val image: String? + ) + + private fun String.parseHtml(): String { + return Jsoup.parse(this@parseHtml).wholeText() + } + + companion object { + private const val SPARKY_IMAGE_ENDPOINT = "https://box.shadowsparky.ru/images/" + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalCache.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalCache.kt new file mode 100644 index 0000000..2cf05cc --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalCache.kt @@ -0,0 +1,66 @@ +package ru.shadowsparky.vbox.backend.data.http + +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.model.MOVIE_FLAG +import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG +import ru.shadowsparky.vbox.shared.domain.model.VideoItem + +@Single +class ExternalCache( + private val dispatcherProvider: DispatcherProvider, + private val db: AppDatabase +) { + + suspend fun put(item: VideoItem) { + insertIfNeeded(item) + } + + suspend fun get(id: Long): VideoItem? = queryOrNull(id) + + private suspend fun queryOrNull(id: Long): VideoItem? { + return withContext(dispatcherProvider.io) { + val oldInfo = db.movieQueries.selectMovie(id) + .executeAsOneOrNull() + ?: return@withContext null + VideoItem( + oldInfo.movie_id, + oldInfo.poster_url, + oldInfo.title, + oldInfo.description, + oldInfo.flags == SERIAL_FLAG + ) + } + } + + private suspend fun insertIfNeeded(new: VideoItem) = withContext(dispatcherProvider.io) { + val old = queryOrNull(new.id) + if (old == null) { + db.movieQueries.insertMovie( + new.id, + new.poster, + new.description ?: "", + new.title, + new.serializeFlags() + ) + } else if (old != new) { + db.movieQueries.updateMovie( + new.poster, + new.description ?: "", + new.title, + new.serializeFlags(), + new.id + ) + } + } + + private fun VideoItem.serializeFlags(): Int { + return if (isSerial) { + SERIAL_FLAG + } else { + MOVIE_FLAG + } + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalConst.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalConst.kt new file mode 100644 index 0000000..bb2cfa4 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalConst.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.vbox.backend.data.http + +const val REGEX_HOST = "EXTERNAL_HOST" +const val IMAGE_ENDPOINT = "EXTERNAL_IMAGE_ENDPOINT" +const val CACHE_DIR = "EXTERNAL_CACHE_DIR" +const val BACKEND_HOST = "EXTERNAL_BACKEND_HOST" +const val FAVORITES_PREFIX = "EXTERNAL_FAVORITES_PREFIX" +const val LINKS_PATH = "EXTERNAL_LINKS_PATH" +const val SEASON_IDS_PATH = "EXTERNAL_SEASON_IDS_PATH" +const val SEASON_PATH = "EXTERNAL_SEASON_PATH" diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/XmlReader.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/XmlReader.kt new file mode 100644 index 0000000..9413588 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/XmlReader.kt @@ -0,0 +1,61 @@ +package ru.shadowsparky.vbox.backend.data.http + +import org.kxml2.io.KXmlParser +import org.xmlpull.v1.XmlPullParser +import java.io.StringReader + +class XmlReader { + private var parser: XmlPullParser? = null + + val name: String + get() = parser!!.name + val eventType: Tag + get() = parser!!.eventType.fromType() + val next: Tag + get() = parser!!.next().fromType() + + fun setInput(raw: String) { + parser = KXmlParser().apply { + setInput(StringReader(raw)) + nextTag() + } + } + + fun require( + tag: Tag, + name: String + ) { + parser?.require(tag.toType(), null, name) + } + + fun Tag.toType(): Int { + return when (this) { + Tag.START -> XmlPullParser.START_TAG + Tag.END -> XmlPullParser.END_TAG + Tag.TEXT -> XmlPullParser.TEXT + } + } + + fun Int.fromType(): Tag { + return when (this) { + XmlPullParser.START_TAG -> Tag.START + XmlPullParser.END_TAG -> Tag.END + else -> Tag.TEXT + } + } + + fun getAttributeValue(name: String): String { + return parser!!.getAttributeValue(null, name) + } + + fun nextTag() { + parser!!.nextTag() + } + + enum class Tag { + START, END, TEXT + } + + val text: String + get() = parser!!.text +} \ No newline at end of file diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendMovieTagRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendMovieTagRepository.kt new file mode 100644 index 0000000..b4028a1 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendMovieTagRepository.kt @@ -0,0 +1,64 @@ +package ru.shadowsparky.vbox.backend.data.tags + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.domain.DispatcherProvider +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository +import ru.shadowsparky.vbox.shared.domain.tag.TagInfo + +@Factory +class MovieTagRepositoryFactory( + private val appDatabase: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + @Named(EventType.MOVIE_TAG) private val movieEventHandler: RemoteEventHandler +) { + fun create(userId: Long): MovieTagRepository { + return BackendMovieTagRepository( + appDatabase, + dispatcherProvider, + userId, + movieEventHandler + ) + } +} + +class BackendMovieTagRepository( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + private val userId: Long, + private val movieEventHandler: RemoteEventHandler +) : MovieTagRepository { + private val dbQueries get() = db.tagsQueries + + override fun query(movieId: Long): Flow> = dbQueries + .getTagsForMovie(movieId, userId) + .asFlow() + .mapToList(dispatcherProvider.io) + .map { list -> + list.map { row -> TagInfo(id = row.id, tag = row.tag) } + } + + override fun queryMovies(tagId: Long): Flow> = dbQueries + .getMoviesByTagId(tagId, userId) + .asFlow() + .mapToList(dispatcherProvider.io) + .map { list -> list.toSet() } + + override suspend fun link(movieId: Long, tagId: Long) { + dbQueries.linkMovieTag(movieId, tagId, userId).await() + movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId)) + } + + override suspend fun unlink(movieId: Long, tagId: Long) { + dbQueries.unlinkMovieTag(movieId, tagId, userId).await() + movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId)) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendUserTagRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendUserTagRepository.kt new file mode 100644 index 0000000..84ddb59 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendUserTagRepository.kt @@ -0,0 +1,72 @@ +package ru.shadowsparky.vbox.backend.data.tags + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.tag.TagInfo +import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository + +@Factory +class UserTagRepositoryFactory( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + @Named(EventType.USER_TAG) private val userTagEventHandler: RemoteEventHandler +) { + fun create(userId: Long): UserTagRepository { + return BackendUserTagRepository( + userId, + db, + dispatcherProvider, + userTagEventHandler + ) + } +} + +class BackendUserTagRepository( + private val userId: Long, + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + private val userTagEventHandler: RemoteEventHandler +) : UserTagRepository { + private val queries get() = db.tagsQueries + + override val userTags: Flow> by lazy { + queries.getUserTagsByUserId(user_id = userId) + .asFlow() + .mapToList(dispatcherProvider.io) + .map { list -> + list.map { row -> TagInfo(id = row.id, tag = row.tag) } + } + } + + override suspend fun add(tag: String) { + queries.insertUserTag(userId, tag).await() + userTagEventHandler.notify(RemoteEvent.OnUserTag(userId)) + } + + override suspend fun edit(id: Long, newTag: String) { + checkOwner(id) + queries.updateTagText(newTag, id).await() + userTagEventHandler.notify(RemoteEvent.OnUserTag(userId)) + } + + override suspend fun delete(id: Long) { + checkOwner(id) + queries.deleteTagById(id).await() + userTagEventHandler.notify(RemoteEvent.OnUserTag(userId)) + } + + private suspend fun checkOwner(id: Long) = withContext(dispatcherProvider.io) { + val ownerId = queries.getUserIdByTagId(id).executeAsOneOrNull() + check(ownerId == userId) { "tag owner mismatch. owner: $ownerId, current user: $userId" } + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/DbModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/DbModule.kt new file mode 100644 index 0000000..a37a3fd --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/DbModule.kt @@ -0,0 +1,33 @@ +package ru.shadowsparky.vbox.backend.di + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.asJdbcDriver +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.postgresql.ds.PGSimpleDataSource +import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.vbox.backend.AppDatabase + +@Module +class DbModule { + + @Factory + fun provideSqlDriver(envFetcher: EnvFetcher): SqlDriver { + return PGSimpleDataSource().apply { + val db = envFetcher.get("POSTGRES_DB", "videobox") + val uri = envFetcher.get("VIDEOBOX_SERVER_URI", "localhost") + val login = envFetcher.get("POSTGRES_USER", "login") + val pass = envFetcher.get("POSTGRES_PASSWORD", "password") + + setUrl("jdbc:postgresql://$uri:5432/$db") + user = login + password = pass + }.asJdbcDriver() + } + + @Single + fun provideDb(driver: SqlDriver): AppDatabase { + return AppDatabase.Companion.invoke(driver) + } +} \ No newline at end of file diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt new file mode 100644 index 0000000..2b0458a --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt @@ -0,0 +1,46 @@ +package ru.shadowsparky.vbox.backend.di + +import kotlinx.serialization.json.Json +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import ru.shadowsparky.backend.data.TokenVerifier +import ru.shadowsparky.backend.domain.JwtInfo +import ru.shadowsparky.backend.domain.LoginVerifier +import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler +import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory +import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory +import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory +import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory +import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory +import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.VideoApi + +@Single +class RoutingEntryPoint( + val videoApi: VideoApi, + val searchFactory: SearchRepositoryFactory, + val recentlyFactory: RecentlyWatchedRepositoryFactory, + val savedMovieFactory: SavedMovieRepositoryFactory, + val json: Json, + val movieTagFactory: MovieTagRepositoryFactory, + val userTagFactory: UserTagRepositoryFactory +) + +@Single +class WebSocketEntryPoint( + @Named(EventType.SEARCH) val searchEventHandler: BackendRemoteEventHandler, + @Named(EventType.RECENT) val recentlyEventHandler: BackendRemoteEventHandler, + @Named(EventType.SAVED) val savedEventHandler: BackendRemoteEventHandler, + @Named(EventType.USER_TAG) val userTagEventHandler: BackendRemoteEventHandler, + @Named(EventType.MOVIE_TAG) val movieTagEventHandler: BackendRemoteEventHandler, + val tokenVerifier: TokenVerifier +) + +@Single +class AuthEntryPoint( + val loginVerifier: LoginVerifier, + val authTokenRepositoryFactory: AuthTokenRepositoryFactory, + val jwtInfo: JwtInfo, + val tokenVerifier: TokenVerifier +) diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EventModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EventModule.kt new file mode 100644 index 0000000..5f5c361 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EventModule.kt @@ -0,0 +1,32 @@ +package ru.shadowsparky.vbox.backend.di + +import org.koin.core.annotation.Module +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler + +@Module +class EventModule { + + @Single(binds = [BackendRemoteEventHandler::class]) + @Named(EventType.SEARCH) + fun provideSearch(impl: BackendRemoteEventHandler): RemoteEventHandler = impl + + @Single(binds = [BackendRemoteEventHandler::class]) + @Named(EventType.RECENT) + fun provideRecent(impl: BackendRemoteEventHandler): RemoteEventHandler = impl + + @Single(binds = [BackendRemoteEventHandler::class]) + @Named(EventType.SAVED) + fun provideSaved(impl: BackendRemoteEventHandler): RemoteEventHandler = impl + + @Single(binds = [BackendRemoteEventHandler::class]) + @Named(EventType.USER_TAG) + fun provideUserTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl + + @Single(binds = [BackendRemoteEventHandler::class]) + @Named(EventType.MOVIE_TAG) + fun provideMovieTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt new file mode 100644 index 0000000..428b284 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt @@ -0,0 +1,13 @@ +package ru.shadowsparky.vbox.backend.di + +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Module +import ru.shadowsparky.vbox.backend.data.http.ExternalBackendApi +import ru.shadowsparky.vbox.shared.domain.VideoApi + +@Module +class HttpModule { + + @Factory + fun provideVideoApi(impl: ExternalBackendApi): VideoApi = impl +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt new file mode 100644 index 0000000..9fcbb7b --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.vbox.backend.di.factory + +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.backend.data.BackendRecentlyWatchedRepository +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler + +@Factory +class RecentlyWatchedRepositoryFactory( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + @Named(EventType.RECENT) private val remoteEventHandler: RemoteEventHandler +) { + fun create(userId: Long): BackendRecentlyWatchedRepository { + return BackendRecentlyWatchedRepository(db, userId, dispatcherProvider, remoteEventHandler) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt new file mode 100644 index 0000000..f60b076 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt @@ -0,0 +1,23 @@ +package ru.shadowsparky.vbox.backend.di.factory + +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.backend.data.Logger +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler +import ru.shadowsparky.vbox.backend.data.BackendSavedMovieRepository +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository + +@Factory +class SavedMovieRepositoryFactory( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + private val logger: Logger, + @Named(EventType.SAVED) private val eventHandler: BackendRemoteEventHandler +) { + fun create(userId: Long): SavedMovieRepository { + return BackendSavedMovieRepository(db, userId, dispatcherProvider, logger, eventHandler) + } +} \ No newline at end of file diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SearchRepositoryFactory.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SearchRepositoryFactory.kt new file mode 100644 index 0000000..97336e5 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SearchRepositoryFactory.kt @@ -0,0 +1,21 @@ +package ru.shadowsparky.vbox.backend.di.factory + +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.backend.data.BackendSearchRepository +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.SearchRepository + +@Factory +class SearchRepositoryFactory( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + @Named(EventType.SEARCH) private val eventHandler: RemoteEventHandler +) { + fun create(userId: Long): SearchRepository { + return BackendSearchRepository(db, userId, dispatcherProvider, eventHandler) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Jwt.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Jwt.kt new file mode 100644 index 0000000..e805ba5 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Jwt.kt @@ -0,0 +1,70 @@ +package ru.shadowsparky.vbox.backend.presentation + +import com.auth0.jwt.JWT +import com.auth0.jwt.interfaces.DecodedJWT +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.jwt.jwt +import io.ktor.server.response.respond +import ru.shadowsparky.backend.domain.INVALID_TOKEN_MSG +import ru.shadowsparky.backend.domain.LOGIN_NAME +import ru.shadowsparky.backend.domain.VerifyTokenException +import ru.shadowsparky.vbox.backend.di.AuthEntryPoint +import ru.shadowsparky.vbox.shared.domain.USER_ID_ARG +import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo + +const val AUTH_JWT_NAME = "auth-jwt" + +fun Application.configureJwt(authEntryPoint: AuthEntryPoint) { + install(Authentication) { + jwt(AUTH_JWT_NAME) { + val jwtInfo = authEntryPoint.jwtInfo + realm = jwtInfo.realm + verifier(authEntryPoint.tokenVerifier.verifier) + validate { credential -> + val tokenRepo = authEntryPoint.loginVerifier + val login = credential.payload.getClaim(LOGIN_NAME).asString() + try { + if (login != null) { + tokenRepo.verify(login) + if (credential.payload.expiresAt == null) { + throw VerifyTokenException("Static tokens not supported!") + } + JWTPrincipal(credential.payload) + } else { + null + } + } catch (e: VerifyTokenException) { + routingLogger.error("verify token failed ${e.message}") + null + } + } + challenge { defaultScheme, realm -> + call.respond( + HttpStatusCode.Unauthorized, + ServerExceptionInfo(INVALID_TOKEN_MSG) + ) + } + } + } +} + +private fun String?.obtainToken(): DecodedJWT { + val token = this?.removePrefix("Bearer ") + ?: throw VerifyTokenException(INVALID_TOKEN_MSG) + return JWT.decode(token) +} + +fun ApplicationCall.obtainToken(): DecodedJWT { + return request.headers["Authorization"].obtainToken() +} + +fun ApplicationCall.obtainUserId(): Long { + return obtainToken() + .getClaim(USER_ID_ARG) + .asLong() +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt new file mode 100644 index 0000000..336c515 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt @@ -0,0 +1,44 @@ +package ru.shadowsparky.vbox.backend.presentation + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.response.respond +import io.ktor.server.routing.routing +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import ru.shadowsparky.http.domain.HttpException +import ru.shadowsparky.vbox.backend.di.AuthEntryPoint +import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint +import ru.shadowsparky.vbox.backend.presentation.routing.setupAuthMethods +import ru.shadowsparky.vbox.backend.presentation.routing.setupBaseMethods +import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo + +val routingLogger: Logger = LoggerFactory.getLogger("routing") + +fun Application.configureRouting( + routingEntryPoint: RoutingEntryPoint, + authEntryPoint: AuthEntryPoint +) { + install(StatusPages) { + exception { call, cause -> + routingLogger.error("http exception occurred. returns ${cause.httpCode}", cause) + call.respond( + status = HttpStatusCode.fromValue(cause.httpCode), + message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка") + ) + } + exception { call, cause -> + routingLogger.error("error occurred. returns 500...", cause) + call.respond( + status = HttpStatusCode.InternalServerError, + message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка") + ) + } + } + routing { + setupBaseMethods(routingEntryPoint.videoApi) + setupAuthMethods(routingEntryPoint, authEntryPoint) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Serialization.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Serialization.kt new file mode 100644 index 0000000..bcd316d --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Serialization.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.vbox.backend.presentation + +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation + +fun Application.configureSerialization() { + install(ContentNegotiation) { + json() + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt new file mode 100644 index 0000000..078b42a --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt @@ -0,0 +1,67 @@ +package ru.shadowsparky.vbox.backend.presentation + +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.routing.Route +import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.pingPeriod +import io.ktor.server.websocket.timeout +import io.ktor.server.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import kotlinx.coroutines.CompletableDeferred +import ru.shadowsparky.backend.data.TokenVerifier +import ru.shadowsparky.backend.domain.USER_ID_ARG +import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler +import ru.shadowsparky.vbox.backend.data.SessionCache +import ru.shadowsparky.vbox.backend.di.WebSocketEntryPoint +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import kotlin.time.Duration.Companion.seconds + +fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with(socketEntryPoint) { + install(WebSockets) { + pingPeriod = (15).seconds + timeout = (15).seconds + maxFrameSize = Long.MAX_VALUE + masking = false + } + routing { + hashMapOf( + EventType.SEARCH to searchEventHandler, + EventType.RECENT to recentlyEventHandler, + EventType.SAVED to savedEventHandler, + EventType.USER_TAG to userTagEventHandler, + EventType.MOVIE_TAG to movieTagEventHandler + ).forEach { (key, handler) -> webSocket(key, handler, tokenVerifier) } + } +} + +private fun Route.webSocket( + eventType: String, + handler: BackendRemoteEventHandler, + tokenVerifier: TokenVerifier +) { + val path = when (eventType) { + EventType.SEARCH -> RemoteEventHandler.SEARCH_CHANGED + EventType.RECENT -> RemoteEventHandler.RECENTLY_CHANGED + EventType.SAVED -> RemoteEventHandler.SAVED_CHANGED + EventType.USER_TAG -> RemoteEventHandler.USER_TAG_CHANGED + EventType.MOVIE_TAG -> RemoteEventHandler.MOVIE_TAG_CHANGED + else -> error("Unsupported type $eventType") + } + webSocket(path) { + val frame = (incoming.receive() as Frame.Text).readText() + val userId = tokenVerifier.verify(frame).getClaim(USER_ID_ARG).asLong() + val session = SessionCache.Writer { text -> outgoing.trySend(Frame.Text(text)) } + handler.put(userId, session) + val deferred = CompletableDeferred() + try { + outgoing.invokeOnClose { deferred.complete(null) } + deferred.await() + } finally { + handler.remove(userId, session) + } + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt new file mode 100644 index 0000000..c79825b --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt @@ -0,0 +1,50 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.authenticate +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Routing +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import ru.shadowsparky.vbox.backend.di.AuthEntryPoint +import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint +import ru.shadowsparky.vbox.backend.presentation.AUTH_JWT_NAME +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository +import ru.shadowsparky.vbox.shared.domain.HeathCheck + +fun Routing.setupAuthMethods( + routingEntryPoint: RoutingEntryPoint, + authEntryPoint: AuthEntryPoint +) = with(routingEntryPoint) { + val default = authEntryPoint.authTokenRepositoryFactory.create() + post(AuthTokenRepository.REGISTER_PATH) { + call.respond(default.register(call.receive())) + } + post(AuthTokenRepository.LOGIN_PATH) { + call.respond(default.login(call.receive())) + } + post(AuthTokenRepository.UPDATE_PATH) { + call.respond(default.update(call.receive())) + } + post(AuthTokenRepository.REVOKE_PATH) { + default.revoke(call.receive()) + call.respond(HttpStatusCode.OK) + } + authenticate(AUTH_JWT_NAME) { + + get("/chech") { call.respond(HttpStatusCode.OK) } + get(HeathCheck.PATH) { call.respond(HttpStatusCode.OK) } + + post(AuthTokenRepository.CHANGE_PASS_PATH) { + authEntryPoint.authTokenRepositoryFactory.create(call.obtainUserId()) + .changePassword(call.receive()) + call.respond(HttpStatusCode.OK) + } + setupSearch(searchFactory) + setupRecentlyWatched(recentlyFactory) + setupSavedMovie(savedMovieFactory) + setupTagsRouting(userTagFactory, movieTagFactory) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt new file mode 100644 index 0000000..833bb44 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt @@ -0,0 +1,32 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respond +import io.ktor.server.routing.Routing +import io.ktor.server.routing.get +import io.ktor.server.util.getOrFail +import ru.shadowsparky.vbox.shared.domain.VideoApi + +fun Routing.setupBaseMethods(api: VideoApi) { + get(VideoApi.VIDEOS_PATH) { + val p = call.parameters + val obj = api.fetchNewVideos(p[VideoApi.SEARCH_ARG]) + call.respond(obj) + } + get(VideoApi.DETAILS_PATH) { + val p = call.parameters + call.respond(api.fetchDetails(p.getOrFail(VideoApi.ID_ARG).toLong())) + } + get(VideoApi.LINKS_PATH) { + val p = call.parameters + call.respond( + api.fetchVideoLinks( + p.getOrFail(VideoApi.ID_ARG).toLong(), + p[VideoApi.SEASON_ID_ARG]?.toLong() + ) + ) + } + get("/") { + call.respond(HttpStatusCode.NotFound) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/RecentlyWatchedRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/RecentlyWatchedRouting.kt new file mode 100644 index 0000000..166f7d1 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/RecentlyWatchedRouting.kt @@ -0,0 +1,48 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import kotlinx.coroutines.flow.first +import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository +import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRequest +import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedResponse + +fun Route.setupRecentlyWatched(recentlyFactory: RecentlyWatchedRepositoryFactory) { + get(RecentlyWatchedRepository.GET_INFO) { + val p = call.parameters + val recentlyWatchedId = p[RecentlyWatchedRepository.MOVIE_ID_ARG]?.toLong() + val seasonId = p[RecentlyWatchedRepository.SEASON_ID_ARG]?.toLong() + val repo = recentlyFactory.create(call.obtainUserId()) + val list = if (recentlyWatchedId != null) { + repo.queryRecentlyWatched(recentlyWatchedId, seasonId).first() + } else { + repo.getAllRecentlyWatched() + } + call.respond(RecentlyWatchedResponse(list)) + } + post(RecentlyWatchedRepository.WRITE) { + val info = call.receive() + recentlyFactory.create(call.obtainUserId()).writeAll(info.info) + call.respond(HttpStatusCode.OK) + } + post(RecentlyWatchedRepository.REMOVE) { + val repo = recentlyFactory.create(call.obtainUserId()) + call.receive().info.forEach { + repo.remove(it) + } + call.respond(HttpStatusCode.OK) + } + post(RecentlyWatchedRepository.TOGGLE) { + val repo = recentlyFactory.create(call.obtainUserId()) + call.receive().info.forEach { + repo.toggleInfo(it) + } + call.respond(HttpStatusCode.OK) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SavedMovieRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SavedMovieRouting.kt new file mode 100644 index 0000000..226a90c --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SavedMovieRouting.kt @@ -0,0 +1,43 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.util.getOrFail +import kotlinx.coroutines.flow.first +import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.IsSavedResponse +import ru.shadowsparky.vbox.shared.domain.RemoveRequest +import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository +import ru.shadowsparky.vbox.shared.domain.SavedMoviesResponse +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails + +fun Route.setupSavedMovie(savedMovieFactory: SavedMovieRepositoryFactory) { + post(SavedMovieRepository.REMOVE) { + val saveRequest = call.receive() + savedMovieFactory.create(call.obtainUserId()).remove(saveRequest.movieId) + call.respond(HttpStatusCode.OK) + } + post(SavedMovieRepository.WRITE) { + val saveRequest = call.receive() + savedMovieFactory.create(call.obtainUserId()).save(saveRequest) + call.respond(HttpStatusCode.OK) + } + get(SavedMovieRepository.QUERY_SAVED) { + val p = call.parameters + val saved = savedMovieFactory.create(call.obtainUserId()) + .isSaved(p.getOrFail(SavedMovieRepository.MOVIE_ID_ARG).toLong()) + .first() + call.respond(IsSavedResponse(saved)) + } + get(SavedMovieRepository.QUERY_ALL) { + val saved = savedMovieFactory.create(call.obtainUserId()) + .getAll() + .first() + call.respond(SavedMoviesResponse(saved)) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SearchRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SearchRouting.kt new file mode 100644 index 0000000..d5ca0ad --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SearchRouting.kt @@ -0,0 +1,42 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import kotlinx.coroutines.flow.first +import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.SearchDeleteRequest +import ru.shadowsparky.vbox.shared.domain.SearchQueryRequest +import ru.shadowsparky.vbox.shared.domain.SearchQueryResponse +import ru.shadowsparky.vbox.shared.domain.SearchRepository + +fun Route.setupSearch(searchFactory: SearchRepositoryFactory) { + get(SearchRepository.SEARCH) { + val p = call.parameters + val userId = call.obtainUserId() + val search = searchFactory.create(userId) + call.respond(SearchQueryResponse(search.search(p[SearchRepository.QUERY_ARG] ?: "").first())) + } + post(SearchRepository.ADD_TO_SEARCH) { + val userId = call.obtainUserId() + val request = call.receive() + searchFactory.create(userId).addToSearch(request.query) + call.respond(HttpStatusCode.OK) + } + delete(SearchRepository.CLEAR) { + val userId = call.obtainUserId() + val request = call.receive() + val repo = searchFactory.create(userId) + if (request.query == null) { + repo.clear() + } else { + repo.deleteFromSearch(request.query!!) + } + call.respond(HttpStatusCode.OK) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/TagsRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/TagsRouting.kt new file mode 100644 index 0000000..e7725c2 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/TagsRouting.kt @@ -0,0 +1,79 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import kotlinx.coroutines.flow.first +import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory +import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.tag.AddRequest +import ru.shadowsparky.vbox.shared.domain.tag.DeleteRequest +import ru.shadowsparky.vbox.shared.domain.tag.EditRequest +import ru.shadowsparky.vbox.shared.domain.tag.LinkRequest +import ru.shadowsparky.vbox.shared.domain.tag.LinkedMovies +import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository +import ru.shadowsparky.vbox.shared.domain.tag.TagResponse +import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository + +fun Route.setupTagsRouting( + userTagFactory: UserTagRepositoryFactory, + movieTagFactory: MovieTagRepositoryFactory +) { + setupUserTag(userTagFactory) + setupMovieTag(movieTagFactory) +} + +private fun Route.setupUserTag(userTagFactory: UserTagRepositoryFactory) { + get(UserTagRepository.LIST) { + val userTag = userTagFactory.create(call.obtainUserId()) + call.respond(TagResponse(userTag.userTags.first())) + } + post(UserTagRepository.ADD) { + val userTag = userTagFactory.create(call.obtainUserId()) + userTag.add(call.receive().tag) + call.respond(HttpStatusCode.OK) + } + post(UserTagRepository.EDIT) { + val userTag = userTagFactory.create(call.obtainUserId()) + val edit = call.receive() + userTag.edit(edit.id, edit.newTag) + call.respond(HttpStatusCode.OK) + } + delete(UserTagRepository.DELETE) { + val userTag = userTagFactory.create(call.obtainUserId()) + userTag.delete(call.receive().id) + call.respond(HttpStatusCode.OK) + } +} + +private fun Route.setupMovieTag(movieTagFactory: MovieTagRepositoryFactory) { + get(MovieTagRepository.QUERY) { + val movieTag = movieTagFactory.create(call.obtainUserId()) + val movieId = call.parameters[MovieTagRepository.MOVIE_ID]?.toLong() + val tags = movieTag.query(checkNotNull(movieId) { "movieId required" }).first() + call.respond(TagResponse(tags)) + } + get(MovieTagRepository.QUERY_MOVIES) { + val movieTag = movieTagFactory.create(call.obtainUserId()) + val tagId = call.parameters[MovieTagRepository.TAG_ID]?.toLong() + val ids = movieTag.queryMovies(checkNotNull(tagId) { "tagId required" }).first() + call.respond(LinkedMovies(ids)) + } + post(MovieTagRepository.LINK) { + val movieTag = movieTagFactory.create(call.obtainUserId()) + val link = call.receive() + movieTag.link(link.movieId, link.tagId) + call.respond(HttpStatusCode.OK) + } + delete(MovieTagRepository.UNLINK) { + val movieTag = movieTagFactory.create(call.obtainUserId()) + val link = call.receive() + movieTag.unlink(link.movieId, link.tagId) + call.respond(HttpStatusCode.OK) + } +} diff --git a/apps/vbox/backend/src/main/resources/application.conf b/apps/vbox/backend/src/main/resources/application.conf new file mode 100644 index 0000000..3058540 --- /dev/null +++ b/apps/vbox/backend/src/main/resources/application.conf @@ -0,0 +1,8 @@ +ktor { + deployment { + port = 8181 + } + application { + modules = [ ru.shadowsparky.vbox.backend.MainKt.module ] + } +} diff --git a/apps/vbox/backend/src/main/resources/logback.xml b/apps/vbox/backend/src/main/resources/logback.xml new file mode 100644 index 0000000..678bbd4 --- /dev/null +++ b/apps/vbox/backend/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq new file mode 100644 index 0000000..bdca057 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq @@ -0,0 +1,80 @@ +-- Создание таблицы movie + +CREATE TABLE movie ( + movie_id BIGSERIAL PRIMARY KEY, + poster_url VARCHAR(256) NOT NULL, + description TEXT NOT NULL, + title TEXT NOT NULL, + flags INT NOT NULL +); + +-- Создание таблицы user + +CREATE TABLE "user" ( + user_id BIGSERIAL PRIMARY KEY, + login VARCHAR(256) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at BIGINT NOT NULL, + flags INT DEFAULT NULL +); + +-- Создание таблицы recently_watched + +CREATE TABLE recently_watched ( + recently_watched_id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + movie_id BIGINT NOT NULL, + episode BIGINT DEFAULT NULL, + season BIGINT DEFAULT NULL, + UNIQUE (user_id, movie_id), + FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (movie_id) REFERENCES movie (movie_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Создание таблицы saved_movie + +CREATE TABLE saved_movie ( + saved_movie_id BIGSERIAL PRIMARY KEY, + movie_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + UNIQUE (movie_id, user_id), + FOREIGN KEY (movie_id) REFERENCES movie (movie_id) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Создание таблицы search_info + +CREATE TABLE search_info ( + search_id BIGSERIAL PRIMARY KEY, + query VARCHAR(256) DEFAULT NULL, + user_id BIGINT DEFAULT NULL, + UNIQUE (query, user_id), + FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE refresh_tokens ( + token_id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at BIGINT NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE user_tag ( + id bigserial NOT NULL, + user_id bigint NOT NULL, + tag varchar NOT NULL, + CONSTRAINT user_tag_pk PRIMARY KEY (id), + CONSTRAINT user_tag_user_fk FOREIGN KEY (user_id) REFERENCES "user" (user_id) +); + +CREATE UNIQUE INDEX user_tag_tag_idx ON user_tag (tag,user_id); + +CREATE TABLE movie_tag ( + id bigserial NOT NULL, + movie_id bigint NOT NULL, + tag_id bigint NOT NULL, + CONSTRAINT movie_tag_pk PRIMARY KEY (id), + CONSTRAINT movie_tag_movie_fk FOREIGN KEY (movie_id) REFERENCES movie(movie_id), + CONSTRAINT movie_tag_user_tag_fk FOREIGN KEY (tag_id) REFERENCES user_tag(id) +); diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/movie.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/movie.sq new file mode 100644 index 0000000..1e8500e --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/movie.sq @@ -0,0 +1,10 @@ +insertMovie: + INSERT INTO "movie" (movie_id, poster_url, description, title, flags) VALUES (?, ?, ?, ?, ?); + +selectMovie: + SELECT * FROM "movie" WHERE movie_id = ?; + +updateMovie: + UPDATE "movie" + SET poster_url = ?, description = ?, title = ?, flags = ? + WHERE movie_id = ?; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/recent.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/recent.sq new file mode 100644 index 0000000..a0660fd --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/recent.sq @@ -0,0 +1,23 @@ +selectAll: + SELECT * FROM "recently_watched" WHERE user_id = ?; + +selectByMovieId: + SELECT * FROM "recently_watched" WHERE movie_id = ? AND user_id = ? AND season IS NOT NULL AND episode IS NOT NULL; + +selectByMovieIdAndSeason: + SELECT * FROM "recently_watched" WHERE movie_id = ? AND user_id = ? AND season = ? AND episode IS NOT NULL; + +selectByIds: + SELECT * FROM "recently_watched" WHERE movie_id = ? AND episode = ? AND season = ? AND user_id = ?; + +insertRecentlyWatched: + INSERT INTO "recently_watched" (user_id, movie_id, episode, season) VALUES (?, ?, ?, ?); + +deleteRwByIds: + DELETE FROM "recently_watched" WHERE (movie_id = ? AND episode = ? AND season = ? AND user_id = ?); + +deleteRecentlyWatchedById: + DELETE FROM "recently_watched" WHERE recently_watched_id = ? AND user_id = ?; + +deleteRecentlyWatchedByMovieId: + DELETE FROM "recently_watched" WHERE movie_id = ? AND user_id = ?; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/refresh_tokens.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/refresh_tokens.sq new file mode 100644 index 0000000..083d422 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/refresh_tokens.sq @@ -0,0 +1,19 @@ +selectByHash: + SELECT * + FROM refresh_tokens + WHERE token_hash = ?; + +insertToken: + INSERT INTO refresh_tokens(user_id, token_hash, expires_at, revoked) + VALUES (?, ?, ?, FALSE) + RETURNING token_id; + +revokeToken: + UPDATE refresh_tokens + SET revoked = TRUE + WHERE token_id = ?; + +revokeAllForUser: + UPDATE refresh_tokens + SET revoked = TRUE + WHERE user_id = ?; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/saved_movie.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/saved_movie.sq new file mode 100644 index 0000000..3d37c77 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/saved_movie.sq @@ -0,0 +1,13 @@ +-- videobox."saved_movie" definition + +selectByUserId: + SELECT * FROM "saved_movie" WHERE user_id = ? ORDER BY saved_movie_id; + +selectByUserIdAndMovieId: + SELECT * FROM "saved_movie" WHERE user_id = ? AND movie_id = ?; + +addSavedMovie: + INSERT INTO "saved_movie" (movie_id, user_id) VALUES (?, ?); + +removeSavedMovie: + DELETE FROM "saved_movie" WHERE user_id = ? AND movie_id = ?; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/search.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/search.sq new file mode 100644 index 0000000..026f834 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/search.sq @@ -0,0 +1,14 @@ +selectAll: + SELECT * FROM "search_info" WHERE user_id = ? ORDER BY search_id; + +selectByQuery: + SELECT * FROM "search_info" WHERE query = ? AND user_id = ? ORDER BY search_id; + +insertSearchInfo: + INSERT INTO "search_info" (query, user_id) VALUES (?, ?); + +deleteSearchInfo: + DELETE FROM "search_info" WHERE query = ? AND user_id = ?; + +deleteAll: + DELETE FROM "search_info" WHERE user_id = ?; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/tags.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/tags.sq new file mode 100644 index 0000000..ced3b19 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/tags.sq @@ -0,0 +1,47 @@ +getUserTagsByUserId: +SELECT id, user_id, tag +FROM user_tag +WHERE user_id = ?; + +getUserIdByTagId: +SELECT user_id +FROM user_tag +WHERE id = ?; + +insertUserTag: +INSERT INTO user_tag (user_id, tag) +VALUES (?, ?); + +updateTagText: +UPDATE user_tag +SET tag = ? +WHERE id = ?; + +deleteTagById: +DELETE FROM user_tag +WHERE id = ?; + +getTagsForMovie: +SELECT ut.id, ut.tag +FROM movie_tag mt +JOIN user_tag ut ON mt.tag_id = ut.id +WHERE mt.movie_id = ? AND ut.user_id = ?; + +getMoviesByTagId: +SELECT mt.movie_id +FROM movie_tag mt +JOIN user_tag ut ON mt.tag_id = ut.id +WHERE mt.tag_id = ? AND ut.user_id = ?; + +linkMovieTag: +INSERT INTO movie_tag (movie_id, tag_id) +SELECT ?, ut.id +FROM user_tag ut +WHERE ut.id = ? AND ut.user_id = ? +ON CONFLICT DO NOTHING; + +unlinkMovieTag: +DELETE FROM movie_tag +WHERE movie_id = ? + AND tag_id = ? + AND tag_id IN (SELECT id FROM user_tag WHERE user_id = ?); diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/users.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/users.sq new file mode 100644 index 0000000..4f0d6fc --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/users.sq @@ -0,0 +1,16 @@ +selectUserByLogin: + SELECT * FROM "user" WHERE login = ? LIMIT 1; + +selectUserByUserId: + SELECT * FROM "user" WHERE user_id = ? LIMIT 1; + +addUser: + INSERT INTO "user" (login, password_hash, created_at, flags) VALUES (?, ?, ?, ?); + +removeUser: + DELETE FROM "user" WHERE login = ?; + +updatePassword: + UPDATE "user" + SET password_hash = ? + WHERE user_id = ?; diff --git a/apps/vbox/client/androidApp/.gitignore b/apps/vbox/client/androidApp/.gitignore new file mode 100644 index 0000000..0265f6b --- /dev/null +++ b/apps/vbox/client/androidApp/.gitignore @@ -0,0 +1,4 @@ +keystore.properties +release.keystore +*.keystore +build \ No newline at end of file diff --git a/apps/vbox/client/androidApp/build.gradle.kts b/apps/vbox/client/androidApp/build.gradle.kts new file mode 100644 index 0000000..c5a3a4b --- /dev/null +++ b/apps/vbox/client/androidApp/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.convention.android.app) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.koin) + alias(libs.plugins.convention.ktor.client) +} + +android { + buildFeatures { buildConfig = true } + namespace = "ru.shadowsparky.videobox.multiplatform" + defaultConfig { + applicationId = namespace + minSdk = libs.versions.proj.min.get().toInt() + targetSdk = libs.versions.proj.target.get().toInt() + versionCode = 35 + versionName = "1.18" + } +} + +dependencies { + implementation(libs.activity.compose) + implementation(libs.material) + implementation(project(":apps:vbox:client:shared")) + implementation(project(":apps:vbox:common")) + + implementation(project(":libs:base")) + implementation(project(":libs:ui")) + implementation(project(":libs:http-client")) + implementation(project(":libs:video-player")) + debugImplementation(libs.ui.tooling) + compileOnly(libs.ui.tooling.preview.android) + testImplementation(libs.koin.test) + testImplementation(libs.mockk) + testImplementation(kotlin("test-junit")) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.runner) +} diff --git a/apps/vbox/client/androidApp/proguard-rules.pro b/apps/vbox/client/androidApp/proguard-rules.pro new file mode 100644 index 0000000..bf6dd8c --- /dev/null +++ b/apps/vbox/client/androidApp/proguard-rules.pro @@ -0,0 +1,41 @@ +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE +-dontwarn org.slf4j.impl.StaticLoggerBinder +-dontwarn java.lang.management.ManagementFactory +-dontwarn java.lang.management.RuntimeMXBean +-dontwarn kotlinx.atomicfu.** +-dontwarn io.netty.** +-dontwarn com.typesafe.** +-dontwarn org.slf4j.** +-dontwarn com.sun.nio.file.SensitivityWatchEventModifier +-dontwarn org.xmlpull.v1.** +-dontwarn javax.xml.namespace.** +-dontwarn androidx.test.platform.app.InstrumentationRegistry +-dontwarn androidx.window.extensions.WindowExtensions +-dontwarn androidx.window.extensions.WindowExtensionsProvider +-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation +-dontwarn androidx.window.extensions.core.util.function.Consumer +-dontwarn androidx.window.extensions.core.util.function.Function +-dontwarn androidx.window.extensions.core.util.function.Predicate +-dontwarn androidx.window.extensions.layout.DisplayFeature +-dontwarn androidx.window.extensions.layout.FoldingFeature +-dontwarn androidx.window.extensions.layout.WindowLayoutComponent +-dontwarn androidx.window.extensions.layout.WindowLayoutInfo +-dontwarn androidx.window.sidecar.SidecarDeviceState +-dontwarn androidx.window.sidecar.SidecarDisplayFeature +-dontwarn androidx.window.sidecar.SidecarInterface$SidecarCallback +-dontwarn androidx.window.sidecar.SidecarInterface +-dontwarn androidx.window.sidecar.SidecarProvider +-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo + +-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent { *; } +-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnSaved { *; } +-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnRecent { *; } +-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnSearch { *; } diff --git a/apps/vbox/client/androidApp/src/main/AndroidManifest.xml b/apps/vbox/client/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c7c272e --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/vbox/client/androidApp/src/main/ic_launcher-playstore.png b/apps/vbox/client/androidApp/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..5c19190a04db29dd50f40180dfc4d5742014ec36 GIT binary patch literal 7857 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 literal 0 HcmV?d00001 diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt new file mode 100644 index 0000000..479748c --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt @@ -0,0 +1,34 @@ +package ru.shadowsparky.videobox.multiplatform + +import android.app.Application +import android.os.StrictMode +import com.google.android.material.color.DynamicColors +import org.koin.android.ext.koin.androidContext +import org.koin.core.annotation.KoinApplication +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.koin.plugin.module.dsl.startKoin +import ru.shadowsparky.domain.AppConfig + +@KoinApplication +object VBox + +class App : Application() { + override fun onCreate() { + super.onCreate() + DynamicColors.applyToActivitiesIfAvailable(this) + startKoin { androidContext(this@App) } + if (BuildConfig.DEBUG) StrictMode.enableDefaults() + } +} + +@Module +class AppModule { + + @Single + fun provideAppConfig(): AppConfig { + return object : AppConfig { + override fun isDebug(): Boolean = BuildConfig.DEBUG + } + } +} diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/MainActivity.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/MainActivity.kt new file mode 100644 index 0000000..14d2b8f --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/MainActivity.kt @@ -0,0 +1,101 @@ +package ru.shadowsparky.videobox.multiplatform + +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.lifecycle.lifecycleScope +import com.arkivanov.decompose.retainedComponent +import kotlinx.coroutines.launch +import ru.shadowsparky.koin +import ru.shadowsparky.vbox.shared.presentation.Nav +import ru.shadowsparky.vbox.shared.presentation.RootComponent +import ru.shadowsparky.vbox.shared.presentation.RootFactory +import ru.shadowsparky.vbox.shared.presentation.UrlRoutes +import ru.shadowsparky.vbox.shared.presentation.nav.Route + +class MainActivity : ComponentActivity() { + private lateinit var root: RootComponent + + override fun onCreate(savedInstanceState: Bundle?) { + edgeToEdge(false) + super.onCreate(savedInstanceState) + root = retainedComponent { componentContext -> + val routeFromIntent = obtainRouteOrNull(intent?.data) + val route = if (routeFromIntent == null) { + listOf(Route.default) + } else { + listOf(Route.default, routeFromIntent) + } + koin.get().create(componentContext, route) + } + if (openSavedIfNeeded(intent) || openUrlIfNeeded(intent.data)) { + intent = Intent(intent) + .setData(null) + .setAction(null) + } + setContent { + Nav(rootComponent = root) + edgeToEdge(isSystemInDarkTheme()) + } + lifecycleScope.launch { + root.onFinish.collect { + finishAndRemoveTask() + } + } + } + + private fun edgeToEdge(isDarkTheme: Boolean) { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + Color.TRANSPARENT, + Color.TRANSPARENT, + { isDarkTheme } + ), + navigationBarStyle = SystemBarStyle.light( + Color.TRANSPARENT, + Color.TRANSPARENT + ) + ) + } + + private fun openSavedIfNeeded(intent: Intent?): Boolean { + if (intent?.action == "ru.shadowsparky.vbox.action.OPEN_SAVED_MOVIES") { + root.nav(Route.Bookmark(), true) + return true + } + return false + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (!openSavedIfNeeded(intent)) { + val uri = intent.data ?: return + val route = obtainRouteOrNull(uri) + if (route != null) { + root.nav(route, true) + } else { + openUrlIfNeeded(uri) + } + } + } + + private fun openUrlIfNeeded(uri: Uri?): Boolean { + uri ?: return false + if (uri.path?.startsWith("/video/") == true) { + root.nav(Route.Overview.Player(url = uri.toString())) + return true + } + return false + } + + private fun obtainRouteOrNull(uri: Uri?): Route? { + uri ?: return null + return UrlRoutes.parseUrl(uri.toString(), basePath = "/") + } +} diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt new file mode 100644 index 0000000..a88ee98 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.videobox.multiplatform.preview + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import ru.shadowsparky.ui.components.TextPreference + +@Preview +@Composable +fun SwitchPreferencePreview() { + TextPreference(Modifier, "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", "summary") +} diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt new file mode 100644 index 0000000..c1d319d --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt @@ -0,0 +1,64 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package ru.shadowsparky.videobox.multiplatform.preview + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import ru.shadowsparky.vbox.shared.presentation.search.AppBarWithSearch +import ru.shadowsparky.vbox.shared.presentation.search.OldSearchInfo +import ru.shadowsparky.vbox.shared.presentation.search.SearchAppBar + +@Preview +@Composable +fun AppBarWithSearchPreview() { + AppBarWithSearch( + title = "TitleWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", + isDefault = false, + onBack = {} + ) +} + +@Preview +@Composable +fun AppBarWithSearchWithBackPreview() { + AppBarWithSearch( + title = "TitleWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", + onBack = {} + ) +} + +@SuppressLint("RememberInComposition") +@Preview +@Composable +fun InternalAppBarPreview() { + SearchAppBar( + TextFieldValue(""), + FocusRequester(), + {}, + {} + ) +} + +@Preview(showBackground = true) +@Composable +fun SearchInfoPreview() { + OldSearchInfo( + modifier = Modifier, + info = listOf( + "Почему дора дура?", + "клиинка", + "симпсоны", + "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW" + ), + PaddingValues(0.dp), + onClick = {}, + onDeleteRequested = {} + ) +} diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SeasonOverviewScreenPreview.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SeasonOverviewScreenPreview.kt new file mode 100644 index 0000000..e57c7a2 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SeasonOverviewScreenPreview.kt @@ -0,0 +1,19 @@ +package ru.shadowsparky.videobox.multiplatform.preview + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import ru.shadowsparky.vbox.shared.domain.model.Season +import ru.shadowsparky.vbox.shared.presentation.overview.season.SeasonsInfo + +@Preview +@Composable +fun SeasonsInfoPreview() { + SeasonsInfo( + paddingValues = PaddingValues(0.dp), + seasons = listOf(Season(listOf(), 23, "Lalalal")) + ) { + + } +} diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/VideoScreenPreview.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/VideoScreenPreview.kt new file mode 100644 index 0000000..6c00d37 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/VideoScreenPreview.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.videobox.multiplatform.preview + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import ru.shadowsparky.vbox.shared.presentation.list.ErrorAndRetry +import ru.shadowsparky.vbox.shared.presentation.list.Loading + +@Preview +@Composable +fun ErrorPreview() { + ErrorAndRetry( + errorMsg = "Неизвестная ошибка" + ) {} +} + +@Preview +@Composable +fun LoadingPreview() { + Loading() +} diff --git a/apps/vbox/client/androidApp/src/main/res/drawable/baseline_saved_search_24.xml b/apps/vbox/client/androidApp/src/main/res/drawable/baseline_saved_search_24.xml new file mode 100644 index 0000000..80d9d23 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/drawable/baseline_saved_search_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/apps/vbox/client/androidApp/src/main/res/drawable/baseline_video_library_24.xml b/apps/vbox/client/androidApp/src/main/res/drawable/baseline_video_library_24.xml new file mode 100644 index 0000000..69b8b7a --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/drawable/baseline_video_library_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_foreground.xml b/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..fdd9b90 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml b/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..fdd9b90 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/vbox/client/androidApp/src/main/res/drawable/ic_outline_remove_red_eye_24.xml b/apps/vbox/client/androidApp/src/main/res/drawable/ic_outline_remove_red_eye_24.xml new file mode 100644 index 0000000..efe7c38 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/drawable/ic_outline_remove_red_eye_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/vbox/client/androidApp/src/main/res/layout/activity_main.xml b/apps/vbox/client/androidApp/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..18c33cc --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/layout/activity_main.xml @@ -0,0 +1,10 @@ + + + + diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..f30783b --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..260a46b93746e78615ed995b71d53566f6b0a700 GIT binary patch literal 1169 zcmV;C1aA9@P)1i@y>(M0{Jl^RgsSkX2(DZy3WBBXx|D8QbtSkFx)Qo7v_HD)MfgocL9M!U zr;#dA&=e9ym+ricU$oq08`zIV}};NLT7FigiKrz z^gps%5mcxs%4e-JaR3*f3W~*I!^mn?&=+*(azR04wIpbNfB!2wbF-jmH2Mu$r64_0 zkO5MV0aB0wQjh`m6?ArXMh6E6w70jH6Gul!y1ui(o`Mz^7pb+im8z?&sj8|fC+h0z zsIRY&_&9arn81F5c6WEFva*seM7}5}C{QJdW0}Byf+i*=)Ib;_pI|0gSZ?+cG(0>^ ze!pKAFjuhLa844(Gr*aGjGn&<#|b()IiZ=E843o2YO8HGBWQeloC1M>Zu@OGP7n^Z zrlv-ffmYjYMi7rho12@|+}un)pHKJvO*m2z4jNsd)wY`vr0uIH3ibB(QdwDk89cwP`bHVh37WqkYhw4lSoL+bACzV#Btvx1hEmelJkaUT)S2}&lDG&nezaY>1I zO3=>E4s~^PQAtUO?zv2ON>D>XgXv2Q4NnQOf@=hgjEvm+Ze$hQzQqSg@i+{ypP=RC zWhyN#)dkFz_V#x5ivaOB46vUdfGc!J!c4+)%LVj-d))XUKs*K$*i#T#U0qd|8_r3& zFf}!0Woc>HSCCDRf(*DjLHG)$wzgIkR8&-?O3DYgeLFoptsBoGcP9wo%e;KQrQ^hm zbB{QdMebG*PM(m2nIz5xtGH?gX<)hGoRlwcZ%7=&BK8nuW27Jhq#y&NAOoZz1Ee4W zq#y(GBFOc>)nGwEWVIwnQ54tCAY_7|_4Re@Gl)OA4!HdXbt@|?Uy;?4pl~?+TI-A@ zi32zmWWi4|6NvY$P|f0qMY#= zvZgYdugS?t2zuDr+1VV6#iF?A#Eo%u>}0U4;_*0c`^GvtI$DvjzrWx7@=C9kmX;E3 zo-z#s0|ReoXJ^M278a)G=H|YKLZPX2=I7`Ccr5;HcAvk|XR7^mP8N-O{?LSkir-O%J&ZaHsz`8DvOhF@I9d jqF~z%$dV;XmLuvHME}$LpJgNz00000NkvXXu0mjfpnxCi literal 0 HcmV?d00001 diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..c5d564e688985aaa2221657ee8731f58d76242b1 GIT binary patch literal 3094 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 z\U%()exfbF?B0bl(+S_9=XDfDV%g*ra_4dyolhn0`fBnGwF1>_nT=(&RuZD9X{ z@0(5{tkw2fq8#zhO_^=?>T$r~v z+=GLiKodA|)ow)XRG{reL9};y0>63anbns}k#VJN`JqITs@2W0`<<75bldlTKfPZL zYJXpCA57Jz;w!oK{O{+kCLbFrCrdyu@Njd&^N7Yz?6b_8x{wRn9wZ9rHi#b5K;1)1 zr)pBqN9MaJt&;F%EO(Km(?eC$>Fdi54g5~@IJz=|Pr{!n`w@dM3Pt|)>99S+VAW@S zMN;KKq6BpkgJy|{>d@v4P<*%?K(cN|Tw%kpw2I5|mGbhwVIW;H#laMBxs$=5w z$oeB4sqm$z6rHL++rk{c=YpBB%_PYbqh{PRR$XoHv&^t!d1ZPHn_wr#sb=0*rBO6~ zV&&vh_IZb;zk>=%e=I&PNGdc@xg*LNyxmCg!UNXr*`LMFe70)y;KP#m)bF(CK?Mmq zs^=F9i;=D^2F#5KHaGo7mTA6B16{}cwSKCky?5qA1puwZh20V!DzT!L#-Myh?@%!$ zM|so)-7J6=!y^~5cIQOJ7ho7GfYM>9aIaspU?5WReUIyi%b?%T<*u~{-?jbwlE)HM zcmVRJ9Mq;`NvXK-wNdp6lxuB+w5?OC11NuUoJa?4buIaZ*NZ5>bG01 zy5={uqC_Y%munCrR_83fG9jT%<6ANF%%<=Yif2>cs6t7J;N!F;3gk(c)r}>+ zJd$J3%sts{DvjRxQWX+(P)G8jmvn9KPsi0|_8?P{%V3y?DZ;7d;8j;3Wu} zXc;)by_p1bimHn&nGI8eOb$m@70z*clU9!Y3ZgVgjGD?yc)hm`#T_4CDV<>&`K$2I5R5L+Mne8%74){uxnMl+q&|Y zv$x^qhU{g;@aZ9ulQ89jWW*>ga!w}>@BB%{Wa|mQwG5|NmzCs7{3D6n2NH)22W0By_4ep=0&(mQd-A}J?-J` ziPNJ$yaat>1e^Blv;1TS(PW>p##Az<`A3)$_nUlWGNh&xcgGyAxUxL&+A1=$`ML6v zqwF}9@|ap=R(pr(-xk|hJ)p^4aenH7pPee8Ti^up4cMxbb}jB|Dn-jy&|pgfq(COn zpENig(0;OB8R6!PPDQgK(H)WchmZYxH%#W2Bgs`EUV-9lZSd~vR?lURyM4pr)`=he zV%3@z-aT(vc@=oqS!h1=lRstNg*sL?*UxzOBaFFlx>5JoURv*cE>KeO@!b5ur&lvt{&2tPD;4fqi&mX2}L;1M- zhax)X>Rqy?wqpj2*o>9tGQ`rPa)dt>iWb9jMEMfEuNh|-=wc-xBT+^N%LTUzyftLL zZ_iKL204hZe=~>g>kb~%o6MT``}$G2K#0Ibq7~C2D76Ib-o1_-2T4{T_Bv4=MxtR(p zGxGv>&wbl-C8I%u=qU{#>}7&SeH}#8;=Ni&TlHRo&8JUD2Mt879DcM=(BKvzcAs z{Fqu`M!bZtPZwkSow|?U!bbDXh3GhlJ7ylE0r$`IL3g_eN!Fnp9p^ zo~=g4ZM-nT^F|FQkvjQ%{)$fh07OPNg@<8Wz2bAd?KSMZGv-4m4tYmBzEcxi!dqUE zlgXEO{=-*?3tnMQvs||FIOJDt(6=GZ>zV7hnF1Y;VY{T#6BFUHf@^0lzV;mn#?XX~O3do1 z$aPp@7T@>+uo!o}hB@8tKj-4aK7!%H!+o^K$Q6&(+wx zG|GbIaPd2^m)AvlNr-VvyCB3|&^46L51390r{6EQs>Cg%z|R(zsXHWkJ7Uy7{EG#a z`V*3^jlqvj0;8Qsm;{14+jZ@E(1f^h=llb#o36*dd6-VCg2UV1EYk;w^bRC93S@7 z=jzqY>NQHc2>-sss4tb9SYcckb3mtx(%rWO*L0eXar}t1rA2tyn|B3??w;pETdgAz zej{;l{bD%ASE>-vcy$cR`fd!37oXP}QA$<$2<1SC&EpL_gYpj~|GyuoUy;gRY#8=C T$PEF%G(ZMAMu=K%r^NpQ03wvp literal 0 HcmV?d00001 diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_banner_foreground.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_banner_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..55999420a4fc704dd9748a3ca37c705f381982d7 GIT binary patch literal 6915 zcmd6M_cvT$*ge7MF^CuuMi(V|?__jQLlAWcCWMF@y)%p&y|*EH2+^W5L?1m$v?O}( zqkZSIzUy7@zwrKY*S+`Ld(OG{dCq?J-UqI$tx8Vvgaii%hg@9^qK|`vtAIVPCjwxP zfB&3O;@~_PREH?O^0wH^A@`=4&OzPh`vD$(4>6()VR9zWOI=hGJDe3o!w2L5N8Z@!&+cC@{wcSqq^DkN1 z!r0tREmOBClC6I5=qDAIGq>CF7-}M*8paXdO0q zaW{n+8Poq#S-HC>>wE70(E<KIgyed)_oU_oZ9(*2;1hv#iuDE-v!W5l~Hp7}weU{4^*LK>cdgYr5QUHCNU* zV!GggVH%%#H~)`Ar3sEN+u_mC(T~fY9r`hci39@oto$pF+o7e*mj%?E+F`zD+cefj zie-+o)vNCpzAM-FRJ?j*T*&fM5%GW+hRcMJ#?ECizp&$MXI3@+)p(|?uO~$MhlY8K zuC?_l>39iDz87;~!oUuVW@++6uJ?LA>RXD)4nqtN^K)uj86F;u3O|y1M$I|0!B^9dCe0{y8cZg#L%`u2QD3WsMY@_}G=E-T<-4gS;w9 zu5iyvNbTQ57bV^dz<2@PtKqZ}ZyMgw9QhP#Oyi^^kbUB0Wo3}v`hQ}=Hr zO}SIy@yY<9e_tdUMN<;bX!u`!(-`A!XyC5bZ7-;|*v$)msX-g#T6M%&!b5J@V!8Dm zNga0#Wa(!5?J@xpZ0%@EzDQY#@Djuw8yp*>;5Vr&*5)y)RA5=Hcn=4y*u={|4%jX$ z?PienM9if|-3WAk=F~|oKiQ&J5T9E!MN*ZyTv#A$7Br?p)#~%~+iFmSg$N*588JzG@RB9a1Xo{QK+y`R8*7@78YJ? z@z{y_c=RU{7plF|?tlH5V2T{@6U21cJ~!6*YD?-3yhL3ZDGztmj@qPR*4&+CGs1Up z&7A=M*Xy^vGo|8;I_ISp(&^aT*ICdDT9R&@g1Wl8!$}=^hW+t^7m#1Z$2~YHN~-aU zBYEO37P@G|I>6?x)QiS>4Uv}ucP6~3tKxS-7Ay)uf{Gg(_CZznu+;kbHeWAGkI!JQ z<|Aq&T>w#mCZbHlA9D(S27Va;Vlw)iE#bPR>9O1K*hXNj6w0#6LXu z;d&b_ShX>$!PA>=8R@jkf&|B)OXBQujNNrx^mYwQ@65|IA2q*i`__9$cla*oLjBK1 zX-yTxNkAs#XfV(a(>5Wk0p_lvv8i5_=Q?{_)XG3AlX7E6(K24ngbK)6@UTXFZVEPh z$lcPs9DgML%mJ_7eS4~`h1b>8)D&WF9zbV>1u^fT)aUW|_1tVGco47Gd2()$EsZzC zc(&3m`k9<4v&JH*ZLA(4hdpl58_qLy8j{PDjuiByLGa~K z_fvJG3x(+*$s?u@}@xmBkSQU{v1X_Op4 zS*Zd|5ZD0#6pV{1$_5>kXUw<{M2m4*NU{ZORGQ3NJt@zgU%d?)z~9)|=$PvW3~Y3M zUCw3`)YNray>_p0$|aB%g-(HZc_z)(ptGQC26tmOzIhY+g3cTjh)Ep5U}JGHP~O6v z16Xjhk2{j+XO(n>^ObxQ<@E3)-|Jj)OE2otA@%?M{nmqf-QC%b&H9;DVbUP+sn5Di zb|6mA2a}P+TW_tMXE(#2GGT^+*FeJgC`X3aL}+rC?@6*i3`1UJW#u^y4Nc9Am>}LF zc8cDYhpVoyV=mtYX%y(lUD$+1;dvd~ZD3KryhvG1>` z9He;TR5FRPPHdwXg~OJKntrTZ-8El#7PMt2YS1Pecb%FmiRPmxSX?jxy2p@bCs zU&y}d#pC|_^Czt4$MIm4kj0-;+<=>-{vp7_r%`+#uyN*t)$=lwfxte9DjK+Krb2R% zv5fvHrJiJ874#P`UKS47`24vHGpi{r*$Cdm-t$zGdo3^ zJK>GJ76;-`1nZjcAr_pPozCmG(M76}Nl8gpO(oS%lDlJlIO0i+v?KIHJs0PS3pPxVU8@mlBjmM z;Pqy%d1rH?(gvApXP5Z)4?=SVBi8)4B)V*2ZF2wYeCYa>MPkEJ3@Ipi!t9mK#|J5E zic3oy<$X>!Pv=$ag4R*76+6cqCph?6^f zYALR%35-p#4jE8Q@2s)-MNQ#{ZM$7r0l9$byMQ7DPcv zWo)-l`dai2d{c(|OkF^;=skOsmynRq{K0a^eE~|0vgKu#7-bDRYX{lax;mlychlwI zRUi0$F*L}kWp0e#4O6I$A3(>{5^o$F^mEb(685tZMDgMu?a;u7Dq!q(zoP@*sSxG- zgS{p0bIidn4UnY-#`D>XpPV?5eTO5WIn83(1#x8kE-BzfKKi;&fr>(Fcc~sWDd6#0 zyu6_^;3q#GFF$`xz)>Hqs#qa>Txx)5Tl`UBy5e`LvrFrs#wc`K!?~*&Vw#wCD_rYt z0+6nTPxKGoCO2#zUxGID;G;Q}3Lki2kbR0#b8j}67LU-drF}}AL+Qi3mUBYOXC|TR zar7YY$uPakkAe;uc_C+>pHN{eoP@$kBH-pS&7ef9OF|E5^8FP#(5}>L=|d=4ZORu; zL%gZ90L3Va@jC|--BMgJcPchLJU__p8MBPOWq(F2`0esjT6-uFoNV}S3}MN9C$AnM zoZ263Ot{}b|59X>Uf6CJX+N4xeDf)&xw)BG$41@oWMeGe!G?-tZ@DAT-p-DrE)$KK z(PuWq>y?plydm$tFB%!q?8B+GGs^4MV?oJu7)y|UEnrpPR|n?Y1AYA=GozBJGd-ry zW#2D9P0NdFyQ8|cdp#aO08Li?2(YPf>y;zLF<)hhaL5#`YW3XnDTw|M5JSK8_n(eh zE!o$?LKYd@0pM*c#VhY*xqlQ=3+Z1HaDQWCanSmV&8PS?Nf{QolrhvMUwJF?svxpu zASS@|*_GRXLVxS3zG&z5Ue?(Q%G--@b9KNU1}??loBQ=hhc+mu^J)zE6W25LK(9xtttVwlUhi}euhaTysC(>q9_at`p(>n43-`Zr;SbiLpjJ)Sa}Fk(p>)xZD3XZt0`4qh$DqI;W-7UMr5f&aY9 zk@DC!GBf+}So}u}v3Dvho)eVB|8h0*ploN7VfLr%(hUn89)vC0RgR6h(kBZ4w`ij> zD-E7|oG2re&7PA8kme=z_0bv~6XOXoZTWek&4=0nDqnwoN~ZJs_i!`Di#kZ)SPk%c zjFRa%`U3dCHt-CegzWiDlCz6er|uDk9zrVHWbKT{hKg z5`ww+rS|9oC-}WunKe{ggg3FLxixt6>g!aR^2|B?cdEn{e-%iyt9&IR98h?e2XDJI zeoR;Oycd@x;-L*Ta=I247Jo#7Az`1qpzXxo?}kcVW_5>$hX<4Bz+J!hP*`Jzg7FCB z5Klx&C^MHeR}NQI8;QY-I(>Jmed`%w@T1ELWsRX;W5eJq6@L3NHAHQL0~)`?n8~LH z-)wQ$$2=W`N<381cQ5tL@F)!4Z*XILQV6b;GvPj?B^>oa`s~jh{yTRWJ*!`s2$pR z;s>;b>P!7zaM&rpCKPssbu{JYTglbsY6~Bhn|!nLKLaqKaj79}pkkwHGdlD``B#R9 zG=o2Xngs2H?5sCgl!Xxeyb68^X(~9xRkaOr!5E{t>KZqpHkE^taN2IirmI?KuL2X! zCjz{~DQ=#$9&KDIUSpXH-@9rV`d^5@k7-gh5un*?EJM zYTn3gRra`!&aqoD1p6H-=5VS1$pWS)X#E#Q2FZ`bw%uXGmot3|?!2Mu0Ik-)DjaOHPL)vjDb(!L1m=*>3U*RT0m&l`RILte>>LYKo)DrFZ0R} zK?SxQrIeb*CxV*5iaH4g;WrxG<*o_7Kn7J4p;!^Dyketp-2t$W z?EsS*;fW7W$&@I{bG`(LG#A1aGha)E&B%@d5iHp1_nTR4EjS+=8w+f0 zX=yR>3K!gP>4_lUxjx+reXDFfa>5x_ue(jfqCvat+Rq@qx6tUqiR#Zrp`V7ij$*F@ zWuU!8!~8wP_8f9V%pT^Q52G2eENY0Cmse(PF^NSY51aJhmXlFwm;O&{ej_IwVF@jg zA94iw#IWvSYcesTGhZUpa7}*x%4Qfb^}bmDtw2GV+Z_?Jm;j?^un?SwLpxi-)FYBY z%!3+o?X@>=csaltzZiPbeU-7HPQ*;v`Lv~zcfb95-&&eR?Wsg8l8b2X_xJY;{j92S zPk&dNOIIR$-%(_Onb|l}iO(6a;dq{fwK*coShYi8!`xWL^Jyc;YX(aL0!TN4PXxze zfJ$rnvkXStNKkT4j_d|ji2aRi(?pzOv-{6f2m$H8a?kaR=e`*miYKgLZ1_|J-oW*@ zSbML{XRQ~$JT^46P*B)4k7{~{zc)BCa=VFE1&=8CqMg6pYIy^dk{%c+A9fDy@69*t zh&znuGaDMm?8WBfWRuJ=Wj*!yY*Dfi7PEIUhY z^1C{g|MT~+e=%YwhQH&cPCvt#Xvqli3zi7YITW|(UzAPe;p403<>tmbID1`fb~S&x zHOXm>)%F=HYT{V0y@=uI4r`c*ga9MN!n<;6HknVjwf}Vm(R-_SM%ikxAhdUnLiTk0 z!fq`0_LZmS`B;izd(!96-<+^2)=|uPvFUutYuP-s;|R0X7qc;f)w_c|6!L!H?E<;; z(A$h9Gh0vBr%x&Rkw=tDcWe1O@GTDSZjzGRl?!#W=w8{BYR0Ugn*K9#T{|(|jCl|9 z7I!*Z;=pweCsoO0RoQR5D>NR0dkuI}t-f z=n7?nDlN2_7xkUg;v4bBhNMG8hiU7-X_|~`Vtn>|<8$M0Qf{KcYii(Pgii#?GiZSNkIEfSvWj>~r)T#1wyH`eX;T`15{x0Br!ugcr zwJF$lWKeKeMurv>cKNh~)5n?RtZ^coZ85bI(mo>tWPwadFJJM7=p?D^m^c90V3b&0UEO@};6aT%LtP~n&YPZ|zQ8+( zN}VV>CJvAUtgo-fMn*=yMs`LBa7<24ex4)&$07+>TU(3z{r+!|oe=_@PoF-mOOk+N zkqL+m4i0{c>_|WcApsUc0xX6ESPTiU7!qJHB*0=wfW?piiy;9PlT!kAcX!#dXV2K( zyLVZCf4?c*zkeUMj#Yi@C1(V@e*Ky?Ha4=t!a|14Fbk*C$=q%?d-CLoY8&*B69RDL zcXf5C!ZH(&$79HukP`wnHa6Jl)2CG-nF-DW3`fds(MnDTc=P6sDxge&?*!$xXoUo% z0SORj72tBYRG;a=Rsy2YXeww4Jjx-pX%#R$Jj_Z`22qVk;4%Q5N$>ah(sc+udk04 z7Zs8JFe~gsCUH{?iwXwxcS zYio<$x^;_{l$5C2v_Jwxn^pl=u3TaH`T45PwL$_!n^plvK>|b@36Pal0*<<8V-|P< zPr031$q515+uN+RwpJC8ncxXLJO-)UPOapG0KR^9@7_ICNM_R9+?={$r`%4hMTrNj1QMVC zX99*Jd?#cAJe9571|!Kj0l5YVuox0xF(klZNPxwV0E;037DECoh6Gp)39uLvU@;`X zVq^lMh66e{!7Q+XX79e5*FN0q zSzWz)^%SyGWU0II&z(E>F`u4oYHDhE^ytyA-1NfI($e7S!5)5Dk>@}Dk>_f b+^By6;`_Lq{2MOZ00000NkvXXu0mjf6AA!& literal 0 HcmV?d00001 diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..fa51166f5d1293dc52d7ca633c966d6f595f3bb0 GIT binary patch literal 4339 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 literal 0 HcmV?d00001 diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..053c6b4f44443a47cb2e5f981dfe40ef8337617d GIT binary patch literal 3703 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 PgA%YZw?)>PdM5lIkyX1g literal 0 HcmV?d00001 diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..8a52cb31a5e0512705384f70ce1f38de21724dfa GIT binary patch literal 10405 zcmYMaby$?`7cKnEFu>45cXyY>i_}mf(x`-_ASE?4(hMmL1}P<_l!SmZNQp?Qgv5X- z-5@a3dHkL4T;E*R#2>StJNCWzUTf|9nV~+4jF^cS001)WI~vB|clWm4hizz6-a~ z(asH@GUOc>)Z`V$?1d7Y1BuR^UN$PKR~(PXG@4*{hI+qVF5M2`V;TLJE^_)O-)Apm z>`0ktby3>KaWUY}MQ673;2@qoZRwND=6Y?d zZuU>IJ{s6dm?wvJ<&B%3-aYc+G^o*vp~>ws?%>ipVna+Ca9$0nR_Y*192Jg|0FDi~ zWWSCb!#13ecm7ljx9X6z>d2ysk#fq4H9I*8f36iy!l8j+<0Iw2e7P^)8xYN-#qK-Ls=^*@!SvhKK<@T9aO{D8`PRn1kqd18J!TJ_=vWp|p)zS;W%5hZ& zZ8pO2BkpIzJWmh#O&dgb&%LWthvn9|FfyMCPqi_yyjc4d*;=eZ4}CW?Od zIsKj37CiE~C3d=Il?6+rmPpL&F0avZy1^v%hdsxrCHV zlRrHrwQyt7AQd9_C|RLY+N@zV^DpD(+kYs#1Rs%derV6#;ggmz@%7BZpTF%%X!WzO zI?Mq6t?`X_@`#%*%sVICK9aa^OkmIC0z=Jy&XU&ilha9Urz)aJo%xk`izKZ`?pRi+ z@5*aT!E~)ncz)P!>qKh8*#m$c<1Fps{ADexEIB)YV&1cuyYWq!O)rHUE;)d!EKu|E z9p2Z0pZZhz&ObYI{+cvIwvi^+o-0MqnyMtqypEx?S$a$SJtB*;q$qjf2WJXSEjuxJ zLo3ufAa9@2{HYy5P$ig_i>cuMN_N`<}B~>$u~K*5Fp;sVJi}Y|% zwVz|TRgd`?>4Z2dd|+wWesR1p_4-3-m9D*RLyP8=Bh6LV{WP2Niys6_Q86hcNYRUt zZ0Qb-@JdZ-7tH0nfrzN+l@0ESA9+pn`!JB8g57JLL}UM+hsUnCW>@8{w;U=<_F-4J zlb+*?2by7H(KL{c>ZaN-gM{nTMfG;rx#Xe!h16(cPo$y5%Z&rWAyUe(u<0SzAf`|X z^1xDf*1{U$d3fp2o>Cf@fQU%D|KaNAr3gF>$)bJ6CRmJvNj0P`DWt ziD$`C8}a%-A4gs#VJ`T6-7F4l1Wp>6++mKW#ku*)-*9$MCb zD%WkT)}v<3r6oYlgzRaH-1!DiFgG#zvDd@c!@8Bjqat__zJ98R49goR>w1ch--kC% zNs*@#uB;*>^MRtp-kef`awsh)cQSd0Zq+obt9J~#F|o2@=fC;wZK%#G5tm(VlSIE` zsbBYzQ6ow>1^JNU7}XFPvb|NWq}jyyy)@EJ<|#? z`GC>aw+D^y-3ur2AWF#|%R+~G`}kN;uTc<3d=&Fkzm8RTEtYMI_) z2{&qPZ1ipA=u+rM_6T<4RIRPqZlkf?YuGHsC;hkh%1RYtVq#9~nNwxvj^&CA4fST6 zZ5_%D3dHW{>TVO(?96@m!eY+JcX*}zw>zMeqsu##CXe`|tZG9yMW2i^mU#E$bk@RY ztiMrn>6Ed-)4W<;*_@l{>baud@t|0L^HNyu2bZbXD|JS3!SY z-}|7GO~&|@&)rvx0ZXB7Yoo7YWxcpn#r^+oo4?A)IDD6?#PRpUEAI1z3MXfgG%949?aoe{n)h+}aNz2CYL&XB zC5!8LVPquDB5VXk&9A>Inbw&+4^t-LpaVCOMaQqoVvCXUKU~+*=Ywbc?V$C_Yx$VcWJyB0Z3@aN{F`>LQyDx=J6=jba+-9Zl zDBBqm=|rU()0uldf+4N_DlJU{vuP?<)MOGjo7^5A>P@|(&@T&@k^$7L+Hg{_d*;eqUN<9WaDD(&!0sD{6qSCKMBL_d8?D?Z9*ngp`HVU$S6|x zRmYkAv&a7a{@NiSiu#$AuanaDKWrKq9=~O{QW84iJ{XZ6W4MF;+O3uiu9OVD*Be9P zcJX)T=xxf0B^x0Rd`U$~NhwJPZJBg^7qAATiGg`_|4ndRUL1r}W-s0zvbDwM6AbaQ30W-z_G?I^lF&XZ+Y3(ol z!q^b~`=2ocP0q!jucxP17$kdGw@2xH{Wl&aiDOp@Q%ty@nVO*_dG^u}+QR5a3&_W7 ze&I%?SQas0k?&lUGFy;b-4)>{o8Ox~0#ycxpAtXO0ZeA?ADch`teuC4*w6N-M;G^} zarzXoOHbEWcdtnqrAjE@iXz`HaYxxaE6)!aR)w0{j$}!WEG;eV-#A8P<44$L5jZvl z>@B_s-s@-<{N7xHt@05;wy3CFpZ&TCjJm+H^=wbY6>4s7Zb~O&wx5>Tu)tAw_MiZhJRN<3_cvGeBI>IJLyvH8!Of*p=d9k1lIW8! z_-f`AUv?MT7?Z8b{GREG0eNCpv1Hy`)3xE5n-)th%&R3A>ieQ%V%EG`;rH59K<~@= z7SeGrky}lM8zj!rM8HyR7{IM^{Z^5_?6VvD86S9PjgoZ+lgJEHPXkilD~c-mqQPfv z6fP8Ba9jq%Dz1Nt5!acLPmGV3c&YQ)9}@9=eQIQ6ByVG?JmA#p6*JEQdyaKJ)h-2b zG^5ZXIx};C>SvR?eJJi&GGx(8q78swDo*?wOl_bQHc2|JbBcP>Bp2V3qI8o532}Lx zQ?=aHiN4oE4Fhat8E*TFxX!oKEeC%fBY{WwPZ^Zca9wO1^Sk zt6;UE!1zb`cd!$GQ%hTa(CvIuPcBaP#&s+*?BsMq71fmm(apVn zv40D4$2Mp%N(jmao;E$p?*<`5MFV|(-S~%Z zv~DsVT!wc)$LEMV3StXd=lpbHiC+l^JP;{fvyCp)i-K4}a#L3&)sVN>SEox$(NJvl zU7#kibN(H`Z`yv1+p2PzDvRAxU^bxwnlgF#`S&*GJ_uXao4P}6k3HW99;`^TzITAy zc3Z#z8ag_uoImEy&K1jZqtUc5U`SOSjNP@ba!cE~ex3+sW{VS5Gu%z^>261=Sdb= z1<@DwM2C2bLI7Z13~s4{lGytMK!dM>G^%I#rEs0}x7vr$vrp{T<7r{Ct-c$Rw>W{^ zPKk!zTzn2lrgL`$L|^CSLtP+5r!PmwwX?8Qj>@to2PiHsM&Q9LKYBaVAtHF8wyH$k z+TG_&`)K(-NgUB>(K+~BSD~xSLc;)ImvG!NDDd6yOP9L-r6p5N`y()?SDSW z0rsC;b{~PIEU654L0O*uqc#t4hAj(@|NfGRnurk-{J(a1^Iu+xT9}RHE155}`jI)v zQO0BWv&vEMLgoKPUd41dEolv&<`*w?wW zkQieH5B-<|golGZuCi$M(6^X*Csf4;pkEOr^|rLMKZ?&EDkPyv!{QIu$ zw?Pm6$4}>%57!^@LT0vOk0M7qaGRB(71xl!<`{zq_^MN=wUw0>xBu;HP2cHK_}Fgi zme{AY1$^+3;0-Ny1RIdCD=Q9zP6%90N4-bT?Oz^^HH!-g4I4uL4PxEu`>ai|r>7@& ztd-h?8X%h&0XfvxavXc->rHk9HFXrS!yB4L@d@#ej)um~6f|mdaCCBN$g~c6c8 zKx0cpDFBgYH=&HsV@dJ@c}9II2o`QCc9Btp6a%D&G7wS*3BbJ2(I)KtcT|bcwTEr) z!ZDaf?*gN7F|cma$f?Omu~c@IYp-{~4Ov+X*G&usLhwQlU*B7Flk9?3*D`)tuY9Irciv` z7j(OF6$g+GS=l49X!GCEe|PF0UADZu{L&isJeK0fCvx3sD=#lkJes5ghAn-c0fgZ; z_?tq`4?W*5R^!JBcfk3`*gaJ55q8cCDJfl%QNp?5vY{N?q>#(qfG+8fv0UVCbW!pM z0Tlqn)`R>-GQo=fUWWw{xoHrkZjQewN8rX}Y1Eg#=Z|FD7>VVm^XkYYz&rp@H-q9=wm}s5O z^5yI&iZww?>Q!P1*&gr!zYf`aTfX}yNUnC1E9+K1F&P<|CSL_((L#SBeOL3{f3w9R zX#%?P=0w)Vi|`IN{~4Fuh&QR)QWyrSHKe5>9!ChhhQ1uQR}>w)qG|6=0fy16kXMAX zB}co{letD0vHu*t-t897Tns(6icNp8q~(Y?zA?yGd`2HVQr6l`JFh9*a97aKMUna& zNWVC6vq9hAJs6`t)u~1h%G(nF?@Ry2&|6D{Eagg|v8rKxt)1?=@TW}kCtWvJd$ooO z7AO=-9e6MbX#dLQpwwt&k+jn=;ELv50i1{c`JiI~`pD+5u$jWsK57H3gHNoiBsVro zwE|2@O6Nn|sq+S>l@+Ry2ZgU$5yN2Ti60O|X^BIq&zxU~%`9Oh!p6bw#>p zwIIuq@$x9~)+4h3vdf+3rFySl19gu+#U6-G%f}yUfOAN|KfXdgEX%Fz_n^%r$mOKmswpoOAW;RMA~v5ik3?@3~*4X{~c93h@_+!M&MiHh9NId3pEILba1F+ly(e(rx4fJkyXxJ zcimzn96E_eYhDZzE-NhT{^pr%1lhB!dlaii>yg<$iu}B|I(H+Au&3EO4kj2Ni|5@iFqYfW2#t z5vXkF?|CxNh!6(sUY-3)bz5wIdPBgEFIRUl<+7XF=q*`~|D!Fyla7^jhqk+c3@2N!Ac3Q&KRQwoPi6V&@#s!eaz7 z+()OUag$u2wv?He-bj=1&2-X=K{=!)@_3PyrL)wNTUfnCrLKr^c$y zE&Tk8O_;$c!y<@)nvSOg6K!bC&x_5b%Je8n!#pvP{}2{#H`^w`Su<^%Wc*=+aAosEs`uD-h_qR z+OF%?FN--IGJzQ+JgA#*-|mCJ@04J{MgwGd1orOxTU3zl;iyN(EdKGal&#|;Y0(jV zj>YBx%6{HwOo>1$kXx}IKjV!GU8hNvSzYGt4!N4yuYu%q%5`NimaEd2grB0jQ&?}S z0jynTD~U@s?ePH{*{-?1#>P~M2?>X|?CyIoRe>lt7#sIwT_#Hod_7sYxwz10v?gIz zbeEfY1fL*kPb<22Rs%d~Qb!-Xspwqsz;lGQs7FRd64(WjDE5HE-+)9e{apKRUFvl+ z5%~&>MJ9e%kgyFS-z^aMvR>8>QGl>ZGbblOGv-ljE}DMdZ4cb#Gv*%$`-E&+6Klgt zjG#gz(fI5;u*ClP6&MP3OygQnKAfDK`>JU;U)=Csb(JD7F+T46JZ5Uo%GlVrvw!l| zgDgTJT&|&Eg%}6WuI|3v8?zMUip^uV`tWw_A$0_4?1u)`L8CWXWQzXVW@0a%3@E7) z|A_J*8v_x-=sm?Fo|ZD^D=Ri|mAO>E_o&)-ntpPvJFS6nlR8?UGRB@&G@Q%FVe(!? zwY2`~;`?E@>wbq)y z^*(5-^*l_oVCyS3)v~qauntpt>2Q3t2bTR9*qa56NES(l_&32tWkyd zL@HNaxbKrzLm`z2@?YVnzmnz9GY_14B&%>wq*Xj9f$cIsh#Ir8U|IRk@U!K(r}A2` zx`-Tnns<;u<))DlU0F~UDn z{Ijq{B=I$@GC&Ow5-vdtqo)aT7aT{j4#d%S&_q99_lRcho>kQ_LhU52idK{a;%B*5 zAis$P@5486Sk)D=QPoUTrn?09^ zSel6O*F7zf$PD)2=9OQ+3{Ll!!VxXp^3f>P9>FPCCoSWPw|+4KbmSYlDtlRALv0;Q zF0{SP_YNX3bd!67a}$5Q=GJzc{IIBvlE&N)yfoRuWpV9S50Ta&I2$z+=>ngW{{;1_ za@!zvL$`+9B1xKdl8j3;>A5<4!o}eu$rdj8D17m$HszqHRy9T8=wzMF&d!#i-Aa?{ zhrX9*UMyYia}AobPbd^uc-hhtM;yoUJ4jg|rt#9O-K;&F{|uj-b9U-s#P_@2_zwVO8BfVIUgJE(ep(#cj3vChoF~6}1O(aS8-oI6idypqN4ZIRkkYmv9FtYrh7HkL*GnmW zJN#~Hvy+&T!WOUW0j5aVkDBqFnXAnq1LgH)dZ~8La=ZLu5y*R71w<8*#V+sW1F_wZ zu(H&z8YnTLc_wdr=BD=Tg*JNe_cKgXRNgAMf0{7EI?wsUDK=s{%_c`TbyL%jjrx}J zICdA*Kf+$VdNuGq@mraOEn~!``~c5?|7Cy8`|rr&m=|}S6pao(UOkQXb$S3!K2R4y zL&Kvve)MnkwxFmNlY=i!OP_^cIC1Bi(UkQB%gZ#MLH;Er4iPGdyN|oHHVAVGq8#dG zC=Xj$-rD-2DeCH!5>Z=k=`3rQcU@9}1Shnd?OR1asMf0&FYa>$JSUd+O~JTwqC(s0 z@e?g>snhG`%DOl0pE|rxB%GayTh}>$Yg(dE>H3gzh}M3aq=zvreO3LClu|owz95y_9~f4W>W@C&gZw7Y3xin^988FDvG9Po{%F$76)Ll zj0};x zDc@hME!L)Vmezn5Vs*STn&rEV_KN<9F>!A zv3u9WZd=2@`v*w(MIGr)?tVclwqr`%-P{eM)!MS6iSY<2v2GLd4Jtp57f#P4sK{94 z(fgnl{)(56&$|E9>H4jSNZHUeA$fv3wVa*Rjg2o@Z~G_x6Y4js%o|L|86>dN-rrC^ z-%x1S+{q*i<+%!dx#5M`_yyIi9OC7!jwMwusjc-i&B06vW|8f($vpYC=bWH_M_XI4 zXF!1yCc}M6>f6^vzVc~o@bk#g+Srn!_v(;Yqv#xid$KI*XQHyIO`*ZK-0)IBJdYxD*a{C>X0a=Y>zk_q38&6PGU8)_P_%= z@8lX*1>ZYm`j39^rprA;?)@>0u!w!Z*CgV((0VfWkBC)c&Wt7(kh#U~69dK76MyE6 zY)a)_KeK!#*uQtzm%K@;dja{g7;MQq`^apz zeI6g5n*rX+W()TAl9c^H_lj$Tpr0eyg177VmCsk7n+u!PD7N^l=`%weC54@>c*F>V zNqQk0PnD_PDA{qho^F9)SLI(3{<^W$bwjPNV$Y0s8B+D9SahPy*&=&+qFj#le8F~Z zR&|eV8RE*wj^?yw;xCN)h!`s92){f#@EpyR^ZW|-vV71ya+fygTB2j27Mcih_*k(Xv3}vyoz$&wuJJi?!DSKS9{2nU!3olQG zde?sMJ{vcTknpHmKY^2v?@t{lnVk;ZEO7Kkbe~U_>dfn5J{#Q^hX=%~nfjl@#qW&W zum>Nee}-&)?*DB5-nJ)J`#Im$8_%WAD492*295Z0Fn8`bxOhU?Y8Ujk=^ ztIg^4j;k9yMvaG~q7E@Elx))U`ajhRyFikJ`Ce)2w9Iymo9^n=IXw=<3pV`nEH#+! zG^L8%`+Rb6I^~R((k6YW&tI6cJ5`e-?$QrdM2%78f(E1>+rcR)QhPFP;@HE^1izAR zA>V;K(0ZWB?Xli|A^8fS;Q zh(QqCeN;sR($Ug-1q@HPMTAGqAgC_=TEx*^`;M$5iU?-MB2eX{^;m2_S4_V`3~R9( zW)`W(UDNr!QCQ?AN^Skch8D*h5B(XMU^`6;MllzPj`KM)YlcurcOV|W7nv%bGl!h- zpxHSIdUq!ECi_W$OT*yPAlMVo3w zcHLk;WdHUxMffMDee)1hh$(L8;r^ka>?TLOoY~u*NQL5>;?e)_g44e{ORUL%=c-b; zI^%L_F#aa0))wLfNf~HL1VgU@`RPYaxUzb;1D7xTwVwe~_8t)lNgH7wDJf~Y1%H|l zWT{^UEowb)v9)`fTIgTXRNLCo4 zT%=qEpYnZ(=jky9%|o2j%JGJVP|H5;egcfUF%QXtytc|(}29maLh* U!+&Y}@A@I2t*Nh3qh^cwKQkoD#sB~S literal 0 HcmV?d00001 diff --git a/apps/vbox/client/androidApp/src/main/res/values/ic_banner_background.xml b/apps/vbox/client/androidApp/src/main/res/values/ic_banner_background.xml new file mode 100644 index 0000000..15db34b --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/values/ic_banner_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/vbox/client/androidApp/src/main/res/values/ic_launcher_background.xml b/apps/vbox/client/androidApp/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/vbox/client/androidApp/src/main/res/values/strings.xml b/apps/vbox/client/androidApp/src/main/res/values/strings.xml new file mode 100644 index 0000000..1083d06 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Videobox + Сохранённые фильмы + Просмотр списка ранее сохранённых фильмов + diff --git a/apps/vbox/client/androidApp/src/main/res/values/themes.xml b/apps/vbox/client/androidApp/src/main/res/values/themes.xml new file mode 100644 index 0000000..f184558 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + diff --git a/apps/vbox/client/web/src/wasmJsMain/resources/manifest.json b/apps/vbox/client/web/src/wasmJsMain/resources/manifest.json new file mode 100644 index 0000000..590615d --- /dev/null +++ b/apps/vbox/client/web/src/wasmJsMain/resources/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Videobox", + "short_name": "VBox", + "start_url": "/", + "display": "standalone", + "icons": [ + { + "src": "icon.png", + "sizes": "192x192", + "type": "image/png" + } + ] +} diff --git a/apps/vbox/client/web/src/wasmJsMain/resources/sqljs.worker.js b/apps/vbox/client/web/src/wasmJsMain/resources/sqljs.worker.js new file mode 100644 index 0000000..d7687ac --- /dev/null +++ b/apps/vbox/client/web/src/wasmJsMain/resources/sqljs.worker.js @@ -0,0 +1,58 @@ +import initSqlJs from "sql.js"; + +let db = null; + +async function createDatabase() { + let SQL = await initSqlJs({locateFile: file => 'sql-wasm.wasm'}); + db = new SQL.Database(); +} + +function onModuleReady() { + const data = this.data; + + switch (data && data.action) { + case "exec": + if (!data["sql"]) { + throw new Error("exec: Missing query string"); + } + + return postMessage({ + id: data.id, + results: db.exec(data.sql, data.params)[0] ?? {values: []} + }); + case "begin_transaction": + return postMessage({ + id: data.id, + results: db.exec("BEGIN TRANSACTION;") + }) + case "end_transaction": + return postMessage({ + id: data.id, + results: db.exec("END TRANSACTION;") + }) + case "rollback_transaction": + return postMessage({ + id: data.id, + results: db.exec("ROLLBACK TRANSACTION;") + }) + default: + throw new Error(`Unsupported action: ${data && data.action}`); + } +} + +function onError(err) { + return postMessage({ + id: this.data.id, + error: err + }); +} + +if (typeof importScripts === "function") { + db = null; + const sqlModuleReady = createDatabase() + self.onmessage = (event) => { + return sqlModuleReady + .then(onModuleReady.bind(event)) + .catch(onError.bind(event)); + } +} diff --git a/apps/vbox/client/web/webpack.config.d/config.js b/apps/vbox/client/web/webpack.config.d/config.js new file mode 100644 index 0000000..ecb0970 --- /dev/null +++ b/apps/vbox/client/web/webpack.config.d/config.js @@ -0,0 +1,15 @@ +const TerserPlugin = require("terser-webpack-plugin"); + +config.optimization = config.optimization || {}; +config.optimization.minimize = true; +config.optimization.minimizer = [ + new TerserPlugin({ + terserOptions: { + mangle: true, // Note: By default, mangle is set to true. + compress: false, // Disable the transformations that reduce the code size. + output: { + beautify: false, + }, + }, + }), +]; \ No newline at end of file diff --git a/apps/vbox/client/web/webpack.config.d/devServerConfig.js b/apps/vbox/client/web/webpack.config.d/devServerConfig.js new file mode 100644 index 0000000..64358dc --- /dev/null +++ b/apps/vbox/client/web/webpack.config.d/devServerConfig.js @@ -0,0 +1,4 @@ +config.devServer = { + ...config.devServer, + historyApiFallback: true +}; diff --git a/apps/vbox/client/web/webpack.config.d/sqljs-config.js b/apps/vbox/client/web/webpack.config.d/sqljs-config.js new file mode 100644 index 0000000..3b0b6cd --- /dev/null +++ b/apps/vbox/client/web/webpack.config.d/sqljs-config.js @@ -0,0 +1,17 @@ +// {project}/webpack.config.d/sqljs.js +config.resolve = { + fallback: { + fs: false, + path: false, + crypto: false, + } +}; + +const CopyWebpackPlugin = require('copy-webpack-plugin'); +config.plugins.push( + new CopyWebpackPlugin({ + patterns: [ + '../../node_modules/sql.js/dist/sql-wasm.wasm' + ] + }) +); diff --git a/apps/vbox/common/.gitignore b/apps/vbox/common/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/apps/vbox/common/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/apps/vbox/common/build.gradle.kts b/apps/vbox/common/build.gradle.kts new file mode 100644 index 0000000..a4b4b6a --- /dev/null +++ b/apps/vbox/common/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.ktor.client) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":libs:base")) + implementation(project(":libs:http-client")) + } + } + val androidMain by getting + } + android { + namespace = "ru.shadowsparky.vbox.common" + } +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/VBoxCommon.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/VBoxCommon.kt new file mode 100644 index 0000000..adf6ed3 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/VBoxCommon.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.vbox.shared + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@Configuration +@ComponentScan +class VBoxCommon diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DispatcherProvider.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DispatcherProvider.kt new file mode 100644 index 0000000..12ef45b --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DispatcherProvider.kt @@ -0,0 +1,3 @@ +package ru.shadowsparky.vbox.shared.di.factory + +typealias DispatcherProvider = ru.shadowsparky.domain.DispatcherProvider diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/AuthTokenRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/AuthTokenRepository.kt new file mode 100644 index 0000000..8d38bdf --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/AuthTokenRepository.kt @@ -0,0 +1,35 @@ +package ru.shadowsparky.vbox.shared.domain + +import kotlinx.serialization.Serializable +import ru.shadowsparky.http.domain.TokenInfo +import ru.shadowsparky.vbox.shared.domain.model.LoginInfo + +interface AuthTokenRepository { + suspend fun register(loginInfo: LoginInfo): TokenInfo + suspend fun login(loginInfo: LoginInfo): TokenInfo + + suspend fun update(refresh: TokenRequest): TokenInfo + + suspend fun changePassword(request: ChangePasswordRequest) + + suspend fun revoke(tokenRequest: TokenRequest) + + companion object { + const val LOGIN_PATH = "$DYNAMIC_PREFIX/login" + const val REGISTER_PATH = "$DYNAMIC_PREFIX/register" + const val UPDATE_PATH = "$DYNAMIC_PREFIX/updateToken" + const val CHANGE_PASS_PATH = "$DYNAMIC_PREFIX/changePassword" + const val REVOKE_PATH = "$DYNAMIC_PREFIX/revokeToken" + } +} + +@Serializable +data class TokenRequest(val token: String) + +@Serializable +data class ChangePasswordRequest( + val oldPasswordHash: String, + val newPasswordHash: String +) + +const val FLAG_USER_BLOCKED = 1 diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HeathCheck.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HeathCheck.kt new file mode 100644 index 0000000..c6d5f8e --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HeathCheck.kt @@ -0,0 +1,9 @@ +package ru.shadowsparky.vbox.shared.domain + +interface HeathCheck { + suspend fun check() + + companion object { + const val PATH = "healthCheck" + } +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RecentlyWatchedRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RecentlyWatchedRepository.kt new file mode 100644 index 0000000..c304ba2 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RecentlyWatchedRepository.kt @@ -0,0 +1,47 @@ +package ru.shadowsparky.vbox.shared.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo + +interface RecentlyWatchedRepository { + suspend fun remove(info: RecentlyWatchedInfo) + suspend fun write(info: RecentlyWatchedInfo) + + suspend fun writeAll(info: List) { + info.forEach { write(it) } + } + + fun queryRecentlyWatched( + movieId: Long, + seasonId: Long? = null + ): Flow> + + suspend fun getAllRecentlyWatched(): List + + suspend fun clear() { + getAllRecentlyWatched().forEach { remove(it) } + } + + companion object { + const val PREFIX = "$DYNAMIC_PREFIX/recent" + + const val REMOVE = "$PREFIX/delete" + const val WRITE = "$PREFIX/add" + const val TOGGLE = "$PREFIX/toggle" + const val GET_INFO = "$PREFIX/query" + + const val MOVIE_ID_ARG = "movieId" + const val SEASON_ID_ARG = "seasonId" + } +} + +@Serializable +data class RecentlyWatchedResponse( + val info: List +) + +@Serializable +data class RecentlyWatchedRequest( + val info: List +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt new file mode 100644 index 0000000..d42833b --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt @@ -0,0 +1,51 @@ +package ru.shadowsparky.vbox.shared.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository +import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository + +interface RemoteEventHandler { + suspend fun notify(eventInfo: RemoteEvent) + + companion object { + const val SEARCH_CHANGED = "${SearchRepository.PREFIX}/onChange" + const val RECENTLY_CHANGED = "${RecentlyWatchedRepository.PREFIX}/onChange" + const val SAVED_CHANGED = "${SavedMovieRepository.PREFIX}/onChange" + const val USER_TAG_CHANGED = "${UserTagRepository.PREFIX}/onChange" + const val MOVIE_TAG_CHANGED = "${MovieTagRepository.PREFIX}/onChange" + } +} + +interface RemoteEventListener { + val event: Flow +} + +@Serializable +sealed interface RemoteEvent { + val userId: Long + + @Serializable + data class OnSearch(val millis: Long, override val userId: Long) : RemoteEvent + + @Serializable + data class OnRecent(val millis: Long, override val userId: Long, val movieId: Long) : + RemoteEvent + + @Serializable + data class OnSaved(override val userId: Long, val movieId: Long) : RemoteEvent + + @Serializable + data class OnUserTag(override val userId: Long) : RemoteEvent + + @Serializable + data class OnMovieTag(override val userId: Long, val movieId: Long) : RemoteEvent +} + +object EventType { + const val SEARCH = "search" + const val RECENT = "recent" + const val SAVED = "saved" + const val USER_TAG = "user_tag" + const val MOVIE_TAG = "movie_tag" +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SavedMovieRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SavedMovieRepository.kt new file mode 100644 index 0000000..27c22c6 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SavedMovieRepository.kt @@ -0,0 +1,40 @@ +package ru.shadowsparky.vbox.shared.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails + +interface SavedMovieRepository { + suspend fun save(details: VideoDetails) + fun getAll(): Flow> + fun isSaved(id: Long): Flow + suspend fun remove(id: Long) + suspend fun saveAll(details: List) { + details.forEach { save(it) } + } + + suspend fun clear() { + getAll().first().forEach { remove(it.id) } + } + + companion object { + const val MOVIE_ID_ARG = RecentlyWatchedRepository.MOVIE_ID_ARG + + const val PREFIX = "$DYNAMIC_PREFIX/savedMovie" + const val REMOVE = "${PREFIX}/delete" + const val WRITE = "${PREFIX}/add" + const val QUERY = "${PREFIX}/query" + const val QUERY_ALL = "${QUERY}_all" + const val QUERY_SAVED = "${QUERY}_saved" + } +} + +@Serializable +data class RemoveRequest(val movieId: Long) + +@Serializable +data class IsSavedResponse(val isSaved: Boolean) + +@Serializable +data class SavedMoviesResponse(val list: List) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SearchRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SearchRepository.kt new file mode 100644 index 0000000..e2bbef1 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SearchRepository.kt @@ -0,0 +1,41 @@ +package ru.shadowsparky.vbox.shared.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable + +interface SearchRepository { + fun search(query: String): Flow> + + suspend fun addToSearch(query: String) + suspend fun addToSearch(query: List) { + query.forEach { addToSearch(it) } + } + + suspend fun deleteFromSearch(query: String) + suspend fun clear() + + companion object { + const val PREFIX = "$DYNAMIC_PREFIX/search" + + const val SEARCH = "$PREFIX/query" + const val ADD_TO_SEARCH = "$PREFIX/add" + const val CLEAR = "$PREFIX/delete" + + const val QUERY_ARG = "query" + } +} + +@Serializable +class SearchQueryResponse( + val search: List +) + +@Serializable +class SearchQueryRequest( + val query: List +) + +@Serializable +class SearchDeleteRequest( + val query: String? = null +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/ServerConfiguration.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/ServerConfiguration.kt new file mode 100644 index 0000000..82dc311 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/ServerConfiguration.kt @@ -0,0 +1,22 @@ +package ru.shadowsparky.vbox.shared.domain + +data class ServerConfiguration( + val uriString: String = "https://box.shadowsparky.ru/api" +) { + fun asHttpStr(): String { + val builder = StringBuilder(uriString) + if (uriString.endsWith("/")) { + builder.removeSuffix("/") + } + return builder.toString() + } + + fun asWebSocketStr(): String { + val useTls = asHttpStr().startsWith("https") + val withoutScheme = asHttpStr() + .removePrefix("https") + .removePrefix("http") + val newPrefix = if (useTls) "wss" else "ws" + return "$newPrefix$withoutScheme" + } +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/User.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/User.kt new file mode 100644 index 0000000..0720926 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/User.kt @@ -0,0 +1,3 @@ +package ru.shadowsparky.vbox.shared.domain + +const val USER_ID_ARG = "user_id" diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/VideoApi.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/VideoApi.kt new file mode 100644 index 0000000..09bfa9b --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/VideoApi.kt @@ -0,0 +1,23 @@ +package ru.shadowsparky.vbox.shared.domain + +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails +import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse +import ru.shadowsparky.vbox.shared.domain.model.VideosResponse + +const val DYNAMIC_PREFIX = "dynamic" + +interface VideoApi { + suspend fun fetchNewVideos(query: String? = null): VideosResponse + suspend fun fetchDetails(id: Long): VideoDetails + suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse + + companion object { + const val SEARCH_ARG = "search" + const val ID_ARG = "id" + const val SEASON_ID_ARG = "season_id" + + const val VIDEOS_PATH = "videos" + const val DETAILS_PATH = "details" + const val LINKS_PATH = "links" + } +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Episode.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Episode.kt new file mode 100644 index 0000000..a442576 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Episode.kt @@ -0,0 +1,13 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Episode( + val episode: Long, + val files: List, + val title: String? = null, + val isWatched: Boolean = false +) { + val url: String = files.first().url +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/File.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/File.kt new file mode 100644 index 0000000..eb2bd4c --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/File.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class File( + val proPlus: Boolean?, + val quality: Int?, + val url: String +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/LoginInfo.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/LoginInfo.kt new file mode 100644 index 0000000..a7b6e00 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/LoginInfo.kt @@ -0,0 +1,16 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginInfo( + val login: String, + val passwordHash: String +) + +open class LoginException(override val message: String?) : IllegalStateException() + +class EmptyLoginException : LoginException("Login must be not empty!") +class EmptyPasswordException : LoginException("Password must be not empty!") +class ServerLoginException(message: String) : LoginException(message) +class UnknownLoginException(override val cause: Throwable?) : LoginException(cause?.message) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/RecentlyWatchedInfo.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/RecentlyWatchedInfo.kt new file mode 100644 index 0000000..0975227 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/RecentlyWatchedInfo.kt @@ -0,0 +1,11 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class RecentlyWatchedInfo( + val movieId: Long, + val episode: Long?, + val season: Long?, + val id: Long = -1 +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Season.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Season.kt new file mode 100644 index 0000000..ed77808 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Season.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Season( + val episodes: List, + val id: Long, + val title: String +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/ServerExceptionInfo.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/ServerExceptionInfo.kt new file mode 100644 index 0000000..f2021ff --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/ServerExceptionInfo.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ServerExceptionInfo(val msg: String) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoDetails.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoDetails.kt new file mode 100644 index 0000000..510fcb7 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoDetails.kt @@ -0,0 +1,34 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class VideoDetails( + val id: Long, + val poster: String, + val desc: String, + val title: String, + val isSerial: Boolean +) { + val isMovie = !isSerial + + val flags: Int + get() { + return if (isSerial) SERIAL_FLAG else MOVIE_FLAG + } + + companion object { + fun getStub(title: String, isSerial: Boolean = true): VideoDetails { + return VideoDetails( + id = 1234, + poster = "https://poster.ru/img.png", + title = title, + desc = "Short Story Description!", + isSerial = isSerial + ) + } + } +} + +const val SERIAL_FLAG = 1 +const val MOVIE_FLAG = 1 shl 1 diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoItem.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoItem.kt new file mode 100644 index 0000000..506f190 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoItem.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class VideoItem( + val id: Long, + val poster: String, + val title: String, + val description: String? = null, + val isSerial: Boolean = false +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoLinksResponse.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoLinksResponse.kt new file mode 100644 index 0000000..d281ac8 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoLinksResponse.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class VideoLinksResponse( + val name: String? = null, + val seasons: List? = null, + val files: List? = null, +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideosResponse.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideosResponse.kt new file mode 100644 index 0000000..17aafbc --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideosResponse.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class VideosResponse( + @SerialName("has_next_page") + val hasNextPage: Boolean, + val items: List, + val page: Int +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/MovieTagRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/MovieTagRepository.kt new file mode 100644 index 0000000..a73a043 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/MovieTagRepository.kt @@ -0,0 +1,31 @@ +package ru.shadowsparky.vbox.shared.domain.tag + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX + +interface MovieTagRepository { + fun query(movieId: Long): Flow> + fun queryMovies(tagId: Long): Flow> + suspend fun link(movieId: Long, tagId: Long) + suspend fun unlink(movieId: Long, tagId: Long) + + suspend fun clear() {} + + companion object { + const val TAG_ID = "tagId" + const val MOVIE_ID = "movieId" + + const val PREFIX = "$DYNAMIC_PREFIX/movieTag" + const val QUERY = "$PREFIX/query" + const val QUERY_MOVIES = "$PREFIX/queryMovies" + const val LINK = "$PREFIX/link" + const val UNLINK = "$PREFIX/unlink" + } +} + +@Serializable +data class LinkRequest(val movieId: Long, val tagId: Long) + +@Serializable +data class LinkedMovies(val ids: Set) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/UserTagRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/UserTagRepository.kt new file mode 100644 index 0000000..a8a7446 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/UserTagRepository.kt @@ -0,0 +1,42 @@ +package ru.shadowsparky.vbox.shared.domain.tag + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX + +interface UserTagRepository { + val userTags: Flow> + + suspend fun add(tag: String) + suspend fun edit(id: Long, newTag: String) + suspend fun delete(id: Long) + + suspend fun clear() {} + + companion object { + const val PREFIX = "$DYNAMIC_PREFIX/userTag" + + const val LIST = "$PREFIX/list" + const val ADD = "$PREFIX/add" + const val EDIT = "$PREFIX/edit" + const val DELETE = "$PREFIX/delete" + } +} + +@Serializable +data class TagResponse(val tags: List) + +@Serializable +data class AddRequest(val tag: String) + +@Serializable +data class EditRequest(val id: Long, val newTag: String) + +@Serializable +data class DeleteRequest(val id: Long) + +@Serializable +data class TagInfo( + val id: Long? = null, + val tag: String +) diff --git a/apps/vbox/common/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DesktopFileProvider.kt b/apps/vbox/common/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DesktopFileProvider.kt new file mode 100644 index 0000000..33200f1 --- /dev/null +++ b/apps/vbox/common/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DesktopFileProvider.kt @@ -0,0 +1,47 @@ +package ru.shadowsparky.vbox.shared.di.factory + +import org.koin.core.annotation.Single +import java.io.File + +@Single +class DesktopFileProvider { + private val homeUser by lazy { System.getProperty("user.home") } + private val appPackage = "ru.shadowsparky.vbox" + private var cacheDir: File? = null + + fun getCacheFileDir(): String? { + cacheDir?.let { return it.absolutePath } + return getCacheDir().apply { + mkdirs() + cacheDir = this + }.absolutePath + } + + private fun getCacheDir(): File { + getMacOsCacheDir()?.let { return it } + getLinuxCacheDir()?.let { return it } + getWindowsCacheDir()?.let { return it } + return File("cache") // fallback + } + + private fun getMacOsCacheDir(): File? { + return File(homeUser, "Library").provideCacheFile() + } + + private fun getLinuxCacheDir(): File? { + return File(homeUser, ".cache").provideCacheFile() + } + + private fun getWindowsCacheDir(): File? { + val appData = System.getenv("APPDATA") + return File(appData).provideCacheFile() + } + + private fun File.provideCacheFile(): File? { + return if (exists() && canRead() && canWrite()) { + File(this, appPackage) + } else { + null + } + } +} diff --git a/build-logic/.gitignore b/build-logic/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/build-logic/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/build-logic/convention/.gitignore b/build-logic/convention/.gitignore new file mode 100644 index 0000000..4295729 --- /dev/null +++ b/build-logic/convention/.gitignore @@ -0,0 +1,2 @@ +build +bin \ No newline at end of file diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts new file mode 100644 index 0000000..cc4ac3a --- /dev/null +++ b/build-logic/convention/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + `kotlin-dsl` +} + +dependencies { + compileOnly(libs.plugins.android.library.toDep()) + compileOnly(libs.plugins.android.application.toDep()) + compileOnly(libs.plugins.compose.compiler.toDep()) + compileOnly(libs.plugins.jetbrains.compose.toDep()) + compileOnly(libs.plugins.serialization.toDep()) + compileOnly(libs.plugins.kotlin.multiplatform.toDep()) + compileOnly(libs.plugins.devtools.ksp.toDep()) + compileOnly(libs.plugins.koin.compiler.toDep()) +} + +fun Provider.toDep() = map { + "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version}" +} + +val prefix = "ru.shadowsparky.plugin." + +gradlePlugin { + plugins { + create("conventionKmp") { + id = "convention.kmp" + implementationClass = "${prefix}Kmp" + } + create("conventionKtorClient") { + id = "convention.ktor-client" + implementationClass = "${prefix}KtorClient" + } + create("conventionCompose") { + id = "convention.compose" + implementationClass = "${prefix}Compose" + } + create("conventionSerialization") { + id = "convention.serialization" + implementationClass = "${prefix}Serialization" + } + create("conventionAndroidLib") { + id = "convention.android-lib" + implementationClass = "${prefix}AndroidLibrary" + } + create("conventionSqlDelight") { + id = "convention.sqldelight" + implementationClass = "${prefix}SqlDelight" + } + create("conventionJvm") { + id = "convention.jvm" + implementationClass = "${prefix}Jvm" + } + create("conventionKoin") { + id = "convention.koin" + implementationClass = "${prefix}Koin" + } + create("conventionKrpcClient") { + id = "convention.krpc-client" + implementationClass = "${prefix}KrpcClient" + } + create("conventionKrpcServer") { + id = "convention.krpc-server" + implementationClass = "${prefix}KrpcServer" + } + + create("conventionWebApp") { + id = "convention.web-app" + implementationClass = "${prefix}WebApplication" + } + create("conventionBackendApp") { + id = "convention.backend-app" + implementationClass = "${prefix}BackendApplication" + } + create("conventionAndroidApp") { + id = "convention.android-app" + implementationClass = "${prefix}AndroidApplication" + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/AndroidExt.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/AndroidExt.kt new file mode 100644 index 0000000..248ae78 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/AndroidExt.kt @@ -0,0 +1,39 @@ +package ru.shadowsparky + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Project + +fun LibraryExtension.setupAndroid(project: Project) { + val libs = project.findLibs() + compileSdk { + version = release(libs.findVersion("proj.compile").get().toString().toInt()) + } + defaultConfig { + minSdk = libs.findVersion("proj.min").get().toString().toInt() + } + compileOptions { + val javaVersion = JavaVersion.toVersion(libs.findVersion("proj.java").get()) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + } +} + +fun ApplicationExtension.setupAndroid(project: Project) { + val libs = project.findLibs() + compileSdk { + version = release(libs.findVersion("proj.compile").get().toString().toInt()) + } + defaultConfig { + minSdk = libs.findVersion("proj.min").get().toString().toInt() + targetSdk { + version = release(libs.findVersion("proj.target").get().toString().toInt()) + } + } + compileOptions { + val javaVersion = JavaVersion.toVersion(libs.findVersion("proj.java").get()) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/BuildFlags.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/BuildFlags.kt new file mode 100644 index 0000000..278ceba --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/BuildFlags.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky + +object BuildFlags { + const val DESKTOP = true +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/Ext.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/Ext.kt new file mode 100644 index 0000000..1451011 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/Ext.kt @@ -0,0 +1,79 @@ +package ru.shadowsparky + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Project +import org.gradle.api.artifacts.UnknownConfigurationException +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.DependencyHandlerScope +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.findByType + +internal fun Project.findLibs(): VersionCatalog { + return extensions.findByType(VersionCatalogsExtension::class.java)!!.named("libs") +} + +val Project.isAndroidApp: Boolean + get() { + return extensions.findByType() != null + } + +val Project.isAndroidLib: Boolean + get() { + return extensions.findByType() != null + } + +val Project.isWebApp: Boolean + get() { + return hasPlugin("convention-web-app") + } + +val Project.isBackendApp: Boolean + get() { + return hasPlugin("convention-backend-app") + } + +internal fun Project.hasAndroidSupport(): Boolean { + return isAndroidApp || isAndroidLib +} + +internal fun Project.hasMultiplatformSupport(): Boolean = hasPlugin("kotlin-multiplatform") + +private fun Project.hasPlugin(alias: String): Boolean { + val plugin = findLibs().findPlugin(alias).get().get().pluginId + return pluginManager.hasPlugin(plugin) +} + +internal fun DependencyHandlerScope.implementation(deps: Any) { + try { + add("commonMainImplementation", deps) + } catch (_: UnknownConfigurationException) { + add("implementation", deps) + } +} + +internal fun DependencyHandlerScope.androidImplementation(deps: Any) { + add("androidMainImplementation", deps) +} + +internal fun DependencyHandlerScope.desktopImplementation(deps: Any) { + if (BuildFlags.DESKTOP) add("desktopMainImplementation", deps) +} + +internal fun Project.applyCoroutines( + libs: VersionCatalog, + hasAndroid: Boolean = hasAndroidSupport() +) { + dependencies { + implementation(libs.findLibrary("kotlinx-coroutines-core").get()) + if (hasAndroid) { + val lib = libs.findLibrary("kotlinx.coroutines.android").get() + if (hasMultiplatformSupport()) { + androidImplementation(lib) + } else { + implementation(lib) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/KspExtensions.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/KspExtensions.kt new file mode 100644 index 0000000..4112166 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/KspExtensions.kt @@ -0,0 +1,48 @@ +package ru.shadowsparky + +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +private const val COMMON_MAIN = "kspCommonMainMetadata" + +private const val KSP = "ksp" +private const val KSP_ANDROID = "kspAndroid" +private const val KSP_ANDROID_JVM = "kspAndroidJvm" +private const val KSP_WASM_JS = "kspWasmJs" +private const val KSP_JS = "kspJs" +private const val KSP_DESKTOP = "kspDesktop" +private const val KSP_JVM = "kspJvm" + +private fun Project.hasConfig(name: String) = configurations.findByName(name) != null + +internal fun Project.kspAllPlatforms(deps: Any) { + + dependencies { + + // common + if (hasConfig(COMMON_MAIN)) { + add(COMMON_MAIN, deps) + } else { + add(KSP, deps) + return@dependencies + } + + // android + when { + hasConfig(KSP_ANDROID) -> add(KSP_ANDROID, deps) + hasConfig(KSP_ANDROID_JVM) -> add(KSP_ANDROID_JVM, deps) + } + + // web + when { + hasConfig(KSP_WASM_JS) -> add(KSP_WASM_JS, deps) + hasConfig(KSP_JS) -> add(KSP_JS, deps) + } + + // desktop + when { + hasConfig(KSP_DESKTOP) -> add(KSP_DESKTOP, deps) + hasConfig(KSP_JVM) -> add(KSP_JVM, deps) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidApplication.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidApplication.kt new file mode 100644 index 0000000..377775f --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidApplication.kt @@ -0,0 +1,53 @@ +package ru.shadowsparky.plugin + +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import ru.shadowsparky.applyCoroutines +import ru.shadowsparky.findLibs +import ru.shadowsparky.setupAndroid +import java.io.File +import java.io.FileInputStream +import java.util.Properties + +class AndroidApplication : Plugin { + override fun apply(target: Project) = with(target) { + val libs = findLibs() + pluginManager.apply(libs.findPlugin("android.application").get().get().pluginId) + extensions.configure { + val keystorePropertiesFile = File(rootDir, "keystore.properties") + signingConfigs { + if (keystorePropertiesFile.exists()) { + val keystoreProperties = Properties() + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + create("release") { + storeFile = File(rootDir, "release.keystore") + check(storeFile?.exists() == true) { "Unable to load keystore file!" } + storePassword = keystoreProperties["RELEASE_STORE_PASSWORD"].toString() + keyAlias = keystoreProperties["RELEASE_KEY_ALIAS"].toString() + keyPassword = keystoreProperties["RELEASE_KEY_PASSWORD"].toString() + } + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = true + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } + proguardFiles(file("proguard-rules.pro")) + } + getByName("debug") { + isMinifyEnabled = false + applicationIdSuffix = ".debug" + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } + } + } + setupAndroid(target) + } + applyCoroutines(libs) + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidLibrary.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidLibrary.kt new file mode 100644 index 0000000..24d7630 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidLibrary.kt @@ -0,0 +1,42 @@ +package ru.shadowsparky.plugin + +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Action +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import ru.shadowsparky.applyCoroutines +import ru.shadowsparky.findLibs +import ru.shadowsparky.hasMultiplatformSupport +import ru.shadowsparky.setupAndroid + +class AndroidLibrary : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + val isMultiplatform = hasMultiplatformSupport() + val pluginName = if (isMultiplatform) { + "multiplatform.android.library" + } else { + "android.library" + } + pluginManager.apply(libs.findPlugin(pluginName).get().get().pluginId) + if (isMultiplatform) { + extensions.configure { + android { + compileSdk = libs.findVersion("proj.compile").get().toString().toInt() + minSdk = libs.findVersion("proj.min").get().toString().toInt() + androidResources.enable = true + } + } + } else { + extensions.configure { setupAndroid(target) } + } + applyCoroutines(libs) + } +} + +private fun org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension.android(configure: Action): Unit = + (this as org.gradle.api.plugins.ExtensionAware).extensions.configure("android", configure) + diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/BackendApplication.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/BackendApplication.kt new file mode 100644 index 0000000..62babfe --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/BackendApplication.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import ru.shadowsparky.findLibs + +class BackendApplication : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("convention-jvm").get().get().pluginId) + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Compose.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Compose.kt new file mode 100644 index 0000000..78d53ce --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Compose.kt @@ -0,0 +1,28 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import org.jetbrains.compose.ComposePlugin +import ru.shadowsparky.findLibs +import ru.shadowsparky.implementation + +class Compose : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("jetbrains.compose").get().get().pluginId) + plugins.apply(libs.findPlugin("compose.compiler").get().get().pluginId) + dependencies { + val compose = ComposePlugin.Dependencies(target) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.components.resources) + + implementation(libs.findLibrary("compose-material3").get()) + implementation(libs.findLibrary("decompose").get()) + implementation(libs.findLibrary("decompose-extensions").get()) + implementation(libs.findLibrary("decompose-lifecycle-coroutines").get()) + implementation(libs.findLibrary("image.loader").get()) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Jvm.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Jvm.kt new file mode 100644 index 0000000..9ef9ca3 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Jvm.kt @@ -0,0 +1,22 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.kotlin.dsl.withType +import ru.shadowsparky.applyCoroutines +import ru.shadowsparky.findLibs + +class Jvm : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("java-library").get().get().pluginId) + plugins.apply(libs.findPlugin("jetbrains-kotlin-jvm").get().get().pluginId) + + tasks.withType { + sourceCompatibility = libs.findVersion("proj-server-java").get().toString() + targetCompatibility = libs.findVersion("proj-server-java").get().toString() + } + applyCoroutines(libs) + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Kmp.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Kmp.kt new file mode 100644 index 0000000..ae5994a --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Kmp.kt @@ -0,0 +1,32 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.kotlinExtension +import ru.shadowsparky.BuildFlags +import ru.shadowsparky.applyCoroutines +import ru.shadowsparky.findLibs + +class Kmp : Plugin { + + override fun apply(target: Project) = with(target) { + val libs = findLibs() + pluginManager.apply(libs.findPlugin("kotlin-multiplatform").get().get().pluginId) + extensions.configure { applyConfig() } + applyCoroutines(libs, false) + kotlinExtension.sourceSets.named("commonMain").configure { + kotlin.srcDirs( + "build/generated/ksp/metadata/commonMain/kotlin" + ) + } + } +} + +@OptIn(ExperimentalWasmDsl::class) +fun KotlinMultiplatformExtension.applyConfig() { + if (BuildFlags.DESKTOP) jvm("desktop") + wasmJs { browser() } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Koin.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Koin.kt new file mode 100644 index 0000000..7e4c751 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Koin.kt @@ -0,0 +1,44 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Action +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.ExtensionAware +import org.gradle.kotlin.dsl.dependencies +import org.koin.compiler.plugin.KoinGradleExtension +import ru.shadowsparky.androidImplementation +import ru.shadowsparky.findLibs +import ru.shadowsparky.hasAndroidSupport +import ru.shadowsparky.hasMultiplatformSupport +import ru.shadowsparky.implementation +import ru.shadowsparky.isAndroidApp +import ru.shadowsparky.isWebApp + +class Koin : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("koin-compiler").get().get().pluginId) + koinCompiler { + val checkSafety = isAndroidApp || isWebApp // || isBackendApp + if (checkSafety) { + println("koin check safety enabled for module ${target.name}") + } + compileSafety.set(checkSafety) + } + dependencies { + implementation(libs.findLibrary("koin-core").get()) + implementation(libs.findLibrary("koin-annotations").get()) + if (hasAndroidSupport()) { + val androidKoin = libs.findLibrary("koin-android").get() + if (hasMultiplatformSupport()) { + androidImplementation(androidKoin) + } else { + implementation(androidKoin) + } + } + } + } +} + +private fun Project.koinCompiler(configure: Action): Unit = + (this as ExtensionAware).extensions.configure("koinCompiler", configure) diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcClient.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcClient.kt new file mode 100644 index 0000000..7b77d8b --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcClient.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import ru.shadowsparky.findLibs +import ru.shadowsparky.implementation + +class KrpcClient : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("krpc").get().get().pluginId) + + dependencies { + implementation(libs.findLibrary("kotlinx-rpc-krpc-ktor-client").get()) + implementation(libs.findLibrary("kotlinx-rpc-krpc-serialization-json").get()) + implementation(libs.findLibrary("kotlinx-rpc-krpc-client").get()) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcServer.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcServer.kt new file mode 100644 index 0000000..fb1b1e4 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcServer.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import ru.shadowsparky.findLibs +import ru.shadowsparky.implementation + +class KrpcServer : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("krpc").get().get().pluginId) + + dependencies { + implementation(libs.findLibrary("kotlinx-rpc-krpc-ktor-server").get()) + implementation(libs.findLibrary("kotlinx-rpc-krpc-serialization-json").get()) + implementation(libs.findLibrary("kotlinx-rpc-krpc-server").get()) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KtorClient.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KtorClient.kt new file mode 100644 index 0000000..669fcd1 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KtorClient.kt @@ -0,0 +1,30 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import ru.shadowsparky.androidImplementation +import ru.shadowsparky.desktopImplementation +import ru.shadowsparky.findLibs +import ru.shadowsparky.implementation + +class KtorClient : Plugin { + override fun apply(target: Project) { + val libs = target.findLibs() + target.dependencies { + implementation(libs.findLibrary("ktor.client.websockets").get()) + implementation(libs.findLibrary("ktor-serialization-kotlinx-json").get()) + implementation(libs.findLibrary("ktor-client-content-negotiation").get()) + implementation(libs.findLibrary("ktor-client-logging").get()) + implementation(libs.findLibrary("ktor-client-auth").get()) + implementation(libs.findLibrary("ktor-client-core").get()) + val okHttp = libs.findLibrary("ktor.client.okhttp").get() + try { + androidImplementation(okHttp) + desktopImplementation(okHttp) + } catch (_: Exception) { + implementation(okHttp) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Serialization.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Serialization.kt new file mode 100644 index 0000000..701e04c --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Serialization.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import ru.shadowsparky.findLibs +import ru.shadowsparky.implementation + +class Serialization : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("serialization").get().get().pluginId) + dependencies { + implementation(libs.findLibrary("kotlinx.serialization.json").get()) + implementation(libs.findLibrary("kotlinx.serialization.core").get()) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/SqlDelight.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/SqlDelight.kt new file mode 100644 index 0000000..6f7f8fb --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/SqlDelight.kt @@ -0,0 +1,28 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import ru.shadowsparky.androidImplementation +import ru.shadowsparky.desktopImplementation +import ru.shadowsparky.findLibs +import ru.shadowsparky.hasAndroidSupport +import ru.shadowsparky.implementation + +class SqlDelight : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("sqldelight").get().get().pluginId) + dependencies { + implementation(libs.findLibrary("coroutines.extensions").get()) + implementation(libs.findLibrary("sql.delight.runtime").get()) + + if (hasAndroidSupport()) { + androidImplementation(libs.findLibrary("androidx.sqlite").get()) + androidImplementation(libs.findLibrary("androidx.sqlite.framework").get()) + } + + desktopImplementation(libs.findLibrary("sqlite.driver").get()) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/WebApplication.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/WebApplication.kt new file mode 100644 index 0000000..886e40b --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/WebApplication.kt @@ -0,0 +1,9 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project + +class WebApplication : Plugin { + override fun apply(target: Project) { + } +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..875164f --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,15 @@ +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e87b6f4 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,13 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.jetbrains.compose) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.koin.compiler) apply false + alias(libs.plugins.sqldelight) apply false + alias(libs.plugins.ktor) apply false + alias(libs.plugins.devtools.ksp) apply false + alias(libs.plugins.serialization) apply false + alias(libs.plugins.krpc) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..c157c29 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,13 @@ +kotlin.code.style=official +xcodeproj=./iosApp +android.useAndroidX=true +org.gradle.jvmargs=-Xms512m -Xmx4096m -XX:+HeapDumpOnOutOfMemoryError +org.jetbrains.compose.experimental.jscanvas.enabled=true +org.jetbrains.compose.experimental.uikit.enabled=true +android.nonTransitiveRClass=false +org.gradle.caching=true +org.gradle.parallel=true +org.gradle.configuration-cache=true +android.uniquePackageNames=false +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=false diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..500e909 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/248ffb1098f61659502d0c09aa348294/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ac151d55def6b6a9a159dc4cb4642851/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..8a3fe99 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,173 @@ +[versions] +activity-compose = "1.13.0" + +appcompat = "1.7.1" +coil-compose = "3.4.0" +compose-gradle-plugin = "1.11.0-rc01" +compose-version = "1.11.0-alpha03" +desugar_jdk_libs = "2.1.5" +haze = "1.7.2" +java-jwt = "4.5.2" +kotlin-version = "2.3.21" +agp = "9.0.1" + +coroutines-extensions = "1.5.5" +decompose-version = "3.5.0" +image-loader = "1.10.0" +junit = "4.13.2" +lifecycleCoroutines = "2.5.0" +material = "1.11.1" +materialVersion = "1.13.0" +media3 = "1.10.0" +mockk = "1.14.9" +jsoup = "1.22.2" +gradle-plugin = "1.5.5" +kotlinx-coroutines-core = "1.11.0" +kotlinx-serialization-json = "1.11.0" +ktor-plugin = "3.4.3" +kxml2 = "2.4.1" +logback-classic = "1.5.32" +multiplatform-settings = "1.3.0" +postgresql = "42.7.11" +runner = "1.7.0" +sqlite = "2.6.2" +sqlite-driver = "2.3.2" +ui-tooling = "1.11.1" +essenty = "1.3.0" +stately-common = "2.0.6" +proj-target = "36" +proj-compile = "36" +proj-min = "29" +proj-java = "21" +proj-server-java = "21" +datastore-preferences-core = "1.2.1" +ksp-version = "2.3.7" +koin-core = "4.2.1" +koin-compiler = "1.0.0-RC2" +kotlin-dsl-version = "5.1.2" +coreKtx = "1.18.0" +workRuntimeKtx = "2.11.2" +krpc = "0.10.2" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore-preferences-core" } + +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore-preferences-core" } +androidx-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-material = { module = "androidx.compose.material:material", version.ref = "material" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil-compose" } +com-android-application-gradle-plugin = { module = "com.android.application:com.android.application.gradle.plugin", version.ref = "agp" } +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } +haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } +java-jwt = { module = "com.auth0:java-jwt", version.ref = "java-jwt" } +jdbc-driver = { module = "app.cash.sqldelight:jdbc-driver", version.ref = "sqlite-driver" } +koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin-core" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor-plugin" } +ktor-server-cors = { module = "io.ktor:ktor-server-cors" } +ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt" } +ktor-server-auth = { module = "io.ktor:ktor-server-auth" } +ktor-server-netty-jvm = { module = "io.ktor:ktor-server-netty-jvm" } +ktor-serialization-kotlinx-json-jvm = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm" } +ktor-server-content-negotiation-jvm = { module = "io.ktor:ktor-server-content-negotiation-jvm" } +ktor-server-status-pages-jvm = { module = "io.ktor:ktor-server-status-pages-jvm" } +ktor-server-host-common-jvm = { module = "io.ktor:ktor-server-host-common-jvm" } +ktor-server-core-jvm = { module = "io.ktor:ktor-server-core-jvm" } +ktor-server-websockets-jvm = { module = "io.ktor:ktor-server-websockets-jvm" } +material = { module = "com.google.android.material:material", version.ref = "materialVersion" } +multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" } +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +compose-gradle-plugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-gradle-plugin" } +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-version" } + +decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose-version" } +decompose-extensions = { module = "com.arkivanov.decompose:extensions-compose", version.ref = "decompose-version" } +decompose-lifecycle-coroutines = { module = "com.arkivanov.essenty:lifecycle-coroutines", version.ref = "lifecycleCoroutines" } + +gradle = { module = "com.android.tools.build:gradle", version.ref = "agp" } +image-loader = { module = "io.github.qdsfdhvh:image-loader", version.ref = "image-loader" } +junit = { module = "junit:junit", version.ref = "junit" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin-core" } +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin-core" } +koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin-core" } +koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin-core" } + +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-core" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-core" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization-json" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor-plugin" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-plugin" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor-plugin" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor-plugin" } +kxml2 = { module = "com.github.kobjects:kxml2", version.ref = "kxml2" } +logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback-classic" } +ktor-plugin = { module = "io.ktor.plugin:plugin", version.ref = "ktor-plugin" } +postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" } +runner = { module = "androidx.test:runner", version.ref = "runner" } +android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlite-driver" } +coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqlite-driver" } +runtime-wasm-js = { module = "app.cash.sqldelight:runtime-wasm-js", version.ref = "sqlite-driver" } +sql-delight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqlite-driver" } +sql-delight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlite-driver" } +sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqlite-driver" } +sqldelight-web-driver = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqlite-driver" } + +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-plugin" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-plugin" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor-plugin" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor-plugin" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor-plugin" } +ui-compose = { module = "androidx.compose.ui:ui" } +ui-compose-util = { module = "androidx.compose.ui:ui-util" } + +ui-tooling-preview-android = { module = "androidx.compose.ui:ui-tooling-preview-android", version.ref = "ui-tooling" } +ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "ui-tooling" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-version" } +web-worker-driver-wasm-js = { module = "app.cash.sqldelight:web-worker-driver-wasm-js", version.ref = "sqlite-driver" } + +kotlinx-rpc-krpc-ktor-client = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-ktor-client", version.ref = "krpc" } +kotlinx-rpc-krpc-ktor-server = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-ktor-server", version.ref = "krpc" } +kotlinx-rpc-krpc-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-serialization-json", version.ref = "krpc" } +kotlinx-rpc-krpc-server = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-server", version.ref = "krpc" } +kotlinx-rpc-krpc-client = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-client", version.ref = "krpc" } + +[plugins] +jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-gradle-plugin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin-version" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-version" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqlite-driver" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-version" } +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +multiplatform-android-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +ktor = { id = "io.ktor.plugin", version.ref = "ktor-plugin" } +java-library = { id = "java-library", version = "unspecified" } +devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp-version" } +jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-version" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin-version" } +krpc = { id = "org.jetbrains.kotlinx.rpc.plugin", version.ref = "krpc" } +koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-compiler" } + +convention-jvm = { id = "convention.jvm", version = "unspecified" } +convention-kmp = { id = "convention.kmp", version = "unspecified" } +convention-ktor-client = { id = "convention.ktor-client", version = "unspecified" } +convention-compose = { id = "convention.compose", version = "unspecified" } +convention-serialization = { id = "convention.serialization", version = "unspecified" } +convention-android-lib = { id = "convention.android-lib", version = "unspecified" } +convention-sqldelight = { id = "convention.sqldelight", version = "unspecified" } +convention-koin = { id = "convention.koin", version = "unspecified" } +convention-krpc-client = { id = "convention.krpc-client", version = "unspecified" } +convention-krpc-server = { id = "convention.krpc-server", version = "unspecified" } +convention-android-app = { id = "convention.android-app", version = "unspecified" } +convention-web-app = { id = "convention.web-app", version = "unspecified" } +convention-backend-app = { id = "convention.backend-app", version = "unspecified" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f6b961fd5a86aa5fbfe90f707c3138408be7c718 GIT binary patch literal 54329 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

<5G2M1WZP5 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 F{|7XC?P&l2 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..22d945d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat Apr 10 18:10:04 MSK 2021 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# 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 + ;; + 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" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@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=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@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= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +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 init + +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 + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +: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 %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="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! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libs/android-stub/.gitignore b/libs/android-stub/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/android-stub/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/android-stub/build.gradle.kts b/libs/android-stub/build.gradle.kts new file mode 100644 index 0000000..268a4c0 --- /dev/null +++ b/libs/android-stub/build.gradle.kts @@ -0,0 +1,10 @@ + +import com.android.build.api.dsl.LibraryExtension + +plugins { + alias(libs.plugins.convention.android.lib) +} + +extensions.configure { + namespace = "ru.shadowsparky.androidstub" +} diff --git a/libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java b/libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java new file mode 100644 index 0000000..966aa14 --- /dev/null +++ b/libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java @@ -0,0 +1,5 @@ +package com.android.internal.telephony; + +public interface ITelephony { + boolean endCall(); +} diff --git a/libs/backend-base/.gitignore b/libs/backend-base/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/backend-base/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/backend-base/build.gradle.kts b/libs/backend-base/build.gradle.kts new file mode 100644 index 0000000..7400a92 --- /dev/null +++ b/libs/backend-base/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.convention.jvm) + alias(libs.plugins.convention.koin) +} +dependencies { + implementation(libs.java.jwt) + implementation(libs.logback.classic) +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/BackendCommon.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/BackendCommon.kt new file mode 100644 index 0000000..97b4e68 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/BackendCommon.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.backend + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@ComponentScan +@Configuration +class BackendCommon diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/EnvFetcher.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/EnvFetcher.kt new file mode 100644 index 0000000..a3e6e65 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/EnvFetcher.kt @@ -0,0 +1,11 @@ +package ru.shadowsparky.backend.data + +import org.koin.core.annotation.Single + +@Single +class EnvFetcher { + fun get(key: String, default: String = ""): String { + val value = System.getenv(key) + return value?.ifEmpty { default } ?: default + } +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/JwtPreparer.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/JwtPreparer.kt new file mode 100644 index 0000000..4dfe122 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/JwtPreparer.kt @@ -0,0 +1,33 @@ +package ru.shadowsparky.backend.data + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import org.koin.core.annotation.Single +import ru.shadowsparky.backend.domain.JwtInfo +import ru.shadowsparky.backend.domain.LOGIN_NAME +import ru.shadowsparky.backend.domain.USER_ID_ARG +import java.util.Date +import java.util.concurrent.TimeUnit + +@Single +class JwtPreparer( + private val jwtInfo: JwtInfo, + private val envFetcher: EnvFetcher +) { + private val tokenTimeInMinutes = envFetcher.get("JWT_DURATION", "30").toLong() + fun prepare( + login: String, + userId: Long + ): String { + val exp = Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(tokenTimeInMinutes)) + val token = JWT.create() + .withAudience(jwtInfo.audience) + .withIssuer(jwtInfo.issuer) + .withClaim(LOGIN_NAME, login) + .withClaim(USER_ID_ARG, userId) + .withSubject(userId.toString()) + .withExpiresAt(exp) + .sign(Algorithm.HMAC512(jwtInfo.secret)) + return token + } +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/Logger.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/Logger.kt new file mode 100644 index 0000000..a8f483b --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/Logger.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.backend.data + +import org.koin.core.annotation.Single +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +@Single +class Logger { + private val loggers = hashMapOf() + + fun info(tag: String, message: String) = obtainLogger(tag).info(message) + fun debug(tag: String, message: String) = obtainLogger(tag).debug(message) + fun error(tag: String, e: Exception) = obtainLogger(tag).error("error occurred", e) + + private fun obtainLogger(tag: String): Logger { + loggers[tag]?.let { return it } + loggers[tag] = LoggerFactory.getLogger(tag) + return loggers[tag]!! + } +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/StringFetcher.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/StringFetcher.kt new file mode 100644 index 0000000..e6ae0b5 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/StringFetcher.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.backend.data + +import org.koin.core.annotation.Single + +@Single +class StringFetcher { + fun get(resource: StringResource): String { + return resource.message + } +} + +enum class StringResource(val message: String) { + UNKNOWN_USER("Пользователь не найден или неверный пароль"), + INVALID_PASSWORD("Вы ввели неверный пароль"), + NO_CHANGES_PASS("Старый и новый пароль должны отличаться"), + USER_BLOCKED("Пользователь заблокирован"), + REG_BLOCKED("Регистрация недоступна. Доступ по приглашениям."), + ALREADY_REGISTERED("Пользователь уже существует"), + VIDEO_NOT_FOUND("Видео не найдено.") +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/TokenVerifier.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/TokenVerifier.kt new file mode 100644 index 0000000..006cbb3 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/TokenVerifier.kt @@ -0,0 +1,27 @@ +package ru.shadowsparky.backend.data + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.interfaces.DecodedJWT +import org.koin.core.annotation.Factory +import ru.shadowsparky.backend.domain.JwtInfo +import ru.shadowsparky.backend.domain.LOGIN_NAME +import ru.shadowsparky.backend.domain.LoginVerifier + +@Factory +class TokenVerifier( + private val jwtInfo: JwtInfo, + private val loginVerifier: LoginVerifier +) { + + val verifier = JWT.require(Algorithm.HMAC512(jwtInfo.secret)) + .withAudience(jwtInfo.audience) + .withIssuer(jwtInfo.issuer) + .build() + + suspend fun verify(token: String): DecodedJWT { + val jwt = verifier.verify(token) + loginVerifier.verify(jwt.getClaim(LOGIN_NAME).asString()) + return jwt + } +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/JwtInfo.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/JwtInfo.kt new file mode 100644 index 0000000..3a69cd4 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/JwtInfo.kt @@ -0,0 +1,8 @@ +package ru.shadowsparky.backend.domain + +data class JwtInfo( + val secret: String, + val issuer: String, + val audience: String, + val realm: String +) diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/LoginVerifier.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/LoginVerifier.kt new file mode 100644 index 0000000..741eb44 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/LoginVerifier.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.backend.domain + +interface LoginVerifier { + suspend fun verify(login: String) +} + +const val LOGIN_NAME = "login" +const val USER_ID_ARG = "user_id" + +const val INVALID_TOKEN_MSG = "Был передан неправильный токен" diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/VerifyTokenException.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/VerifyTokenException.kt new file mode 100644 index 0000000..1c4ee12 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/VerifyTokenException.kt @@ -0,0 +1,3 @@ +package ru.shadowsparky.backend.domain + +class VerifyTokenException(override val message: String?) : IllegalStateException() diff --git a/libs/base/.gitignore b/libs/base/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/base/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/base/build.gradle.kts b/libs/base/build.gradle.kts new file mode 100644 index 0000000..f032688 --- /dev/null +++ b/libs/base/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.multiplatform.settings) + } + } + } + android { namespace = "ru.shadowsparky" } +} diff --git a/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/AndroidClipboardManager.kt b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/AndroidClipboardManager.kt new file mode 100644 index 0000000..cdf3725 --- /dev/null +++ b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/AndroidClipboardManager.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.data + +import android.content.ClipData +import android.content.Context +import org.koin.core.annotation.Factory +import ru.shadowsparky.domain.Clip +import ru.shadowsparky.domain.ClipboardManager + +@Factory +class AndroidClipboardManager(private val context: Context) : ClipboardManager { + private val service by lazy { + context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + } + + override suspend fun setText(clip: Clip) { + service.setPrimaryClip(ClipData.newPlainText(clip.title, clip.text)) + } +} diff --git a/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/Log.android.kt b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/Log.android.kt new file mode 100644 index 0000000..aa3bd0a --- /dev/null +++ b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/Log.android.kt @@ -0,0 +1,17 @@ +package ru.shadowsparky.data + +import org.koin.core.annotation.Single +import ru.shadowsparky.domain.Log + +private typealias Impl = android.util.Log + +@Single +class AndroidLog : Log { + override fun d(tag: String, msg: String?, e: Throwable?) { + Impl.d(tag, msg, e) + } + + override fun e(tag: String, e: Throwable?, msg: String?) { + Impl.e(tag, msg, e) + } +} diff --git a/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/SettingsFactory.android.kt b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/SettingsFactory.android.kt new file mode 100644 index 0000000..2407050 --- /dev/null +++ b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/SettingsFactory.android.kt @@ -0,0 +1,15 @@ +package ru.shadowsparky.data + +import android.app.Application +import android.content.Context +import com.russhwolf.settings.Settings +import com.russhwolf.settings.SharedPreferencesSettings +import org.koin.core.annotation.Single + +@Single +class AndroidSettingsFactory(private val app: Application) : SettingsFactory { + override fun create(name: String): Settings { + val prefs = app.getSharedPreferences(name, Context.MODE_PRIVATE) + return SharedPreferencesSettings(prefs) + } +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/CommonModule.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/CommonModule.kt new file mode 100644 index 0000000..af7c767 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/CommonModule.kt @@ -0,0 +1,37 @@ +package ru.shadowsparky + +import kotlinx.serialization.json.Json +import org.koin.core.Koin +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.koin.core.component.KoinComponent +import ru.shadowsparky.data.SettingsFactory +import ru.shadowsparky.data.SettingsKeyValueStorage +import ru.shadowsparky.domain.DispatcherProvider +import ru.shadowsparky.domain.KeyValueStorage + +val koin: Koin get() = object : KoinComponent {}.getKoin() + +@Module +@ComponentScan +@Configuration +class CommonModule { + + @Single + fun provideJson(): Json { + return Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + } + + @Single + fun provideSettings( + dispatcherProvider: DispatcherProvider, + settingsFactory: SettingsFactory + ): KeyValueStorage { + return SettingsKeyValueStorage(dispatcherProvider, "app_settings", settingsFactory) + } +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/DispatcherProviderImpl.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/DispatcherProviderImpl.kt new file mode 100644 index 0000000..99fe586 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/DispatcherProviderImpl.kt @@ -0,0 +1,14 @@ +package ru.shadowsparky.data + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import org.koin.core.annotation.Singleton +import ru.shadowsparky.domain.DispatcherProvider + +@Singleton +class DispatcherProviderImpl : DispatcherProvider { + override val io: CoroutineDispatcher = Dispatchers.Default + override val main: CoroutineDispatcher = Dispatchers.Main + override val unconfined: CoroutineDispatcher = Dispatchers.Unconfined + override val default: CoroutineDispatcher = Dispatchers.Default +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/KeyValueStorageImpl.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/KeyValueStorageImpl.kt new file mode 100644 index 0000000..c6383a5 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/KeyValueStorageImpl.kt @@ -0,0 +1,110 @@ +package ru.shadowsparky.data + +import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.Settings +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.shadowsparky.domain.DispatcherProvider +import ru.shadowsparky.domain.KeyValueStorage + +private typealias NotifyCallback = ((String?) -> Unit)? + +class SettingsKeyValueStorage( + private val dispatcherProvider: DispatcherProvider, + private val name: String, + private val settingsFactory: SettingsFactory +) : KeyValueStorage { + private val impl: BaseImpl by lazy { + val settings = settingsFactory.create(name) + if (settings is ObservableSettings) { + DefaultImpl(settings) + } else { + FallbackImpl(settings) + } + } + + override fun get(key: String): Flow { + return impl.get(key) + } + + override suspend fun remove(key: String) = withContext(dispatcherProvider.io) { + impl.remove(key) + } + + override suspend fun put(key: String, value: String) = withContext(dispatcherProvider.io) { + impl.put(key, value) + } + + private abstract class BaseImpl(private val settings: Settings) { + + open fun remove(key: String) { + settings.remove(key) + } + + open fun put(key: String, value: String) { + settings.putString(key, value) + } + + abstract fun get(key: String): Flow + } + + private class DefaultImpl(private val settings: ObservableSettings) : BaseImpl(settings) { + override fun get(key: String): Flow { + return callbackFlow { + val listener: (String?) -> Unit = { + launch { send(it) } + } + send(settings.getStringOrNull(key)) + val disposable = settings.addStringOrNullListener(key, listener) + awaitClose { disposable.deactivate() } + } + } + } + + private class FallbackImpl(private val settings: Settings) : BaseImpl(settings) { + private val listenerMap = hashMapOf>() + + override fun get(key: String): Flow = callbackFlow { + val listener: (String?) -> Unit = { + launch { send(it) } + } + send(settings.getStringOrNull(key)) + put(key, listener) + awaitClose { + remove(key, listener) + } + } + + private fun put(key: String, listener: NotifyCallback) { + val list = listenerMap[key] ?: mutableListOf() + list.add(listener) + listenerMap[key] = list + } + + private fun remove(key: String, listener: NotifyCallback) { + val list = listenerMap[key] ?: return + val index = list.indexOf(listener) + if (index >= 0) { + list.removeAt(index) + } + listenerMap[key] = list + } + + override fun remove(key: String) { + super.remove(key) + listenerMap[key]?.forEach { + it?.invoke(null) + } + } + + override fun put(key: String, value: String) { + super.put(key, value) + listenerMap[key]?.forEach { + it?.invoke(value) + } + } + } +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/SettingsFactory.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/SettingsFactory.kt new file mode 100644 index 0000000..2b98ea4 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/SettingsFactory.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.data + +import com.russhwolf.settings.Settings + +interface SettingsFactory { + fun create(name: String): Settings +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt new file mode 100644 index 0000000..254202f --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.domain + +interface AppConfig { + fun isDebug(): Boolean + fun isBackend(): Boolean = false +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/ClipboardManager.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/ClipboardManager.kt new file mode 100644 index 0000000..595bed1 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/ClipboardManager.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.domain + +interface ClipboardManager { + suspend fun setText(clip: Clip) +} + +data class Clip(val title: String, val text: String) diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/DispatcherProvider.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/DispatcherProvider.kt new file mode 100644 index 0000000..416838d --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/DispatcherProvider.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.domain + +import kotlinx.coroutines.CoroutineDispatcher + +interface DispatcherProvider { + val io: CoroutineDispatcher + val main: CoroutineDispatcher + val unconfined: CoroutineDispatcher + val default: CoroutineDispatcher +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/KeyValueStorage.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/KeyValueStorage.kt new file mode 100644 index 0000000..1e727d7 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/KeyValueStorage.kt @@ -0,0 +1,15 @@ +package ru.shadowsparky.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface KeyValueStorage { + fun get(key: String): Flow + fun getBoolean(key: String): Flow = get(key).map { it?.toBoolean() } + fun getInt(key: String): Flow = get(key).map { it?.toInt() } + + suspend fun remove(key: String) + suspend fun put(key: String, value: String) + suspend fun putBoolean(key: String, value: Boolean) = put(key, value.toString()) + suspend fun putInt(key: String, value: Int) = put(key, value.toString()) +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/Log.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/Log.kt new file mode 100644 index 0000000..a0e2c89 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/Log.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.domain + +interface Log { + fun d(tag: String, msg: String? = null, e: Throwable? = null) + fun e(tag: String, e: Throwable? = null, msg: String? = null) +} diff --git a/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/LogImpl.desktop.kt b/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/LogImpl.desktop.kt new file mode 100644 index 0000000..8e1fb90 --- /dev/null +++ b/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/LogImpl.desktop.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.data + +import org.koin.core.annotation.Single +import ru.shadowsparky.domain.Log + +@Single +class DesktopLog : Log { + + override fun d(tag: String, msg: String?, e: Throwable?) { + println("$tag $msg") + e?.printStackTrace() + } + + override fun e(tag: String, e: Throwable?, msg: String?) { + println("$tag $msg") + e?.printStackTrace() + } +} diff --git a/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/SettingsFactory.desktop.kt b/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/SettingsFactory.desktop.kt new file mode 100644 index 0000000..1cd7601 --- /dev/null +++ b/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/SettingsFactory.desktop.kt @@ -0,0 +1,13 @@ +package ru.shadowsparky.data + +import com.russhwolf.settings.PreferencesSettings +import com.russhwolf.settings.Settings +import org.koin.core.annotation.Single +import java.util.prefs.Preferences + +@Single +class DesktopSettingsFactory : SettingsFactory { + override fun create(name: String): Settings { + return PreferencesSettings(Preferences.userRoot().node(name)) + } +} diff --git a/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/LogImpl.wasmJs.kt b/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/LogImpl.wasmJs.kt new file mode 100644 index 0000000..7313901 --- /dev/null +++ b/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/LogImpl.wasmJs.kt @@ -0,0 +1,17 @@ +package ru.shadowsparky.data + +import org.koin.core.annotation.Single +import ru.shadowsparky.domain.Log + +@Single +class WasmLog : Log { + override fun d(tag: String, msg: String?, e: Throwable?) { + println("$tag $msg") + e?.printStackTrace() + } + + override fun e(tag: String, e: Throwable?, msg: String?) { + println("$tag $msg") + e?.printStackTrace() + } +} diff --git a/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/SettingsFactory.wasmJs.kt b/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/SettingsFactory.wasmJs.kt new file mode 100644 index 0000000..d8f4020 --- /dev/null +++ b/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/SettingsFactory.wasmJs.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.data + +import com.russhwolf.settings.Settings +import com.russhwolf.settings.StorageSettings +import org.koin.core.annotation.Single + +@Single +class WasmSettingsFactory : SettingsFactory { + override fun create(name: String): Settings { + return StorageSettings() + } +} diff --git a/libs/http-client/.gitignore b/libs/http-client/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/http-client/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/http-client/build.gradle.kts b/libs/http-client/build.gradle.kts new file mode 100644 index 0000000..f4c134a --- /dev/null +++ b/libs/http-client/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) + alias(libs.plugins.convention.ktor.client) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":libs:base")) + implementation(libs.ktor.client.cio) + implementation(libs.kotlinx.rpc.krpc.client) + // Server API + implementation(libs.kotlinx.rpc.krpc.server) + // Serialization module. Also, protobuf and cbor are provided + implementation(libs.kotlinx.rpc.krpc.serialization.json) + + // Transport implementation for Ktor + implementation(libs.kotlinx.rpc.krpc.ktor.client) + implementation(libs.kotlinx.rpc.krpc.ktor.server) + } + } + } + android { namespace = "ru.shadowsparky.http" } +} diff --git a/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt b/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt new file mode 100644 index 0000000..8b68748 --- /dev/null +++ b/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt @@ -0,0 +1,26 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) { + + actual fun create(): HttpClient { + return HttpClient(OkHttp) { + settings.setup(this) + engine { + config { + retryOnConnectionFailure(true) + connectTimeout(15, TimeUnit.SECONDS) + readTimeout(15, TimeUnit.SECONDS) + writeTimeout(15, TimeUnit.SECONDS) + } + preconfigured = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .build() + } + } + } +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt new file mode 100644 index 0000000..1df5567 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt @@ -0,0 +1,69 @@ +package ru.shadowsparky.http + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.RefreshTokensParams +import kotlinx.coroutines.flow.firstOrNull +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import ru.shadowsparky.http.data.HttpClientFactory +import ru.shadowsparky.http.data.HttpClientSettings +import ru.shadowsparky.http.data.RefreshTokenRunner +import ru.shadowsparky.http.domain.HttpException +import ru.shadowsparky.http.domain.LogoutAction +import ru.shadowsparky.http.domain.RefreshTokenAction +import ru.shadowsparky.http.domain.TokenInfo +import ru.shadowsparky.http.domain.TokenStorage +import ru.shadowsparky.koin + +@Module +@Configuration +@ComponentScan +class HttpModule { + + @Single + fun createHttpClient(settings: HttpClientSettings): HttpClient { + return HttpClientFactory(settings).create() + } + + @Single + fun createBearerAuthProvider( + refreshTokenRunner: RefreshTokenRunner + ): BearerAuthProvider { + val tokenStorage = koin.getOrNull() + return BearerAuthProvider( + refreshTokens = { + refreshTokenRunner.runOrJoin { + refreshTokenInternal(tokenStorage) + } + }, + loadTokens = { + tokenStorage?.token?.firstOrNull()?.toKtor() + }, + realm = tokenStorage?.realm + ) + } + + private suspend fun RefreshTokensParams.refreshTokenInternal(tokenStorage: TokenStorage?): BearerTokens? { + val token = tokenStorage?.token?.firstOrNull()?.refresh ?: return null + return try { + val newToken = koin.getOrNull() + ?.refresh(this.client, token) + ?: return null + tokenStorage.updateToken(newToken) + newToken.toKtor() + } catch (e: Throwable) { + if (e is HttpException) { + koin.getOrNull()?.logout() + } + null + } + } +} + +private fun TokenInfo.toKtor(): BearerTokens { + return BearerTokens(token, refresh) +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt new file mode 100644 index 0000000..b7464ad --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient + +expect class HttpClientFactory(settings: HttpClientSettings) { + fun create(): HttpClient +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt new file mode 100644 index 0000000..2bf41a6 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt @@ -0,0 +1,111 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClientConfig +import io.ktor.client.call.body +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.HttpTimeoutConfig +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.request.header +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.koin.core.annotation.Factory +import ru.shadowsparky.domain.AppConfig +import ru.shadowsparky.domain.Log +import ru.shadowsparky.http.domain.ClientKtorExtension +import ru.shadowsparky.http.domain.HttpException +import ru.shadowsparky.http.domain.UserAgentProvider +import ru.shadowsparky.koin + +const val USE_HTTP_CUSTOM_CERTS = "use_http_custom_certs" + +private const val MESSAGE_LONG = "message" +private const val MESSAGE = "msg" + +@Factory +class HttpClientSettings( + private val json: Json, + private val userAgentProvider: UserAgentProvider?, + private val bearerAuthProvider: BearerAuthProvider, + private val appenderList: List, + private val log: Log +) { + + fun setup( + clientConfig: HttpClientConfig<*> + ) = with(clientConfig) { + install(Auth) { + providers.add(bearerAuthProvider) + } + expectSuccess = false + + HttpResponseValidator { + validateResponse { + val code = it.status.value + if (code in 400..599) { + val body = it.body() + if (body is JsonObject) { + val msg = when { + body.containsKey(MESSAGE_LONG) -> body[MESSAGE_LONG]?.jsonPrimitive?.content + body.containsKey(MESSAGE) -> body[MESSAGE]?.jsonPrimitive?.content + else -> body.toString() + } + throw HttpException(code, msg) + } else { + throw HttpException(code, body.toString()) + } + } + } + } + + install(Logging) { + val isDebug = koin.getOrNull()?.isDebug() ?: true + if (isDebug) { + this.level = LogLevel.ALL + this.logger = object : Logger { + override fun log(message: String) { + log.d("Http", message) + } + } + } + } + + install(ContentNegotiation) { + json(json) + } + + install(WebSockets) { + // как часто слать ping кадры + pingIntervalMillis = 30_000 // или: pingInterval = 20.seconds (в новых API) + } + + defaultRequest { + header(HttpHeaders.Accept, ContentType.Application.Json) + header(HttpHeaders.ContentType, ContentType.Application.Json) + userAgentProvider?.provide()?.let { + header(HttpHeaders.UserAgent, it) + } + } + + // Таймауты на уровне Ktor + install(HttpTimeout) { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + connectTimeoutMillis = 20_000 + } + + appenderList.forEach { it.append(this) } + } +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt new file mode 100644 index 0000000..e441036 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt @@ -0,0 +1,39 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.plugins.auth.providers.BearerTokens +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Factory +import ru.shadowsparky.domain.DispatcherProvider + + +@Factory +class RefreshTokenRunner(private val dispatcherProvider: DispatcherProvider) { + private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io) + private val lock = Mutex() + private var inFlight: Deferred? = null + + suspend fun runOrJoin(block: suspend () -> BearerTokens?): BearerTokens? { + val deferred = lock.withLock { + inFlight?.takeIf { it.isActive } ?: startNew(block).also { inFlight = it } + } + return deferred.await() + } + + private fun startNew(block: suspend () -> BearerTokens?): Deferred { + val d = scope.async(dispatcherProvider.io) { block() } + d.invokeOnCompletion { + scope.launch { + lock.withLock { + if (inFlight === d) inFlight = null + } + } + } + return d + } +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt new file mode 100644 index 0000000..f5f935b --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.http.domain + +import io.ktor.client.HttpClientConfig + +interface ClientKtorExtension { + fun append(clientConfig: HttpClientConfig<*>) +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt new file mode 100644 index 0000000..bf1295c --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt @@ -0,0 +1,8 @@ +package ru.shadowsparky.http.domain + +import io.ktor.http.HttpStatusCode + +open class HttpException(val httpCode: Int, override val message: String?) : RuntimeException() + +class BadRequestException(msg: String) : HttpException(HttpStatusCode.BadRequest.value, msg) +open class NotFoundException(msg: String) : HttpException(HttpStatusCode.NotFound.value, msg) diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/LogoutAction.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/LogoutAction.kt new file mode 100644 index 0000000..5533254 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/LogoutAction.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.http.domain + +interface LogoutAction { + suspend fun logout() +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/RefreshTokenAction.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/RefreshTokenAction.kt new file mode 100644 index 0000000..6521d6b --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/RefreshTokenAction.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.http.domain + +import io.ktor.client.HttpClient + +interface RefreshTokenAction { + suspend fun refresh(client: HttpClient, refreshToken: String): TokenInfo +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenInfo.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenInfo.kt new file mode 100644 index 0000000..1962eb2 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenInfo.kt @@ -0,0 +1,9 @@ +package ru.shadowsparky.http.domain + +import kotlinx.serialization.Serializable + +@Serializable +data class TokenInfo( + val token: String, + val refresh: String? = null +) diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenStorage.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenStorage.kt new file mode 100644 index 0000000..6cfa6b9 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenStorage.kt @@ -0,0 +1,16 @@ +package ru.shadowsparky.http.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull + +interface TokenStorage { + val realm: String + val token: Flow + + suspend fun updateToken(newToken: TokenInfo) + suspend fun clear() +} + +suspend fun TokenStorage.hasAccount(): Boolean { + return token.firstOrNull()?.token != null +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/UserAgentProvider.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/UserAgentProvider.kt new file mode 100644 index 0000000..355303f --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/UserAgentProvider.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.http.domain + +interface UserAgentProvider { + fun provide(): String? +} diff --git a/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt b/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt new file mode 100644 index 0000000..56f88d7 --- /dev/null +++ b/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt @@ -0,0 +1,23 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) { + actual fun create(): HttpClient { + return HttpClient(OkHttp) { + settings.setup(this) + engine { + config { + retryOnConnectionFailure(true) + connectTimeout(15, TimeUnit.SECONDS) + readTimeout(15, TimeUnit.SECONDS) + writeTimeout(15, TimeUnit.SECONDS) + } + preconfigured = OkHttpClient.Builder().pingInterval(30, TimeUnit.SECONDS).build() + } + } + } +} diff --git a/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt b/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt new file mode 100644 index 0000000..99ac603 --- /dev/null +++ b/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient +import io.ktor.client.engine.js.Js + +actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) { + actual fun create(): HttpClient { + return HttpClient(Js) { + settings.setup(this) + } + } +} diff --git a/libs/ui/.gitignore b/libs/ui/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/ui/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/ui/build.gradle.kts b/libs/ui/build.gradle.kts new file mode 100644 index 0000000..27a2c93 --- /dev/null +++ b/libs/ui/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) + alias(libs.plugins.convention.compose) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.haze) + implementation(libs.haze.materials) + } + } + } + android { namespace = "ru.shadowsparky.ui" } +} diff --git a/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.android.kt b/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.android.kt new file mode 100644 index 0000000..b37399e --- /dev/null +++ b/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.android.kt @@ -0,0 +1,14 @@ +package ru.shadowsparky.ui.components + +import android.app.UiModeManager +import android.content.Context +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun isTv(): Boolean { + val context = LocalContext.current + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION +} diff --git a/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/theme/Theme.android.kt b/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/theme/Theme.android.kt new file mode 100644 index 0000000..3557b57 --- /dev/null +++ b/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/theme/Theme.android.kt @@ -0,0 +1,26 @@ +package ru.shadowsparky.ui.theme + +import android.os.Build +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun lightDynamicScheme(): ColorScheme? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicLightColorScheme(LocalContext.current) + } else { + null + } +} + +@Composable +actual fun darkDynamicScheme(): ColorScheme? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicDarkColorScheme(LocalContext.current) + } else { + null + } +} diff --git a/libs/ui/src/commonMain/composeResources/drawable/back.xml b/libs/ui/src/commonMain/composeResources/drawable/back.xml new file mode 100644 index 0000000..0e5dd04 --- /dev/null +++ b/libs/ui/src/commonMain/composeResources/drawable/back.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/libs/ui/src/commonMain/composeResources/drawable/check.xml b/libs/ui/src/commonMain/composeResources/drawable/check.xml new file mode 100644 index 0000000..1228f47 --- /dev/null +++ b/libs/ui/src/commonMain/composeResources/drawable/check.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/libs/ui/src/commonMain/composeResources/drawable/visibility.xml b/libs/ui/src/commonMain/composeResources/drawable/visibility.xml new file mode 100644 index 0000000..cf02125 --- /dev/null +++ b/libs/ui/src/commonMain/composeResources/drawable/visibility.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/ui/src/commonMain/composeResources/drawable/visibility_off.xml b/libs/ui/src/commonMain/composeResources/drawable/visibility_off.xml new file mode 100644 index 0000000..a1b2f6e --- /dev/null +++ b/libs/ui/src/commonMain/composeResources/drawable/visibility_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/BaseScreen.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/BaseScreen.kt new file mode 100644 index 0000000..04cd278 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/BaseScreen.kt @@ -0,0 +1,166 @@ +package ru.shadowsparky.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import ru.shadowsparky.ui.nav.Route +import ru.shadowsparky.ui.theme.ComponentDefaults +import ru.shadowsparky.ui.theme.getColorScheme + +const val USE_EXPERIMENTAL_RAIL = false + +@Immutable +data class NavigationEntry( + val route: T, + val title: String, + val painter: Painter +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseScreen( + modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior? = TopAppBarDefaults.pinnedScrollBehavior(), + snackbarState: SnackbarHostState? = null, + colorScheme: ColorScheme = getColorScheme(), + appBar: @Composable (scroll: TopAppBarScrollBehavior?) -> Unit, + content: @Composable (padding: PaddingValues) -> Unit, +) { + BaseScreen( + modifier, + scrollBehavior, + snackbarState, + colorScheme, + appBar = appBar, + content = content + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun BaseScreen( + modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior? = TopAppBarDefaults.pinnedScrollBehavior(), + snackbarState: SnackbarHostState? = null, + colorScheme: ColorScheme = getColorScheme(), + currentRoute: T? = null, + navigationEntries: List>? = null, + onSelectNavigation: ((T) -> Unit)? = null, + appBar: @Composable (scroll: TopAppBarScrollBehavior?) -> Unit, + content: @Composable (padding: PaddingValues) -> Unit, +) { + val windowType = currentWindowType() + MaterialExpressiveTheme(colorScheme = colorScheme) { + Scaffold( + bottomBar = { + if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) { + BottomBar(navigationEntries, currentRoute, onSelectNavigation) + } + }, + topBar = { + appBar(scrollBehavior) + }, content = { + if (windowType.isExpanded && navigationEntries?.isNotEmpty() == true && USE_EXPERIMENTAL_RAIL) { + ExpandedContent( + it, + navigationEntries, + currentRoute, + onSelectNavigation, + content + ) + } else { + content(it) + } + }, modifier = if (scrollBehavior != null) { + modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } else { + modifier + }, snackbarHost = { + snackbarState?.let { SnackbarHost(it) } + }, contentWindowInsets = WindowInsets.safeDrawing + ) + } +} + +@Composable +private fun BottomBar( + navigationEntries: List>?, + currentRoute: T?, + onSelectNavigation: ((T) -> Unit)? +) { + val blurState = LocalBlurState.current + navigationEntries?.let { + NavigationBar( + containerColor = ComponentDefaults.navBarContentColor(), + modifier = Modifier.applyBlurEffect(blurState) + ) { + it.forEach { entry -> + NavigationBarItem( + currentRoute == entry.route, + onClick = { onSelectNavigation?.invoke(entry.route) }, + icon = { + Icon(entry.painter, contentDescription = entry.title) + }, + label = { Text(entry.title) } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ExpandedContent( + values: PaddingValues, + navigationEntries: List>, + currentRoute: T?, + onSelectNavigation: ((T) -> Unit)?, + content: @Composable ((PaddingValues) -> Unit) +) { + Row(Modifier.fillMaxSize().padding(values)) { + Spacer(Modifier.width(4.dp)) + NavigationRail { + navigationEntries.forEach { entry -> + NavigationRailItem( + selected = currentRoute == entry.route, + onClick = { onSelectNavigation?.invoke(entry.route) }, + icon = { + Icon(entry.painter, contentDescription = entry.title) + }, + label = { Text(entry.title) } + ) + } + } + Spacer(Modifier.width(4.dp)) + Box(Modifier.weight(1f)) { + content(PaddingValues(0.dp)) + } + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/Blur.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/Blur.kt new file mode 100644 index 0000000..aa3e952 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/Blur.kt @@ -0,0 +1,45 @@ +package ru.shadowsparky.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState +import ru.shadowsparky.ui.components.isTv + +private const val BLUR_ENABLED = true + +val LocalBlurState = compositionLocalOf { null } + +@Composable +internal fun isBlurEnabled(): Boolean { + return BLUR_ENABLED && !isTv() +} + +@Composable +fun rememberBlurState(): BlurState? { + if (!isBlurEnabled()) return null + val hazeState = rememberHazeState() + return remember(hazeState) { BlurState(hazeState) } +} + +fun Modifier.applyBlurSource(state: BlurState?): Modifier { + state ?: return this + return hazeSource(state.hazeState) +} + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +fun Modifier.applyBlurEffect(state: BlurState?): Modifier { + state ?: return this + return hazeEffect(state.hazeState, HazeMaterials.thick()) +} + +@Stable +class BlurState(internal val hazeState: HazeState) diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/WindowExtensions.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/WindowExtensions.kt new file mode 100644 index 0000000..0c09887 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/WindowExtensions.kt @@ -0,0 +1,30 @@ +package ru.shadowsparky.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo + +@Composable +private fun currentWindowWidthDp(): Int { + val info = LocalWindowInfo.current + val density = LocalDensity.current + return with(density) { info.containerSize.width.toDp().value.toInt() } +} + +@Composable +fun currentWindowType(): WindowType { + return widthToSizeClass(currentWindowWidthDp()) +} + +sealed interface WindowType { + object Compact : WindowType + object Expanded : WindowType +} + +val WindowType.isExpanded: Boolean get() = this is WindowType.Expanded +val WindowType.isCompact: Boolean get() = this is WindowType.Compact + +private fun widthToSizeClass(widthDp: Int): WindowType = when { + widthDp >= 840 -> WindowType.Expanded + else -> WindowType.Compact +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/BackButton.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/BackButton.kt new file mode 100644 index 0000000..f10060d --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/BackButton.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.ui.components + +import androiddev.libs.ui.generated.resources.Res +import androiddev.libs.ui.generated.resources.back +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.painterResource + +@Composable +fun BackButton(doBack: () -> Unit) { + IconButton( + onClick = { doBack() }, + shape = CircleShape, + colors = IconButtonDefaults.iconButtonVibrantColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(0.1f)) + ) { Icon(painterResource(Res.drawable.back), contentDescription = null) } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt new file mode 100644 index 0000000..3328812 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt @@ -0,0 +1,131 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private const val ROUNDED_MAX = 24 +private const val ROUNDED_MIN = 6 + +@DslMarker private annotation class ExpressiveListDsl + +internal data class ExpressiveNode( + val key: Any?, + val contentType: Any?, + val content: @Composable () -> Unit +) + +@ExpressiveListDsl +class ExpressiveSectionScope { + internal val nodes = mutableListOf() + + fun item(key: Any? = null, contentType: Any? = null, content: @Composable () -> Unit) { + nodes += ExpressiveNode(key, contentType, content) + } + + fun items( + items: List, + key: ((T) -> Any)? = null, + contentType: ((T) -> Any)? = null, + itemContent: @Composable (T) -> Unit + ) { + items.forEach { t -> + nodes += ExpressiveNode( + key = key?.invoke(t), + contentType = contentType?.invoke(t), + content = { itemContent(t) } + ) + } + } +} + +@ExpressiveListDsl +class ExpressiveLazyListScope { + internal val sections = mutableListOf() + + fun section(content: ExpressiveSectionScope.() -> Unit) { + val s = ExpressiveSectionScope().apply(content) + if (s.nodes.isNotEmpty()) sections += s + } +} + +@Composable +fun ExpressiveLazyColumn( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + horizontalPadding: Dp = 8.dp, + verticalSpacing: Dp = 1.dp, + sectionSpacing: Dp = 8.dp, + roundedMax: Dp = ROUNDED_MAX.dp, + roundedMin: Dp = ROUNDED_MIN.dp, + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + content: ExpressiveLazyListScope.() -> Unit +) { + val scope = ExpressiveLazyListScope().apply(content) + + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement + ) { + scope.sections.forEachIndexed { sectionIndex, section -> + + if (sectionIndex != 0 && sectionSpacing > 0.dp) { + item(key = "sectionSpacer_$sectionIndex") { + Spacer(Modifier.height(sectionSpacing)) + } + } + + section.nodes.forEachIndexed { index, node -> + val topRadius = if (index == 0) roundedMax else roundedMin + val bottomRadius = if (index == section.nodes.lastIndex) roundedMax else roundedMin + + item(key = node.key, contentType = node.contentType) { + ExpressiveCard( + topRadius, + bottomRadius, + horizontalPadding, + verticalSpacing, + content = node.content + ) + } + } + } + } +} + +@Composable +fun ExpressiveCard( + topRadius: Dp = ROUNDED_MAX.dp, + bottomRadius: Dp = ROUNDED_MAX.dp, + horizontalPadding: Dp = 8.dp, + verticalSpacing: Dp = 1.dp, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ), + shape = RoundedCornerShape( + topStart = topRadius, + topEnd = topRadius, + bottomStart = bottomRadius, + bottomEnd = bottomRadius + ), + modifier = modifier.padding(horizontal = horizontalPadding, vertical = verticalSpacing) + ) { content() } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyColumn.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyColumn.kt new file mode 100644 index 0000000..971248c --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyColumn.kt @@ -0,0 +1,137 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.OverscrollEffect +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberOverscrollEffect +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +expect fun isTv(): Boolean + +/** + * LazyColumn — обходной путь для бага LazyColumn + contentPadding на Android TV. + * + * Ниже — визуальные схемы, которые показывают, + * КАК ломается фокус и КАК работает обход. + * + * ------------------------------------------------------------ + * 1) Как работает обычный LazyColumn с contentPadding + * (и почему это ломает DPAD-фокус на Android TV) + * ------------------------------------------------------------ + * + * ┌───────────────────────────────────────────────┐ + * │ LazyColumn(contentPadding = paddingValues) │ + * │ │ + * │ ↑ ВЕРХНИЙ PADDING (невидимая область) │ + * │ │ (создаётся внутри списка) │ + * │ │ │ + * │ ┌─▼─────────────────────────────────────────┐ │ + * │ │ Item 1 │ │ + * │ └───────────────────────────────────────────┘ │ + * │ ┌────────────────────────────────────────────┐│ + * │ │ Item 2 (кнопка, карточка, что угодно) ││ + * │ └────────────────────────────────────────────┘│ + * │ │ + * │ ↓ НИЖНИЙ PADDING (ещё одна невидимая зона) │ + * │ │ + * └───────────────────────────────────────────────┘ + * + * ПРОБЛЕМА: + * Для DPAD-навигации эти зоны — не просто отступы. + * Это ПУСТЫЕ ОБЛАСТИ, которые участвуют в алгоритме Focus Search. + * + * Алгоритм думает так: + * + * "Нажали вниз → ниже есть пространство? Да. Есть фокусируемый элемент? Нет." + * + * В итоге фокус: + * - не двигается вниз, + * - может даже перестать попадать в список, + * - прокрутка вниз не инициируется. + * + * + * ------------------------------------------------------------ + * 2) Что делает InsetLazyColumn на Android TV + * ------------------------------------------------------------ + * + * Вместо внутреннего contentPadding мы делаем: + * + * modifier.padding(contentPadding) + * + * А внутрь LazyColumn передаём: + * + * contentPadding = PaddingValues(0.dp) + * + * + * ┌───────────────────────────────────────────────┐ + * │ Modifier.padding(...) ← PADDING СНАРУЖИ │ + * │ │ + * │ ┌───────────────────────────────────────────┐ │ + * │ │ LazyColumn(contentPadding = 0.dp) │ │ + * │ │ │ │ + * │ │ Item 1 │ │ + * │ │ Item 2 │ │ + * │ │ Item 3 │ │ + * │ │ │ │ + * │ └───────────────────────────────────────────┘ │ + * │ │ + * └───────────────────────────────────────────────┘ + * + * РЕЗУЛЬТАТ: + * + * ✔ Визуально всё выглядит так же (insets соблюдены). + * ✔ Внутри LazyColumn нет искусственных «дыр». + * ✔ DPAD-навигация работает корректно. + * ✔ Список правильно скроллится вниз при фокусе. + * ✔ Алгоритм Focus Search видит только настоящие элементы. + * + * + * ------------------------------------------------------------ + * 3) Итоговая мысль + * ------------------------------------------------------------ + * + * Весь баг сводится к тому, что LazyColumn на TV неправильно + * интерпретирует внутренний contentPadding как "дополнительные" + * focus-space зоны, и DPAD начинает вести себя некорректно. + * + * Перенос padding наружу через Modifier полностью устраняет проблему, + * сохраняя визуальное поведение. + */ +@Composable +fun LazyColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), + content: LazyListScope.() -> Unit, +) { + val isTv = isTv() + androidx.compose.foundation.lazy.LazyColumn( + if (isTv) modifier.padding(contentPadding) else modifier, + state, + if (isTv) PaddingValues(0.dp) else contentPadding, + reverseLayout, + verticalArrangement, + horizontalAlignment, + flingBehavior, + userScrollEnabled, + overscrollEffect, + content + ) +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyVerticalGrid.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyVerticalGrid.kt new file mode 100644 index 0000000..b0fa6ab --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyVerticalGrid.kt @@ -0,0 +1,47 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.OverscrollEffect +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.rememberOverscrollEffect +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun LazyVerticalGrid( + columns: GridCells, + modifier: Modifier = Modifier, + state: LazyGridState = rememberLazyGridState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), + content: LazyGridScope.() -> Unit, +) { + val isTv = isTv() + androidx.compose.foundation.lazy.grid.LazyVerticalGrid( + columns, + if (isTv) modifier.padding(contentPadding) else modifier, + state, + if (isTv) PaddingValues(0.dp) else contentPadding, + reverseLayout, + verticalArrangement, + horizontalArrangement, + flingBehavior, + userScrollEnabled, + overscrollEffect, + content + ) +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/ListPreference.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/ListPreference.kt new file mode 100644 index 0000000..242cdbf --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/ListPreference.kt @@ -0,0 +1,66 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListPreference( + title: String, + items: Map, + selectedItem: T, + onItemSelected: (T) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + val entries = remember(items) { items.entries.toList() } + val selectedLabel = remember(items, selectedItem) { items[selectedItem].orEmpty() } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { if (enabled) expanded = !expanded }, + modifier = modifier + ) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, // readOnly + readOnly = true, + label = { Text(title) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled) + .fillMaxWidth(), + enabled = enabled, + singleLine = true + ) + + ExposedDropdownMenu( + expanded = expanded && enabled, + onDismissRequest = { expanded = false } + ) { + entries.forEach { (value, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + expanded = false + if (value != selectedItem) onItemSelected(value) + } + ) + } + } + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Loading.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Loading.kt new file mode 100644 index 0000000..cf313a2 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Loading.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun Loading(modifier: Modifier) { + Box(modifier, contentAlignment = Alignment.Center) { + LoadingIndicator(Modifier.size(96.dp)) + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PasswordTextField.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PasswordTextField.kt new file mode 100644 index 0000000..d2984d9 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PasswordTextField.kt @@ -0,0 +1,63 @@ +package ru.shadowsparky.ui.components + +import androiddev.libs.ui.generated.resources.Res +import androiddev.libs.ui.generated.resources.visibility +import androiddev.libs.ui.generated.resources.visibility_off +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource + +@Composable +fun PasswordTextField( + value: String, + onValueChange: (String) -> Unit, + labelRes: StringResource, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onDone: (() -> Unit)? = null +) { + var passwordVisible by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = if (onDone == null) ImeAction.Next else ImeAction.Done, + keyboardType = KeyboardType.Password + ), + keyboardActions = KeyboardActions(onDone = { onDone?.invoke() }), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + label = { Text(stringResource(labelRes)) }, + modifier = modifier.fillMaxWidth(), + enabled = enabled, + trailingIcon = { + val image = if (passwordVisible) { + Res.drawable.visibility + } else { + Res.drawable.visibility_off + } + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = vectorResource(image), contentDescription = null) + } + } + ) +} + diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PreferenceCategory.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PreferenceCategory.kt new file mode 100644 index 0000000..b5569dd --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PreferenceCategory.kt @@ -0,0 +1,27 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun PreferenceCategory( + title: String, + modifier: Modifier = Modifier, +) { + Text( + text = title, + modifier = modifier + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + bottom = 8.dp, + ), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/SwitchPreference.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/SwitchPreference.kt new file mode 100644 index 0000000..251188c --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/SwitchPreference.kt @@ -0,0 +1,58 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow + +@Composable +fun SwitchPreference( + modifier: Modifier = Modifier, + text: String, + summary: String? = null, + state: Boolean, + onStateChanged: (Boolean) -> Unit +) { + ListItem( + colors = ListItemDefaults.colors(Color.Transparent), + modifier = modifier + .fillMaxWidth() + .toggleable( + value = state, + role = Role.Switch, + onValueChange = onStateChanged + ), + headlineContent = { + Text( + text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + }, + supportingContent = summary?.let { + { + Text( + it, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + ) + } + }, + trailingContent = { + Switch( + checked = state, + onCheckedChange = null + ) + } + ) +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt new file mode 100644 index 0000000..02886cf --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt @@ -0,0 +1,41 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow + +@Composable +fun TextPreference( + modifier: Modifier = Modifier, + text: String, + summary: String? = null +) { + ListItem( + colors = ListItemDefaults.colors(Color.Transparent), + modifier = modifier.fillMaxWidth(), + headlineContent = { + Text( + text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + }, + supportingContent = summary?.let { + { + Text( + it, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + ) + } + } + ) +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/BaseRootComponent.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/BaseRootComponent.kt new file mode 100644 index 0000000..2754552 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/BaseRootComponent.kt @@ -0,0 +1,76 @@ +package ru.shadowsparky.ui.nav + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.bringToFront +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceAll +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.backhandler.BackHandlerOwner +import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import kotlinx.serialization.KSerializer +import org.koin.core.Koin +import org.koin.core.qualifier.named + +abstract class BaseRootComponent( + private val componentContext: ComponentContext, +) : ComponentContext by componentContext, BackHandlerOwner { + protected abstract val initStack: List + protected abstract val serializer: KSerializer + protected abstract val koin: Koin + + private val scope = coroutineScope() + + protected val nav = StackNavigation() + + protected val childStack by lazy { + childStack( + source = nav, + serializer = serializer, + initialStack = { initStack }, + childFactory = ::newChild + ) + } + + private val _onFinish = MutableStateFlow(null) + val onFinish = _onFinish.filterNotNull() + + fun navReplace(vararg route: R) { + nav.replaceAll(*route) + } + + fun nav(route: R, bringToFront: Boolean = false) { + if (bringToFront) { + nav.bringToFront(route) + } else { + nav.pushNew(route) + } + } + + fun onBack() { + nav.pop { popped -> + if (!popped) finish() + } + } + + fun finish() { + scope.launch { _onFinish.emit(Unit) } + } + + val valueChildStack: Value> by lazy { childStack } + + protected open fun newChild(route: Route, ctx: ComponentContext): Child { + val factory = route.provideFactory(koin) + return factory.create(route, ctx, this as BaseRootComponent) + } + + private fun Route.provideFactory(koin: Koin): ComponentFactory { + return koin.get>(named(routeId)) + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Child.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Child.kt new file mode 100644 index 0000000..b249ace --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Child.kt @@ -0,0 +1,3 @@ +package ru.shadowsparky.ui.nav + +interface Child diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/ComponentFactory.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/ComponentFactory.kt new file mode 100644 index 0000000..6e1c7bc --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/ComponentFactory.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.ui.nav + +import com.arkivanov.decompose.ComponentContext + +interface ComponentFactory { + fun create(route: R, child: ComponentContext, root: BaseRootComponent): T +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt new file mode 100644 index 0000000..3977ae8 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt @@ -0,0 +1,28 @@ +package ru.shadowsparky.ui.nav + +import androidx.compose.runtime.Composable +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.androidPredictiveBackAnimatableV2 +import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation + +@OptIn(ExperimentalDecomposeApi::class) +@Composable +fun Nav( + rootComponent: BaseRootComponent<*>, + process: @Composable (child: Child, route: Route) -> Unit +) { + Children( + stack = rootComponent.valueChildStack, + animation = predictiveBackAnimation( + backHandler = rootComponent.backHandler, + fallbackAnimation = stackAnimation(fade()), + onBack = rootComponent::onBack, + selector = { backEvent, _, _ -> androidPredictiveBackAnimatableV2(backEvent) }, + ), + ) { + process(it.instance, it.configuration as Route) + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Route.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Route.kt new file mode 100644 index 0000000..015bf13 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Route.kt @@ -0,0 +1,8 @@ +package ru.shadowsparky.ui.nav + +import kotlinx.serialization.Serializable + +@Serializable +abstract class Route { + abstract val routeId: String +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Color.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Color.kt new file mode 100644 index 0000000..54e065f --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Color.kt @@ -0,0 +1,219 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF465D91) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFD9E2FF) +val onPrimaryContainerLight = Color(0xFF2D4678) +val secondaryLight = Color(0xFF575E71) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFDBE2F9) +val onSecondaryContainerLight = Color(0xFF3F4759) +val tertiaryLight = Color(0xFF725573) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFFCD7FB) +val onTertiaryContainerLight = Color(0xFF593E5A) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF93000A) +val backgroundLight = Color(0xFFFAF9FF) +val onBackgroundLight = Color(0xFF1A1B20) +val surfaceLight = Color(0xFFFAF9FF) +val onSurfaceLight = Color(0xFF1A1B20) +val surfaceVariantLight = Color(0xFFE1E2EC) +val onSurfaceVariantLight = Color(0xFF44464F) +val outlineLight = Color(0xFF757780) +val outlineVariantLight = Color(0xFFC5C6D0) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF2F3036) +val inverseOnSurfaceLight = Color(0xFFF1F0F7) +val inversePrimaryLight = Color(0xFFAFC6FF) +val surfaceDimLight = Color(0xFFDAD9E0) +val surfaceBrightLight = Color(0xFFFAF9FF) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF3F3FA) +val surfaceContainerLight = Color(0xFFEEEDF4) +val surfaceContainerHighLight = Color(0xFFE8E7EF) +val surfaceContainerHighestLight = Color(0xFFE2E2E9) + +val primaryLightMediumContrast = Color(0xFF1A3566) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF556CA1) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF2F3648) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF666D80) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF472D49) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF816382) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF740006) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFCF2C27) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFAF9FF) +val onBackgroundLightMediumContrast = Color(0xFF1A1B20) +val surfaceLightMediumContrast = Color(0xFFFAF9FF) +val onSurfaceLightMediumContrast = Color(0xFF0F1116) +val surfaceVariantLightMediumContrast = Color(0xFFE1E2EC) +val onSurfaceVariantLightMediumContrast = Color(0xFF34363E) +val outlineLightMediumContrast = Color(0xFF50525A) +val outlineVariantLightMediumContrast = Color(0xFF6B6D75) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF2F3036) +val inverseOnSurfaceLightMediumContrast = Color(0xFFF1F0F7) +val inversePrimaryLightMediumContrast = Color(0xFFAFC6FF) +val surfaceDimLightMediumContrast = Color(0xFFC6C6CD) +val surfaceBrightLightMediumContrast = Color(0xFFFAF9FF) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFF3F3FA) +val surfaceContainerLightMediumContrast = Color(0xFFE8E7EF) +val surfaceContainerHighLightMediumContrast = Color(0xFFDCDCE3) +val surfaceContainerHighestLightMediumContrast = Color(0xFFD1D1D8) + +val primaryLightHighContrast = Color(0xFF0D2A5B) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF30487B) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF252C3D) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF42495B) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF3C233E) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF5B405D) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF600004) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF98000A) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFAF9FF) +val onBackgroundLightHighContrast = Color(0xFF1A1B20) +val surfaceLightHighContrast = Color(0xFFFAF9FF) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFE1E2EC) +val onSurfaceVariantLightHighContrast = Color(0xFF000000) +val outlineLightHighContrast = Color(0xFF292C33) +val outlineVariantLightHighContrast = Color(0xFF474951) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF2F3036) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFAFC6FF) +val surfaceDimLightHighContrast = Color(0xFFB8B8BF) +val surfaceBrightLightHighContrast = Color(0xFFFAF9FF) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFF1F0F7) +val surfaceContainerLightHighContrast = Color(0xFFE2E2E9) +val surfaceContainerHighLightHighContrast = Color(0xFFD4D4DB) +val surfaceContainerHighestLightHighContrast = Color(0xFFC6C6CD) + +val primaryDark = Color(0xFFAFC6FF) +val onPrimaryDark = Color(0xFF132F60) +val primaryContainerDark = Color(0xFF2D4678) +val onPrimaryContainerDark = Color(0xFFD9E2FF) +val secondaryDark = Color(0xFFBFC6DC) +val onSecondaryDark = Color(0xFF293042) +val secondaryContainerDark = Color(0xFF3F4759) +val onSecondaryContainerDark = Color(0xFFDBE2F9) +val tertiaryDark = Color(0xFFDFBBDE) +val onTertiaryDark = Color(0xFF412743) +val tertiaryContainerDark = Color(0xFF593E5A) +val onTertiaryContainerDark = Color(0xFFFCD7FB) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF121318) +val onBackgroundDark = Color(0xFFE2E2E9) +val surfaceDark = Color(0xFF121318) +val onSurfaceDark = Color(0xFFE2E2E9) +val surfaceVariantDark = Color(0xFF44464F) +val onSurfaceVariantDark = Color(0xFFC5C6D0) +val outlineDark = Color(0xFF8F9099) +val outlineVariantDark = Color(0xFF44464F) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE2E2E9) +val inverseOnSurfaceDark = Color(0xFF2F3036) +val inversePrimaryDark = Color(0xFF465D91) +val surfaceDimDark = Color(0xFF121318) +val surfaceBrightDark = Color(0xFF38393E) +val surfaceContainerLowestDark = Color(0xFF0C0E13) +val surfaceContainerLowDark = Color(0xFF1A1B20) +val surfaceContainerDark = Color(0xFF1E1F25) +val surfaceContainerHighDark = Color(0xFF282A2F) +val surfaceContainerHighestDark = Color(0xFF33353A) + +val primaryDarkMediumContrast = Color(0xFFD0DCFF) +val onPrimaryDarkMediumContrast = Color(0xFF042355) +val primaryContainerDarkMediumContrast = Color(0xFF7990C7) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFD5DCF3) +val onSecondaryDarkMediumContrast = Color(0xFF1E2536) +val secondaryContainerDarkMediumContrast = Color(0xFF8990A5) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFFF6D1F4) +val onTertiaryDarkMediumContrast = Color(0xFF351D37) +val tertiaryContainerDarkMediumContrast = Color(0xFFA786A7) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFD2CC) +val onErrorDarkMediumContrast = Color(0xFF540003) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF121318) +val onBackgroundDarkMediumContrast = Color(0xFFE2E2E9) +val surfaceDarkMediumContrast = Color(0xFF121318) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF44464F) +val onSurfaceVariantDarkMediumContrast = Color(0xFFDBDCE6) +val outlineDarkMediumContrast = Color(0xFFB0B1BB) +val outlineVariantDarkMediumContrast = Color(0xFF8E9099) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFE2E2E9) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF282A2F) +val inversePrimaryDarkMediumContrast = Color(0xFF2E4779) +val surfaceDimDarkMediumContrast = Color(0xFF121318) +val surfaceBrightDarkMediumContrast = Color(0xFF43444A) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF06070C) +val surfaceContainerLowDarkMediumContrast = Color(0xFF1C1D23) +val surfaceContainerDarkMediumContrast = Color(0xFF26282D) +val surfaceContainerHighDarkMediumContrast = Color(0xFF313238) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3C3D43) + +val primaryDarkHighContrast = Color(0xFFECEFFF) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFAAC2FD) +val onPrimaryContainerDarkHighContrast = Color(0xFF000A23) +val secondaryDarkHighContrast = Color(0xFFECEFFF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFBBC2D8) +val onSecondaryContainerDarkHighContrast = Color(0xFF040B1B) +val tertiaryDarkHighContrast = Color(0xFFFFEAFB) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFDBB8DA) +val onTertiaryContainerDarkHighContrast = Color(0xFF18031B) +val errorDarkHighContrast = Color(0xFFFFECE9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFAEA4) +val onErrorContainerDarkHighContrast = Color(0xFF220001) +val backgroundDarkHighContrast = Color(0xFF121318) +val onBackgroundDarkHighContrast = Color(0xFFE2E2E9) +val surfaceDarkHighContrast = Color(0xFF121318) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF44464F) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFEFEFF9) +val outlineVariantDarkHighContrast = Color(0xFFC1C2CC) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFE2E2E9) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF2E4779) +val surfaceDimDarkHighContrast = Color(0xFF121318) +val surfaceBrightDarkHighContrast = Color(0xFF4F5056) +val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) +val surfaceContainerLowDarkHighContrast = Color(0xFF1E1F25) +val surfaceContainerDarkHighContrast = Color(0xFF2F3036) +val surfaceContainerHighDarkHighContrast = Color(0xFF3A3B41) +val surfaceContainerHighestDarkHighContrast = Color(0xFF45464C) diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt new file mode 100644 index 0000000..36d1ab1 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt @@ -0,0 +1,47 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.material3.AppBarWithSearchColors +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import ru.shadowsparky.ui.isBlurEnabled + +object ComponentDefaults { + + @Composable + fun topAppBarColors(): TopAppBarColors { + return if (isBlurEnabled()) { + TopAppBarDefaults.topAppBarColors( + Color.Transparent, + Color.Transparent + ) + } else { + TopAppBarDefaults.topAppBarColors() + } + } + + @Composable + fun navBarContentColor(): Color { + return if (isBlurEnabled()) { + Color.Transparent + } else { + NavigationBarDefaults.containerColor + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun appBarWithSearchDefaults(): AppBarWithSearchColors { + return if (isBlurEnabled()) { + SearchBarDefaults.appBarWithSearchColors( + appBarContainerColor = Color.Transparent + ) + } else { + SearchBarDefaults.appBarWithSearchColors() + } + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Theme.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Theme.kt new file mode 100644 index 0000000..726c5fa --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Theme.kt @@ -0,0 +1,111 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AppTheme( + content: @Composable () -> Unit +) { + MaterialExpressiveTheme( + colorScheme = getColorScheme(), + typography = AppTypography, + content = content + ) +} + +@Composable +fun getColorScheme(): ColorScheme { + return when { + isSystemInDarkTheme() -> darkDynamicScheme() ?: darkScheme + else -> lightDynamicScheme() ?: lightScheme + } +} + +@Composable +expect fun lightDynamicScheme(): ColorScheme? + +@Composable +expect fun darkDynamicScheme(): ColorScheme? diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Type.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Type.kt new file mode 100644 index 0000000..2fbb1e6 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.material3.Typography + +val AppTypography = Typography() diff --git a/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.desktop.kt b/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.desktop.kt new file mode 100644 index 0000000..408d080 --- /dev/null +++ b/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.desktop.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.runtime.Composable + +@Composable +actual fun isTv(): Boolean = false diff --git a/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/theme/Theme.desktop.kt b/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/theme/Theme.desktop.kt new file mode 100644 index 0000000..9062018 --- /dev/null +++ b/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/theme/Theme.desktop.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +@Composable +actual fun lightDynamicScheme(): ColorScheme? = null + +@Composable +actual fun darkDynamicScheme(): ColorScheme? = null diff --git a/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt new file mode 100644 index 0000000..f5f64c1 --- /dev/null +++ b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt @@ -0,0 +1,80 @@ +package ru.shadowsparky.ui + +import com.arkivanov.essenty.lifecycle.Lifecycle +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.create +import com.arkivanov.essenty.lifecycle.pause +import com.arkivanov.essenty.lifecycle.resume +import com.arkivanov.essenty.lifecycle.start +import com.arkivanov.essenty.lifecycle.stop +import kotlinx.browser.document +import org.w3c.dom.events.Event + +@JsFun( + """ + function hideElementById(id) { + var el = document.getElementById(id); + el.style.display = 'none'; + } +""" +) +external fun hideElementById(id: String) + +@JsFun("(function(){ return document.visibilityState; })") +external fun pageVisibilityState(): String + +fun hookPageVisibility(lifecycle: LifecycleRegistry) { + + fun bootstrap() { + lifecycle.resume() + } + + fun onVisibilityChange() { + when (pageVisibilityState()) { + "visible" -> lifecycle.resume() + else -> lifecycle.stop() + } + } + + fun onPageHideShow(hidden: Boolean) { + if (hidden) { + // уходим глубже: STARTED → CREATED + when (lifecycle.state) { + Lifecycle.State.RESUMED -> { + lifecycle.pause(); lifecycle.stop() + } + + Lifecycle.State.STARTED -> { + lifecycle.stop() + } + + else -> Unit + } + } else { + // pageshow: поднимемся обратно + when (lifecycle.state) { + Lifecycle.State.INITIALIZED -> { + lifecycle.create(); lifecycle.start(); if (pageVisibilityState() == "visible") lifecycle.resume() + } + + Lifecycle.State.CREATED -> { + lifecycle.start(); if (pageVisibilityState() == "visible") lifecycle.resume() + } + + Lifecycle.State.STARTED -> { + if (pageVisibilityState() == "visible") lifecycle.resume() + } + + else -> Unit + } + } + } + + bootstrap() + + val visHandler: (Event) -> Unit = { onVisibilityChange() } + document.addEventListener("visibilitychange", visHandler) + + document.addEventListener("pagehide", { onPageHideShow(true) }) + document.addEventListener("pageshow", { onPageHideShow(false) }) +} diff --git a/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.wasmJs.kt b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.wasmJs.kt new file mode 100644 index 0000000..408d080 --- /dev/null +++ b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.wasmJs.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.runtime.Composable + +@Composable +actual fun isTv(): Boolean = false diff --git a/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/theme/Theme.wasm.kt b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/theme/Theme.wasm.kt new file mode 100644 index 0000000..9062018 --- /dev/null +++ b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/theme/Theme.wasm.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +@Composable +actual fun lightDynamicScheme(): ColorScheme? = null + +@Composable +actual fun darkDynamicScheme(): ColorScheme? = null diff --git a/libs/video-player/.gitignore b/libs/video-player/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/video-player/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/video-player/build.gradle.kts b/libs/video-player/build.gradle.kts new file mode 100644 index 0000000..61d1f83 --- /dev/null +++ b/libs/video-player/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) + alias(libs.plugins.convention.compose) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":libs:ui")) + } + } + val androidMain by getting { + dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + } + } + } + android { namespace = "ru.shadowsparky.videoplayer" } +} diff --git a/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt new file mode 100644 index 0000000..16526b5 --- /dev/null +++ b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt @@ -0,0 +1,213 @@ +package ru.shadowsparky.videoplayer + +import android.content.Context +import androiddev.libs.video_player.generated.resources.Res +import androiddev.libs.video_player.generated.resources.player_audio_track +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.getString +import org.koin.core.annotation.Factory + +@Factory +class AndroidPlayerController(context: Context) : PlayerController { + private val exoPlayer by lazy { ExoPlayer.Builder(context).build() } + + private val _state = MutableStateFlow(PlayerState()) + override val state: StateFlow = _state.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + override val events: Flow = _events.receiveAsFlow() + override val audioTracks = MutableStateFlow>(emptyList()) + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var progressJob: Job? = null + private var isListenerBound = false + private val listener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + updateState() + if (playbackState == Player.STATE_ENDED) { + _events.trySend(PlayerEvent.Ended) + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + updateState() + if (isPlaying) startProgressWatcher() else stopProgressWatcher() + } + + private var trackJob: Job? = null + override fun onEvents(player: Player, events: Player.Events) { + updateState() + if (events.contains(Player.EVENT_TRACKS_CHANGED)) { + trackJob?.cancel() + trackJob = scope.launch { updateAudioTracks() } + } + } + + override fun onPlayerError(error: PlaybackException) { + _events.trySend(PlayerEvent.Error(error.message ?: "Unknown ExoPlayer error")) + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + _state.update { + it.copy(videoWidth = videoSize.width, videoHeight = videoSize.height) + } + } + } + + init { bindListenerIfNeeded(false) } + + private fun bindListenerIfNeeded(allowFail: Boolean) { + try { + if (isListenerBound) return + exoPlayer.addListener(listener) + isListenerBound = true + } catch (t: Throwable) { + if (allowFail) throw t + } + } + + private var isTrackInitialized = false + + @OptIn(UnstableApi::class) + private suspend fun updateAudioTracks() { + val player = exoPlayer + val tracks = player.currentTracks + + val list = tracks.groups.mapIndexedNotNull { groupIndex, group -> + if (group.type != C.TRACK_TYPE_AUDIO) return@mapIndexedNotNull null + + val trackGroup = group.mediaTrackGroup + if (trackGroup.length == 0) return@mapIndexedNotNull null + + (0 until trackGroup.length).map { trackIndex -> + val desc = getString(Res.string.player_audio_track, groupIndex) + AudioTrackInfo( + id = "$groupIndex:$trackIndex", + groupIndex = groupIndex, + trackIndex = trackIndex, + description = desc.trim(), + isSelected = group.isTrackSelected(trackIndex) + ) + } + }.flatten() + + audioTracks.value = list + + if (!isTrackInitialized && list.isNotEmpty()) { + isTrackInitialized = true + list.firstOrNull()?.let { selectAudioTrack(it.id) } + } + } + + @OptIn(UnstableApi::class) + override suspend fun selectAudioTrack(id: String) = withContext(Dispatchers.Main) { + val parts = id.split(":") + if (parts.size != 2) return@withContext + val groupIndex = parts.first().toIntOrNull() ?: return@withContext + val trackIndex = parts.last().toIntOrNull() ?: return@withContext + + val tracks = exoPlayer.currentTracks + val group = tracks.groups.getOrNull(groupIndex) ?: return@withContext + if (group.type != C.TRACK_TYPE_AUDIO) return@withContext + + val trackGroup = group.mediaTrackGroup + if (trackIndex !in 0 until trackGroup.length) return@withContext + + val override = TrackSelectionOverride(trackGroup, listOf(trackIndex)) + + exoPlayer.trackSelectionParameters = + exoPlayer.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .addOverride(override) + .build() + + updateAudioTracks() + } + + override suspend fun load(url: String, startPositionMs: Long): Unit = + withContext(Dispatchers.Main) { + bindListenerIfNeeded(true) + val mediaItem = MediaItem.fromUri(url) + exoPlayer.setMediaItem(mediaItem) + exoPlayer.prepare() + if (startPositionMs > 0) exoPlayer.seekTo(startPositionMs) + _state.update { it.copy(videoUrl = url) } + _events.trySend(PlayerEvent.Playing) + } + + override suspend fun play() = withContext(Dispatchers.Main) { + exoPlayer.play() + } + + override suspend fun pause() = withContext(Dispatchers.Main) { + exoPlayer.pause() + } + + override suspend fun seekTo(positionMs: Long) = withContext(Dispatchers.Main) { + exoPlayer.seekTo(positionMs) + updateState() + } + + override suspend fun release() = withContext(Dispatchers.Main) { + stopProgressWatcher() + exoPlayer.removeListener(listener) + exoPlayer.release() + scope.cancel() + } + + private fun updateState() { + _state.update { s -> + s.copy( + isPlaying = exoPlayer.isPlaying, + isBuffering = exoPlayer.playbackState == Player.STATE_BUFFERING, + isEnded = exoPlayer.playbackState == Player.STATE_ENDED, + durationMs = exoPlayer.duration.coerceAtLeast(0), + currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0), + bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0), + volume = exoPlayer.volume + ) + } + } + + private fun startProgressWatcher() { + if (progressJob?.isActive == true) return + progressJob = scope.launch { + while (isActive) { + updateState() + delay(200) + } + } + } + + private fun stopProgressWatcher() { + progressJob?.cancel() + progressJob = null + } + + internal fun getPlayerInstance(): ExoPlayer = exoPlayer +} diff --git a/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.android.kt b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.android.kt new file mode 100644 index 0000000..97d2ff2 --- /dev/null +++ b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.android.kt @@ -0,0 +1,34 @@ +package ru.shadowsparky.videoplayer + +import android.app.Application +import android.content.Intent +import androidx.core.net.toUri +import org.koin.core.annotation.Factory + +@Factory +class AndroidExternalPlayer(private val app: Application) : ExternalPlayer { + + override fun play(url: String) { + if (!launchInternal(url, true)) { + if (!launchInternal(url, false)) { + error("Unable to launch video") + } + } + } + + private fun launchInternal(uri: String, withType: Boolean): Boolean { + val intent = Intent(Intent.ACTION_VIEW) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (withType) { + intent.setDataAndType(uri.toUri(), "video/*") + } else { + intent.data = uri.toUri() + } + try { + app.startActivity(intent) + } catch (_: Exception) { + return false + } + return true + } +} diff --git a/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/PictureInPicture.kt b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/PictureInPicture.kt new file mode 100644 index 0000000..83fe78b --- /dev/null +++ b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/PictureInPicture.kt @@ -0,0 +1,57 @@ +package ru.shadowsparky.videoplayer.ui + +import android.app.PictureInPictureParams +import android.content.Context +import android.graphics.Rect +import android.os.Build +import android.util.Rational +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer + +internal fun Context.updatePipAutoEnter(playerView: View, enabled: Boolean) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + val w = if (playerView.width > 0) playerView.width else 16 + val h = if (playerView.height > 0) playerView.height else 9 + + val rect = Rect() + playerView.getGlobalVisibleRect(rect) + + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(w, h)) + .setSourceRectHint(rect) + .setAutoEnterEnabled(enabled) + .build() + + findActivity()?.setPictureInPictureParams(params) +} + +@Composable +internal fun rememberPipMode(): State { + val ctx = LocalContext.current + val activity = ctx.findActivity() as? ComponentActivity + val state = remember { mutableStateOf(activity?.isInPictureInPictureMode == true) } + + DisposableEffect(activity) { + if (activity == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return@DisposableEffect onDispose { } + } + + val cb = Consumer { info -> + state.value = info.isInPictureInPictureMode + } + + activity.addOnPictureInPictureModeChangedListener(cb) + onDispose { activity.removeOnPictureInPictureModeChangedListener(cb) } + } + + return state +} diff --git a/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.android.kt b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.android.kt new file mode 100644 index 0000000..f3486e9 --- /dev/null +++ b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.android.kt @@ -0,0 +1,115 @@ +package ru.shadowsparky.videoplayer.ui + +import android.app.Activity +import android.content.Context +import android.content.pm.ActivityInfo +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.annotation.OptIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import ru.shadowsparky.videoplayer.AndroidPlayerController +import ru.shadowsparky.videoplayer.CanvasEvent +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.VideoScaleType + +@OptIn(UnstableApi::class) +@Composable +actual fun VideoCanvas( + controller: PlayerController, + modifier: Modifier, + scaleType: VideoScaleType, + onCanvasEvent: (CanvasEvent) -> Unit +) { + val androidController = controller as? AndroidPlayerController + ?: throw IllegalArgumentException("Controller must be AndroidPlayerController") + + val context = LocalContext.current + + val pipMode by rememberPipMode() + + LaunchedEffect(pipMode) { + if (pipMode) onCanvasEvent(CanvasEvent.HideControls) + } + + DisposableEffect(Unit) { + context.enterFullscreen() + onDispose { + context.exitFullscreen() + } + } + + AndroidView( + modifier = modifier, + factory = { ctx -> + PlayerView(ctx).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + useController = false + + resizeMode = when (scaleType) { + VideoScaleType.FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT + VideoScaleType.FULL -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + VideoScaleType.STRETCH -> AspectRatioFrameLayout.RESIZE_MODE_FILL + } + + keepScreenOn = true + context.updatePipAutoEnter(this, true) + } + }, + update = { playerView -> + if (playerView.player != androidController.getPlayerInstance()) { + playerView.player = androidController.getPlayerInstance() + } + }, + onRelease = { playerView -> + context.updatePipAutoEnter(playerView, false) + playerView.player = null + } + ) +} + +internal fun Context.findActivity(): Activity? { + var ctx = this + while (ctx is android.content.ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null +} + +private fun Context.enterFullscreen() { + val activity = findActivity() ?: return + val window = activity.window ?: return + + WindowCompat.setDecorFitsSystemWindows(window, false) + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE +} + +private fun Context.exitFullscreen() { + val activity = findActivity() ?: return + val window = activity.window ?: return + + WindowCompat.setDecorFitsSystemWindows(window, true) + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.show(WindowInsetsCompat.Type.systemBars()) + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED +} diff --git a/libs/video-player/src/commonMain/composeResources/drawable/lock_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/lock_24px.xml new file mode 100644 index 0000000..163f148 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/lock_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/drawable/lock_open_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/lock_open_24px.xml new file mode 100644 index 0000000..1140e97 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/lock_open_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/drawable/music_note_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/music_note_24px.xml new file mode 100644 index 0000000..6a8bdd8 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/music_note_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/drawable/pause_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/pause_24px.xml new file mode 100644 index 0000000..37a379d --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/pause_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/drawable/play_arrow_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/play_arrow_24px.xml new file mode 100644 index 0000000..edfc427 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/play_arrow_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/drawable/replay_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/replay_24px.xml new file mode 100644 index 0000000..952cce7 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/replay_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/values/strings.xml b/libs/video-player/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000..a4f7579 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,6 @@ + + Ошибка плеера + Во время проигрывания видео произошла ошибка: %1$s + Повторить + Аудио %1$d + diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/AudioTrackInfo.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/AudioTrackInfo.kt new file mode 100644 index 0000000..4fa557b --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/AudioTrackInfo.kt @@ -0,0 +1,9 @@ +package ru.shadowsparky.videoplayer + +data class AudioTrackInfo( + val id: String, + val groupIndex: Int, + val trackIndex: Int, + val description: String, + val isSelected: Boolean +) diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/CanvasEvent.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/CanvasEvent.kt new file mode 100644 index 0000000..f856a97 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/CanvasEvent.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.videoplayer + +sealed class CanvasEvent { + data object HideControls : CanvasEvent() +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ControlsState.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ControlsState.kt new file mode 100644 index 0000000..e0a3939 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ControlsState.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.videoplayer + +import androidx.compose.runtime.MutableState + +class ControlsState( + val isLocked: MutableState +) diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.kt new file mode 100644 index 0000000..650cf71 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.videoplayer + +interface ExternalPlayer { + fun play(url: String) +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerController.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerController.kt new file mode 100644 index 0000000..5b04c75 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerController.kt @@ -0,0 +1,19 @@ +package ru.shadowsparky.videoplayer + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface PlayerController { + val state: StateFlow + val events: Flow + val audioTracks: StateFlow> + + suspend fun load(url: String, startPositionMs: Long = 0L) + + suspend fun play() + suspend fun pause() + suspend fun seekTo(positionMs: Long) + suspend fun selectAudioTrack(id: String) + + suspend fun release() +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerEvent.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerEvent.kt new file mode 100644 index 0000000..fa5abb2 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerEvent.kt @@ -0,0 +1,8 @@ +package ru.shadowsparky.videoplayer + +sealed interface PlayerEvent { + data object Idle : PlayerEvent + data object Playing : PlayerEvent + data class Error(val message: String) : PlayerEvent + data object Ended : PlayerEvent +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerState.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerState.kt new file mode 100644 index 0000000..21f8c95 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerState.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.videoplayer + +data class PlayerState( + val isPlaying: Boolean = false, + val isBuffering: Boolean = false, + val isEnded: Boolean = false, + + val durationMs: Long = 0L, + val currentPositionMs: Long = 0L, + val bufferedPositionMs: Long = 0L, + + val videoWidth: Int = 0, + val videoHeight: Int = 0, + + val volume: Float = 1.0f, + + val videoUrl: String? = null +) diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoPlayerModule.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoPlayerModule.kt new file mode 100644 index 0000000..6418cc2 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoPlayerModule.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.videoplayer + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@Configuration +@ComponentScan +class VideoPlayerModule diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoScaleType.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoScaleType.kt new file mode 100644 index 0000000..30f3cfc --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoScaleType.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.videoplayer + +enum class VideoScaleType { + FIT, + FULL, + STRETCH +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt new file mode 100644 index 0000000..06727d9 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt @@ -0,0 +1,447 @@ +package ru.shadowsparky.videoplayer.ui + +import androiddev.libs.video_player.generated.resources.Res +import androiddev.libs.video_player.generated.resources.lock_24px +import androiddev.libs.video_player.generated.resources.lock_open_24px +import androiddev.libs.video_player.generated.resources.music_note_24px +import androiddev.libs.video_player.generated.resources.pause_24px +import androiddev.libs.video_player.generated.resources.play_arrow_24px +import androiddev.libs.video_player.generated.resources.replay_24px +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.focus.FocusProperties +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import ru.shadowsparky.videoplayer.AudioTrackInfo +import ru.shadowsparky.videoplayer.ControlsState +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.PlayerState + +private const val DEF_SEEK_TIME = 10L + +@Composable +fun DefaultControls( + state: PlayerState, + controlsState: ControlsState, + controller: PlayerController, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + val audioTracksState by controller.audioTracks.collectAsState() + + val isUiLocked = controlsState.isLocked + var isDragging by rememberSaveable { mutableStateOf(false) } + var sliderPosition by rememberSaveable { mutableStateOf(0f) } + + val currentUiPosition = if (isDragging) sliderPosition else state.currentPositionMs.toFloat() + val totalDuration = state.durationMs.toFloat().coerceAtLeast(1f) + + val focus = remember { FocusContainer() } + + Box( + modifier = modifier + .fillMaxSize() + .background(if (isUiLocked.value) Color.Transparent else Color.Black.copy(alpha = 0.4f)) + ) { + SideDoubleTapSeekZones(controller) + Row(modifier = Modifier.align(Alignment.TopEnd).focusGroup().padding(8.dp)) { + if (audioTracksState.isNotEmpty() && !isUiLocked.value) { + AudioTracksButton( + focus, + tracks = audioTracksState, + onSelect = { id -> + scope.launch { controller.selectAudioTrack(id) } + } + ) + } + LockUiButton(focus, isUiLocked.value, audioTracksState.isNotEmpty()) { + isUiLocked.value = it + } + } + if (!isUiLocked.value) { + CenterControls(focus, state, controller) + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(16.dp) + .controlsBackground() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatDuration(currentUiPosition.toLong()), + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = formatDuration(state.durationMs), + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + Slider( + value = currentUiPosition, + valueRange = 0f..totalDuration, + onValueChange = { newPos -> + isDragging = true + sliderPosition = newPos + }, + onValueChangeFinished = { + isDragging = false + scope.launch { + controller.seekTo(sliderPosition.toLong()) + } + }, + modifier = Modifier.fillMaxWidth() + .focusableWithBorder(focus.slider) { + next = if (audioTracksState.isNotEmpty()) focus.audioTrack else focus.lock + previous = focus.next + } + .onPreviewKeyEvent { + if (it.type == KeyEventType.KeyDown) { + when (it.key) { + Key.DirectionLeft -> { + scope.launch { controller.seek(true) } + } + Key.DirectionRight -> { + scope.launch { controller.seek(false) } + } + Key.DirectionUp -> focus.next.requestFocus() + Key.DirectionDown -> focus.lock.requestFocus() + else -> return@onPreviewKeyEvent false + } + } + true + } + ) + } + } + } +} + +@Composable +private fun LockUiButton( + focusContainer: FocusContainer, + state: Boolean, + hasAudioTrack: Boolean, + onChanged: (Boolean) -> Unit +) { + IconButton( + onClick = { onChanged(!state) }, + modifier = Modifier.focusableWithBorder(focusContainer.lock) { + next = focusContainer.prev + previous = if (hasAudioTrack) focusContainer.audioTrack else focusContainer.slider + } + ) { + val icon = if (state) Res.drawable.lock_24px else Res.drawable.lock_open_24px + Icon(painterResource(icon), null, tint = Color.White) + } +} + +@Composable +private fun AudioTracksButton( + focusContainer: FocusContainer, + tracks: List, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + val tracks = tracks.map { track -> track to FocusRequester() } + IconButton( + onClick = { expanded = true }, + modifier = Modifier.focusableWithBorder(focusContainer.audioTrack) { + next = focusContainer.lock + previous = focusContainer.slider + } + ) { + Icon( + painter = painterResource(Res.drawable.music_note_24px), + contentDescription = "Audio tracks", + tint = Color.White + ) + } + LaunchedEffect(expanded) { + if (expanded) { + delay(500) + tracks.firstOrNull()?.second?.requestFocus() + } + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.focusable() + ) { + for (i in 0 until tracks.size) { + val (track, requester) = tracks[i] + DropdownMenuItem( + text = { + Text( + text = if (track.isSelected) + "● ${track.description}" + else + track.description + ) + }, + onClick = { + expanded = false + onSelect(track.id) + }, + modifier = Modifier.focusableWithBorder(requester) { + previous = tracks.getOrNull(i - 1)?.second ?: focusContainer.audioTrack + next = tracks.getOrNull(i + 1)?.second ?: focusContainer.audioTrack + } + ) + } + } + } +} + +@Composable +private fun BoxScope.SideDoubleTapSeekZones(controller: PlayerController, enabled: Boolean = true) { + val scope = rememberCoroutineScope() + if (!enabled) return + Row(modifier = Modifier.matchParentSize()) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.3f) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + scope.launch { controller.seek(true) } + } + ) + } + ) + Spacer(modifier = Modifier.fillMaxHeight().weight(0.3f)) + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.3f) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + scope.launch { + controller.seek(false) + } + } + ) + } + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun BoxScope.CenterControls( + focusContainer: FocusContainer, + state: PlayerState, + controller: PlayerController +) { + val scope = rememberCoroutineScope() + Box( + modifier = Modifier + .align(Alignment.Center) + .focusGroup() + .controlsBackground() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + when { + state.isBuffering -> { + CircularWavyProgressIndicator() + } + + state.isEnded -> { + IconButton( + onClick = { + scope.launch { + controller.seekTo(0) + controller.play() + } + }, modifier = Modifier.focusableWithBorder(focusContainer.next) { + previous = focusContainer.lock + next = focusContainer.slider + } + ) { + Icon( + painter = painterResource(Res.drawable.replay_24px), + contentDescription = "Replay", + modifier = Modifier.size(64.dp), + tint = Color.White + ) + } + } + + else -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + IconButton( + onClick = { + scope.launch { controller.seek(true) } + }, modifier = Modifier.focusableWithBorder(focusContainer.prev) { + next = focusContainer.next + previous = focusContainer.play + } + ) { + Text( + text = "-$DEF_SEEK_TIME", + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + + IconButton( + onClick = { + scope.launch { + if (state.isPlaying) controller.pause() else controller.play() + } + }, modifier = Modifier.focusableWithBorder(focusContainer.next) { + next = focusContainer.next + previous = focusContainer.prev + } + ) { + Icon( + painter = painterResource( + if (state.isPlaying) + Res.drawable.pause_24px + else + Res.drawable.play_arrow_24px + ), + contentDescription = "Play/Pause", + modifier = Modifier.size(64.dp), + tint = Color.White + ) + } + + IconButton( + onClick = { + scope.launch { controller.seek(false) } + }, modifier = Modifier.focusableWithBorder(focusContainer.next) { + next = focusContainer.slider + previous = focusContainer.play + } + ) { + Text( + text = "+$DEF_SEEK_TIME", + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } +} + +private suspend fun PlayerController.seek(minus: Boolean) { + val state = state.value + val newPos = if (minus) { + (state.currentPositionMs - DEF_SEEK_TIME * 1_000) + .coerceAtLeast(0L) + } else { + (state.currentPositionMs + DEF_SEEK_TIME * 1_000) + .coerceAtMost(state.durationMs) + } + seekTo(newPos) +} + +private fun Modifier.controlsBackground(): Modifier { + return background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(16.dp)) +} + +private fun formatDuration(millis: Long): String { + if (millis < 0) return "00:00:00" + + val totalSeconds = millis / 1000 + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + val h = hours.toString().padStart(2, '0') + val m = minutes.toString().padStart(2, '0') + val s = seconds.toString().padStart(2, '0') + + return "$h:$m:$s" +} + +private class FocusContainer( + val audioTrack: FocusRequester = FocusRequester(), + val audioTrackContainer: FocusRequester = FocusRequester(), + val lock: FocusRequester = FocusRequester(), + val prev: FocusRequester = FocusRequester(), + val play: FocusRequester = FocusRequester(), + val next: FocusRequester = FocusRequester(), + val slider: FocusRequester = FocusRequester() +) + +@Composable +private fun Modifier.focusableWithBorder( + requester: FocusRequester, + focusProperties: FocusProperties.() -> Unit +): Modifier { + var isFocused by remember { mutableStateOf(false) } + return focusRequester(requester) + .focusProperties(focusProperties) + .onFocusChanged { isFocused = it.isFocused } + .border( + width = 2.dp, + color = if (isFocused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = CircleShape + ) +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.kt new file mode 100644 index 0000000..b5f7786 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.kt @@ -0,0 +1,15 @@ +package ru.shadowsparky.videoplayer.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import ru.shadowsparky.videoplayer.CanvasEvent +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.VideoScaleType + +@Composable +expect fun VideoCanvas( + controller: PlayerController, + modifier: Modifier, + scaleType: VideoScaleType = VideoScaleType.FIT, + onCanvasEvent: (CanvasEvent) -> Unit +) diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt new file mode 100644 index 0000000..8dc3b97 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt @@ -0,0 +1,170 @@ +package ru.shadowsparky.videoplayer.ui + +import androiddev.libs.video_player.generated.resources.Res +import androiddev.libs.video_player.generated.resources.player_error_summary +import androiddev.libs.video_player.generated.resources.player_error_title +import androiddev.libs.video_player.generated.resources.player_retry +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import ru.shadowsparky.ui.components.isTv +import ru.shadowsparky.videoplayer.CanvasEvent +import ru.shadowsparky.videoplayer.ControlsState +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.PlayerEvent +import ru.shadowsparky.videoplayer.PlayerState + +@Composable +fun VideoPlayer( + controller: PlayerController, + modifier: Modifier = Modifier, + overlayContent: @Composable (PlayerState, ControlsState) -> Unit = { player, controls -> + DefaultControls(player, controls, controller = controller) + } +) { + val isTv = isTv() + val state by controller.state.collectAsState() + val scope = rememberCoroutineScope() + val controlsState by remember { + mutableStateOf(ControlsState(mutableStateOf(false))) + } + var areControlsVisible by remember { mutableStateOf(false) } + var lastErrorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(controller) { + controller.events.collect { event -> + when (event) { + is PlayerEvent.Error -> { + lastErrorMessage = event.message + areControlsVisible = true + } + + is PlayerEvent.Ended -> { + areControlsVisible = true + } + + is PlayerEvent.Idle, is PlayerEvent.Playing -> { + lastErrorMessage = null + areControlsVisible = true + } + } + } + } + + LaunchedEffect(areControlsVisible, state.isPlaying, lastErrorMessage) { + if (areControlsVisible && state.isPlaying && lastErrorMessage == null && !isTv) { + delay(3000) + areControlsVisible = false + } + } + + val rootModifier = if (isTv) { + modifier + } else { + modifier.clickable { areControlsVisible = !areControlsVisible } + } + + Box(modifier = rootModifier.background(Color.Black)) { + VideoCanvas( + controller = controller, + modifier = rootModifier.fillMaxSize(), + onCanvasEvent = { + areControlsVisible = when (it) { + CanvasEvent.HideControls -> false + } + } + ) + + if (lastErrorMessage != null) { + PlaybackError(lastErrorMessage!!, scope, state, controller) { + lastErrorMessage = null + } + } else { + AnimatedVisibility( + visible = areControlsVisible || !state.isPlaying, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.matchParentSize() + ) { overlayContent(state, controlsState) } + } + } +} + +@Composable +private fun PlaybackError( + lastErrorMessage: String, + scope: CoroutineScope, + state: PlayerState, + controller: PlayerController, + onReset: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.8f)) + .clickable(enabled = false) {}, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(Res.string.player_error_title), + style = MaterialTheme.typography.titleSmall, + color = Color.White + ) + Text( + text = stringResource(Res.string.player_error_summary, lastErrorMessage), + style = MaterialTheme.typography.bodyMedium, + color = Color.LightGray, + modifier = Modifier.padding(top = 4.dp) + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + onReset() + scope.launch { + val currentUrl = state.videoUrl + val currentPos = state.currentPositionMs + if (currentUrl != null) { + controller.load(currentUrl, currentPos) + controller.play() + } + } + }, + ) { + Text(stringResource(Res.string.player_retry)) + } + } + } +} diff --git a/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.desktop.kt b/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.desktop.kt new file mode 100644 index 0000000..1e241f6 --- /dev/null +++ b/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.desktop.kt @@ -0,0 +1,8 @@ +package ru.shadowsparky.videoplayer + +import org.koin.core.annotation.Factory + +@Factory +class DesktopExternalPlayer : ExternalPlayer { + override fun play(url: String) { throw NotImplementedError() } +} diff --git a/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.desktop.kt b/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.desktop.kt new file mode 100644 index 0000000..22c0853 --- /dev/null +++ b/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.desktop.kt @@ -0,0 +1,17 @@ +package ru.shadowsparky.videoplayer.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import ru.shadowsparky.videoplayer.CanvasEvent +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.VideoScaleType + +@Composable +actual fun VideoCanvas( + controller: PlayerController, + modifier: Modifier, + scaleType: VideoScaleType, + onCanvasEvent: (CanvasEvent) -> Unit +) { + throw NotImplementedError() +} diff --git a/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.wasmJs.kt b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.wasmJs.kt new file mode 100644 index 0000000..159d55a --- /dev/null +++ b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.wasmJs.kt @@ -0,0 +1,11 @@ +package ru.shadowsparky.videoplayer + +import kotlinx.browser.window +import org.koin.core.annotation.Factory + +@Factory +class WasmExternalPlayer : ExternalPlayer { + override fun play(url: String) { + window.open(url, "_blank", "noreferrer") + } +} diff --git a/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/WasmPlayerController.kt b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/WasmPlayerController.kt new file mode 100644 index 0000000..e77c513 --- /dev/null +++ b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/WasmPlayerController.kt @@ -0,0 +1,139 @@ +package ru.shadowsparky.videoplayer + +import kotlinx.browser.document +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.await +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import org.koin.core.annotation.Factory +import org.w3c.dom.HTMLVideoElement + +@Factory +class WasmPlayerController : PlayerController { + + internal val videoElement = (document.createElement("video") as HTMLVideoElement).apply { + style.apply { + setProperty("position", "absolute") + setProperty("left", "0") + setProperty("top", "0") + setProperty("width", "100%") + setProperty("height", "100%") + setProperty("background-color", "black") + } + setAttribute("controls", "") + setAttribute("preload", "auto") + } + + private val _state = MutableStateFlow(PlayerState()) + override val state: StateFlow = _state.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + override val events: Flow = _events.receiveAsFlow() + + override val audioTracks: StateFlow> = MutableStateFlow(emptyList()) + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + init { + setupListeners() + } + + private fun setupListeners() { + videoElement.onplay = { updateState() } + videoElement.onpause = { updateState() } + + videoElement.ontimeupdate = { updateState() } + + videoElement.onwaiting = { + _state.update { it.copy(isBuffering = true) } + } + + videoElement.onplaying = { + _state.update { it.copy(isBuffering = false) } + } + + videoElement.onended = { + updateState() + _events.trySend(PlayerEvent.Ended) + } + + videoElement.addEventListener("error") { + val err = videoElement.error?.toString() ?: "Unknown HTML5 Video Error" + _events.trySend(PlayerEvent.Error(err)) + } + + videoElement.onloadedmetadata = { + updateState() + _state.update { + it.copy( + videoWidth = videoElement.videoWidth, + videoHeight = videoElement.videoHeight + ) + } + } + } + + override suspend fun load(url: String, startPositionMs: Long) { + println("load $url called") + videoElement.src = url + videoElement.load() + if (startPositionMs > 0) { + videoElement.currentTime = startPositionMs / 1000.0 + } + _state.update { it.copy(videoUrl = url) } + _events.trySend(PlayerEvent.Playing) + println("load $url end") + } + + @OptIn(ExperimentalWasmJsInterop::class) + override suspend fun play() { + try { + println("play called") + videoElement.play().await() + println("play end") + } catch (e: Throwable) { + println("Play failed (autoplay policy?): $e. src: ${videoElement?.src?.toString()}") + } + } + + override suspend fun pause() { + videoElement.pause() + } + + override suspend fun seekTo(positionMs: Long) { + videoElement.currentTime = positionMs / 1000.0 + updateState() + } + + override suspend fun release() { + videoElement.pause() + videoElement.src = "" + videoElement.remove() + scope.cancel() + } + + private fun updateState() { + _state.update { s -> + s.copy( + isPlaying = !videoElement.paused && !videoElement.ended, + isBuffering = false, + isEnded = videoElement.ended, + durationMs = (videoElement.duration * 1000).toLong().coerceAtLeast(0), + currentPositionMs = (videoElement.currentTime * 1000).toLong().coerceAtLeast(0), + bufferedPositionMs = (videoElement.currentTime * 1000).toLong(), + volume = videoElement.volume.toFloat() + ) + } + } + + override suspend fun selectAudioTrack(id: String) { + } +} diff --git a/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.wasmJs.kt b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.wasmJs.kt new file mode 100644 index 0000000..b87df83 --- /dev/null +++ b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.wasmJs.kt @@ -0,0 +1,78 @@ +package ru.shadowsparky.videoplayer.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import kotlinx.browser.document +import org.w3c.dom.HTMLElement +import ru.shadowsparky.videoplayer.CanvasEvent +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.VideoScaleType +import ru.shadowsparky.videoplayer.WasmPlayerController + +private const val VIDEO_LAYER = "video-layer" +private const val ROOT_LAYER = "compose-root" + +@Composable +actual fun VideoCanvas( + controller: PlayerController, + modifier: Modifier, + scaleType: VideoScaleType, + onCanvasEvent: (CanvasEvent) -> Unit +) { + val wasmController = controller as? WasmPlayerController ?: return + + DisposableEffect(Unit) { + showVideoLayer(wasmController, scaleType) + onDispose { + hideVideoLayer(wasmController) + } + } +} + +private fun showVideoLayer( + wasmController: WasmPlayerController, + scaleType: VideoScaleType +) { + setVideoLayer(true) + val videoLayer = document.getElementById(VIDEO_LAYER) as HTMLElement + val video = wasmController.videoElement + videoLayer.appendChild(video) + + video.style.apply { + val fit = when (scaleType) { + VideoScaleType.FIT -> "contain" + VideoScaleType.FULL -> "cover" + VideoScaleType.STRETCH -> "fill" + } + setProperty("object-fit", fit) + } +} + +private fun hideVideoLayer(wasmController: WasmPlayerController) { + val video = wasmController.videoElement + + video.pause() + video.removeAttribute("src") + video.load() + setVideoLayer(false) +} + +private fun setVideoLayer(isVideoEnabled: Boolean) { + val videoLayer = document.getElementById(VIDEO_LAYER) as HTMLElement + val composeRoot = document.getElementById(ROOT_LAYER) as HTMLElement + + videoLayer.innerHTML = "" + videoLayer.setVisibility(isVideoEnabled) + composeRoot.setVisibility(!isVideoEnabled) +} + +private fun HTMLElement.setVisibility(isVisible: Boolean) { + if (isVisible) { + style.setProperty("visibility", "visible") + style.removeProperty("pointer-events") + } else { + style.setProperty("visibility", "hidden") + style.setProperty("pointer-events", "none") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..95a2aa9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,48 @@ +pluginManagement { + includeBuild("build-logic") + + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { + setUrl("https://jitpack.io") // maven repo where the current library resides + } + } +} + +rootProject.name = "AndroidDev" + +include(":apps:vbox:common") +include(":apps:vbox:client:androidApp") +include(":apps:vbox:client:shared") +include(":apps:vbox:client:web") +include(":apps:vbox:backend") + +include(":apps:call-blocker") + +include(":apps:experimentarium:androidApp") +include(":apps:experimentarium:shared") +include(":apps:experimentarium:web") + +include(":libs:backend-base") +include(":libs:base") +include(":libs:ui") +include(":libs:http-client") +include(":libs:android-stub") +include(":libs:video-player")