init projects

This commit is contained in:
2026-06-17 18:58:41 +03:00
parent 8543c4e241
commit 21a768edd5
505 changed files with 19998 additions and 93 deletions
+4
View File
@@ -0,0 +1,4 @@
keystore.properties
release.keystore
*.keystore
build
@@ -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)
}
+41
View File
@@ -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 { *; }
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".App"
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Videobox"
tools:ignore="UnusedAttribute">
<activity
android:name=".MainActivity"
android:configChanges="screenSize|screenLayout|orientation|smallestScreenSize"
android:exported="true"
android:supportsPictureInPicture="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="ru.shadowsparky.vbox.action.OPEN_SAVED_MOVIES" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="box.shadowsparky.ru" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -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<VBox> { androidContext(this@App) }
if (BuildConfig.DEBUG) StrictMode.enableDefaults()
}
}
@Module
class AppModule {
@Single
fun provideAppConfig(): AppConfig {
return object : AppConfig {
override fun isDebug(): Boolean = BuildConfig.DEBUG
}
}
}
@@ -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<RootFactory>().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 = "/")
}
}
@@ -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")
}
@@ -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 = {}
)
}
@@ -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"))
) {
}
}
@@ -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()
}
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14zM7.33,12.5l2.14,-1.53 2.14,1.53 -0.83,-2.46 2.15,-1.5h-2.62L9.47,6l-0.84,2.54L6,8.54l2.14,1.49z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM12,14.5v-9l6,4.5 -6,4.5z" />
</vector>
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:scaleX="0.38866666"
android:scaleY="0.38866666"
android:translateX="7.336"
android:translateY="7.336">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM12,14.5v-9l6,4.5 -6,4.5z" />
</group>
</vector>
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:scaleX="0.38866666"
android:scaleY="0.38866666"
android:translateX="7.336"
android:translateY="7.336">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM12,14.5v-9l6,4.5 -6,4.5z" />
</group>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,6.5c3.79,0 7.17,2.13 8.82,5.5 -1.65,3.37 -5.02,5.5 -8.82,5.5S4.83,15.37 3.18,12C4.83,8.63 8.21,6.5 12,6.5m0,-2C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,9.5c1.38,0 2.5,1.12 2.5,2.5s-1.12,2.5 -2.5,2.5 -2.5,-1.12 -2.5,-2.5 1.12,-2.5 2.5,-2.5m0,-2c-2.48,0 -4.5,2.02 -4.5,4.5s2.02,4.5 4.5,4.5 4.5,-2.02 4.5,-4.5 -2.02,-4.5 -4.5,-4.5z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_fragment_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".presentation.MainActivity">
</androidx.fragment.app.FragmentContainerView>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_banner_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">Videobox</string>
<string name="saved_movies">Сохранённые фильмы</string>
<string name="saved_movies_long">Просмотр списка ранее сохранённых фильмов</string>
</resources>
@@ -0,0 +1,6 @@
<resources>
<style name="BaseTheme" parent="@style/Theme.Material3.DynamicColors.DayNight.NoActionBar" />
<style name="Theme.Videobox" parent="BaseTheme" />
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:enabled="true"
android:icon="@drawable/baseline_saved_search_24"
android:shortcutId="saved_movies"
android:shortcutLongLabel="@string/saved_movies_long"
android:shortcutShortLabel="@string/saved_movies">
<intent
android:action="ru.shadowsparky.vbox.action.OPEN_SAVED_MOVIES"
android:targetClass="ru.shadowsparky.videobox.multiplatform.MainActivity"
android:targetPackage="ru.shadowsparky.videobox.multiplatform" />
</shortcut>
</shortcuts>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts></shortcuts>
+1
View File
@@ -0,0 +1 @@
build
+51
View File
@@ -0,0 +1,51 @@
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.sqldelight)
alias(libs.plugins.convention.koin)
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("ru.shadowsparky.vbox.app")
generateAsync.set(true)
}
}
}
kotlin {
sourceSets {
commonMain.dependencies {
api(project(":apps:vbox:common"))
implementation(libs.coil.compose)
implementation(project(":libs:base"))
implementation(project(":libs:ui"))
implementation(project(":libs:http-client"))
implementation(project(":libs:video-player"))
}
androidMain.dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.sqlite.framework)
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val wasmJsMain by getting {
dependencies {
implementation(libs.sqldelight.web.driver)
implementation(npm("@cashapp/sqldelight-sqljs-worker", "2.1.0"))
implementation(npm("sql.js", "1.10.3")) // или актуальную 1.x
implementation(devNpm("copy-webpack-plugin", "11.0.0"))
}
}
}
android {
namespace = "ru.shadowsparky.vbox.shared"
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
@@ -0,0 +1,388 @@
package app.cash.sqldelight.driver.android
import android.content.Context
import android.database.AbstractWindowedCursor
import android.database.Cursor
import android.database.CursorWindow
import android.os.Build
import android.util.LruCache
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
import androidx.sqlite.db.SupportSQLiteProgram
import androidx.sqlite.db.SupportSQLiteQuery
import androidx.sqlite.db.SupportSQLiteStatement
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import app.cash.sqldelight.Query
import app.cash.sqldelight.Transacter
import app.cash.sqldelight.db.AfterVersion
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlCursor
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlPreparedStatement
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.android.Api28Impl.setWindowSize
import kotlinx.coroutines.runBlocking
private const val DEFAULT_CACHE_SIZE = 20
// workaround
// https://github.com/cashapp/sqldelight/issues/5058
class AndroidSqliteDriver private constructor(
private val openHelper: SupportSQLiteOpenHelper? = null,
database: SupportSQLiteDatabase? = null,
private val cacheSize: Int,
private val windowSizeBytes: Long? = null,
) : SqlDriver {
init {
require((openHelper != null) xor (database != null))
}
private val transactions = ThreadLocal<Transacter.Transaction>()
private val database by lazy {
openHelper?.writableDatabase ?: database!!
}
constructor(
openHelper: SupportSQLiteOpenHelper,
) : this(
openHelper = openHelper,
database = null,
cacheSize = DEFAULT_CACHE_SIZE,
windowSizeBytes = null
)
/**
* @param [cacheSize] The number of compiled sqlite statements to keep in memory per connection.
* Defaults to 20.
* @param [useNoBackupDirectory] Sets whether to use a no backup directory or not.
* @param [windowSizeBytes] Size of cursor window in bytes, per [CursorWindow] (Android 28+ only), or null to use the default.
*/
@JvmOverloads
constructor(
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
context: Context,
name: String? = null,
factory: SupportSQLiteOpenHelper.Factory = FrameworkSQLiteOpenHelperFactory(),
callback: SupportSQLiteOpenHelper.Callback = Callback(schema),
cacheSize: Int = DEFAULT_CACHE_SIZE,
useNoBackupDirectory: Boolean = false,
windowSizeBytes: Long? = null,
) : this(
database = null,
openHelper = factory.create(
SupportSQLiteOpenHelper.Configuration.builder(context)
.callback(callback)
.name(name)
.noBackupDirectory(useNoBackupDirectory)
.build(),
),
cacheSize = cacheSize,
windowSizeBytes = windowSizeBytes,
)
@JvmOverloads
constructor(
database: SupportSQLiteDatabase,
cacheSize: Int = DEFAULT_CACHE_SIZE,
windowSizeBytes: Long? = null,
) : this(
openHelper = null,
database = database,
cacheSize = cacheSize,
windowSizeBytes = windowSizeBytes
)
private val statements = object : LruCache<Int, AndroidStatement>(cacheSize) {
override fun entryRemoved(
evicted: Boolean,
key: Int,
oldValue: AndroidStatement,
newValue: AndroidStatement?,
) {
if (evicted) oldValue.close()
}
}
private val listeners = linkedMapOf<String, MutableSet<Query.Listener>>()
override fun addListener(vararg queryKeys: String, listener: Query.Listener) {
synchronized(listeners) {
queryKeys.forEach {
listeners.getOrPut(it, { linkedSetOf() }).add(listener)
}
}
}
override fun removeListener(vararg queryKeys: String, listener: Query.Listener) {
synchronized(listeners) {
queryKeys.forEach {
listeners[it]?.remove(listener)
}
}
}
override fun notifyListeners(vararg queryKeys: String) {
val listenersToNotify = linkedSetOf<Query.Listener>()
synchronized(listeners) {
queryKeys.forEach { listeners[it]?.let(listenersToNotify::addAll) }
}
listenersToNotify.forEach(Query.Listener::queryResultsChanged)
}
override fun newTransaction(): QueryResult<Transacter.Transaction> {
val enclosing = transactions.get()
val transaction = Transaction(enclosing)
transactions.set(transaction)
if (enclosing == null) {
database.beginTransactionNonExclusive()
}
return QueryResult.Value(transaction)
}
override fun currentTransaction(): Transacter.Transaction? = transactions.get()
inner class Transaction(
override val enclosingTransaction: Transacter.Transaction?,
) : Transacter.Transaction() {
override fun endTransaction(successful: Boolean): QueryResult<Unit> {
if (enclosingTransaction == null) {
if (successful) {
database.setTransactionSuccessful()
database.endTransaction()
} else {
database.endTransaction()
}
}
transactions.set(enclosingTransaction)
return QueryResult.Unit
}
}
private fun <T> execute(
identifier: Int?,
createStatement: () -> AndroidStatement,
binders: (SqlPreparedStatement.() -> Unit)?,
result: AndroidStatement.() -> T,
): QueryResult.Value<T> {
var statement: AndroidStatement? = null
if (identifier != null) {
statement = statements.remove(identifier)
}
if (statement == null) {
statement = createStatement()
}
try {
if (binders != null) {
statement.binders()
}
return QueryResult.Value(statement.result())
} finally {
if (identifier != null) {
statements.put(identifier, statement)?.close()
} else {
statement.close()
}
}
}
override fun execute(
identifier: Int?,
sql: String,
parameters: Int,
binders: (SqlPreparedStatement.() -> Unit)?,
): QueryResult<Long> = execute(
identifier,
{ AndroidPreparedStatement(database.compileStatement(sql)) },
binders,
{ execute() })
override fun <R> executeQuery(
identifier: Int?,
sql: String,
mapper: (SqlCursor) -> QueryResult<R>,
parameters: Int,
binders: (SqlPreparedStatement.() -> Unit)?,
) = execute(
identifier,
{ AndroidQuery(sql, database, parameters, windowSizeBytes) },
binders
) { executeQuery(mapper) }
override fun close() {
statements.evictAll()
return openHelper?.close() ?: database.close()
}
open class Callback(
private val schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
private vararg val callbacks: AfterVersion,
) : SupportSQLiteOpenHelper.Callback(
if (schema.version > Int.MAX_VALUE) error("Schema version is larger than Int.MAX_VALUE: ${schema.version}.") else schema.version.toInt(),
) {
override fun onCreate(db: SupportSQLiteDatabase) {
runBlocking {
schema.create(AndroidSqliteDriver(openHelper = null, database = db, cacheSize = 1))
.await()
}
}
override fun onUpgrade(
db: SupportSQLiteDatabase,
oldVersion: Int,
newVersion: Int,
) {
runBlocking {
schema.migrate(
AndroidSqliteDriver(openHelper = null, database = db, cacheSize = 1),
oldVersion.toLong(),
newVersion.toLong(),
*callbacks,
).await()
}
}
}
}
internal interface AndroidStatement : SqlPreparedStatement {
fun execute(): Long
fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R
fun close()
}
private class AndroidPreparedStatement(
private val statement: SupportSQLiteStatement,
) : AndroidStatement {
override fun bindBytes(index: Int, bytes: ByteArray?) {
if (bytes == null) statement.bindNull(index + 1) else statement.bindBlob(index + 1, bytes)
}
override fun bindLong(index: Int, long: Long?) {
if (long == null) statement.bindNull(index + 1) else statement.bindLong(index + 1, long)
}
override fun bindDouble(index: Int, double: Double?) {
if (double == null) statement.bindNull(index + 1) else statement.bindDouble(
index + 1,
double
)
}
override fun bindString(index: Int, string: String?) {
if (string == null) statement.bindNull(index + 1) else statement.bindString(
index + 1,
string
)
}
override fun bindBoolean(index: Int, boolean: Boolean?) {
if (boolean == null) {
statement.bindNull(index + 1)
} else {
statement.bindLong(index + 1, if (boolean) 1L else 0L)
}
}
override fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R =
throw UnsupportedOperationException()
override fun execute(): Long {
return statement.executeUpdateDelete().toLong()
}
override fun close() {
statement.close()
}
}
private class AndroidQuery(
override val sql: String,
private val database: SupportSQLiteDatabase,
override val argCount: Int,
private val windowSizeBytes: Long?,
) : SupportSQLiteQuery, AndroidStatement {
private val binds = MutableList<((SupportSQLiteProgram) -> Unit)?>(argCount) { null }
override fun bindBytes(index: Int, bytes: ByteArray?) {
binds[index] =
{ if (bytes == null) it.bindNull(index + 1) else it.bindBlob(index + 1, bytes) }
}
override fun bindLong(index: Int, long: Long?) {
binds[index] =
{ if (long == null) it.bindNull(index + 1) else it.bindLong(index + 1, long) }
}
override fun bindDouble(index: Int, double: Double?) {
binds[index] =
{ if (double == null) it.bindNull(index + 1) else it.bindDouble(index + 1, double) }
}
override fun bindString(index: Int, string: String?) {
binds[index] =
{ if (string == null) it.bindNull(index + 1) else it.bindString(index + 1, string) }
}
override fun bindBoolean(index: Int, boolean: Boolean?) {
binds[index] = {
if (boolean == null) {
it.bindNull(index + 1)
} else {
it.bindLong(index + 1, if (boolean) 1L else 0L)
}
}
}
override fun execute() = throw UnsupportedOperationException()
override fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R {
return database.query(this)
.use { cursor -> mapper(AndroidCursor(cursor, windowSizeBytes)).value }
}
override fun bindTo(statement: SupportSQLiteProgram) {
for (action in binds) {
action!!(statement)
}
}
override fun toString() = sql
override fun close() {}
}
private class AndroidCursor(
private val cursor: Cursor,
windowSizeBytes: Long?,
) : SqlCursor {
init {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
windowSizeBytes != null &&
cursor is AbstractWindowedCursor
) {
cursor.setWindowSize(windowSizeBytes)
}
}
override fun next(): QueryResult.Value<Boolean> = QueryResult.Value(cursor.moveToNext())
override fun getString(index: Int) = if (cursor.isNull(index)) null else cursor.getString(index)
override fun getLong(index: Int) = if (cursor.isNull(index)) null else cursor.getLong(index)
override fun getBytes(index: Int) = if (cursor.isNull(index)) null else cursor.getBlob(index)
override fun getDouble(index: Int) = if (cursor.isNull(index)) null else cursor.getDouble(index)
override fun getBoolean(index: Int) =
if (cursor.isNull(index)) null else cursor.getLong(index) == 1L
}
@RequiresApi(Build.VERSION_CODES.P)
private object Api28Impl {
@JvmStatic
@DoNotInline
fun AbstractWindowedCursor.setWindowSize(windowSizeBytes: Long) {
window = CursorWindow(null, windowSizeBytes)
}
}
@@ -0,0 +1,134 @@
package ru.shadowsparky.vbox.shared.data
import app.cash.sqldelight.Query
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import app.cash.sqldelight.coroutines.mapToOneOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.domain.OfflineVideoQueries
import ru.shadowsparky.vbox.shared.domain.OfflineState
import ru.shadowsparky.vbox.shared.domain.OfflineStorage
import ru.shadowsparky.vbox.shared.domain.OfflineVideo
typealias SqlOfflineVideo = ru.shadowsparky.vbox.domain.OfflineVideo
@Factory
class AndroidOfflineStorage(
private val dbProvider: AppDatabaseProvider,
private val downloader: OfflineDownloader
) : OfflineStorage {
private suspend fun getQueries(): OfflineVideoQueries {
return dbProvider.get().offlineVideoQueries
}
override val videos: Flow<List<OfflineVideo>> = flow {
emitAll(getQueries().selectAll().toOfflineVideo())
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun Query<SqlOfflineVideo>.toOfflineVideo(): Flow<List<OfflineVideo>> {
return asFlow().mapToList(Dispatchers.IO)
.flatMapLatest { items: List<SqlOfflineVideo> ->
combine(
items.map { item ->
downloader.query(item.download_id)
.mapNotNull { state ->
if (state == null) remove(item.id)
item.toDomain(state)
}
}
) { it.toList() }.onStart { emit(emptyList()) }
}.flowOn(Dispatchers.IO)
}
override suspend fun query(id: Long): Flow<OfflineVideo?> =
withContext(Dispatchers.IO) {
getQueries()
.selectById(id)
.asFlow()
.mapToOneOrNull(Dispatchers.IO)
.map { it?.toDomain() }
}
private suspend fun queryRaw(id: Long): SqlOfflineVideo? =
withContext(Dispatchers.IO) {
getQueries()
.selectById(id)
.executeAsOneOrNull()
}
override fun queryByArgs(movieId: Long, seasonId: Long?): Flow<List<OfflineVideo>> = flow {
emitAll(
getQueries()
.selectByArgs(
movie_id = movieId,
season_id = seasonId
).toOfflineVideo()
)
}.flowOn(Dispatchers.IO)
override suspend fun find(movieId: Long, seasonId: Long?, episodeId: Long?): OfflineVideo? {
return withContext(Dispatchers.IO) {
getQueries().selectByFullArgs(movieId, seasonId, episodeId)
.executeAsOneOrNull()
?.toDomain()
}
}
override suspend fun add(item: OfflineVideo, uri: String) = withContext(Dispatchers.IO) {
val dmId = downloader.enqueue(uri)
upsertWithDm(item, dmId)
}
private suspend fun upsertWithDm(item: OfflineVideo, dmId: Long?) {
withContext(Dispatchers.IO) {
getQueries().upsert(
movie_id = item.movieId,
season_id = item.seasonId,
episode_id = item.episodeId,
id = item.id,
download_id = dmId
)
}
}
override suspend fun remove(item: OfflineVideo) {
val id = item.id ?: return
remove(id)
}
private suspend fun remove(id: Long) {
withContext(Dispatchers.IO) {
queryRaw(id)?.download_id?.let { downloader.delete(it) }
getQueries().deleteById(id)
}
}
private suspend fun SqlOfflineVideo.toDomain(state: OfflineState? = null): OfflineVideo? {
val state = state ?: run {
download_id?.let { downloader.query(it).firstOrNull() }
} ?: return null
return OfflineVideo(
id = id,
movieId = movie_id,
seasonId = season_id,
episodeId = episode_id,
state = state
)
}
}
@@ -0,0 +1,127 @@
package ru.shadowsparky.vbox.shared.data
import android.app.DownloadManager
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.Environment
import android.os.Handler
import android.os.Looper
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.domain.OfflineState
import java.util.UUID
@Factory
class OfflineDownloader(private val context: Context) {
private val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
suspend fun enqueue(url: String): Long = withContext(Dispatchers.IO) {
val request = DownloadManager.Request(url.toUri())
.setDestinationInExternalFilesDir(
context,
Environment.DIRECTORY_DOWNLOADS,
"/Videobox/${UUID.randomUUID()}.mp4"
)
dm.enqueue(request)
}
suspend fun delete(dmId: Long) = withContext(Dispatchers.IO) {
dm.remove(dmId)
}
fun query(dmId: Long?): Flow<OfflineState?> {
dmId ?: return flowOf(null)
return callbackFlow {
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
trySend(Unit)
}
}
trySend(Unit)
context.contentResolver.registerContentObserver(getUri(dmId), false, observer)
awaitClose {
context.contentResolver.unregisterContentObserver(observer)
}
}.map { obtainState(dmId) }.flowOn(Dispatchers.IO)
}
private fun getUri(dmId: Long): Uri {
return "content://downloads/my_downloads/$dmId".toUri()
}
private fun obtainState(dmId: Long): OfflineState? {
val query = DownloadManager.Query().setFilterById(dmId)
dm.query(query).use { cursor ->
if (!cursor.moveToFirst()) return null
val status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
return when (status) {
DownloadManager.STATUS_PENDING -> OfflineState.Pending
DownloadManager.STATUS_RUNNING -> obtainRunning(cursor)
DownloadManager.STATUS_PAUSED -> obtainPaused(cursor)
DownloadManager.STATUS_SUCCESSFUL -> {
val uri = cursor.getString(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)
) ?: return OfflineState.Error("Uri not found")
runCatching {
context.contentResolver.openFileDescriptor(uri.toUri(), "r").use {
return OfflineState.Available(uri)
}
}.getOrDefault(OfflineState.Error("File not found"))
}
DownloadManager.STATUS_FAILED -> {
val reason = cursor.getInt(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON)
)
OfflineState.Error("Download failed: reason=$reason")
}
else -> OfflineState.Error("Unknown status")
}
}
}
private fun obtainPaused(cursor: Cursor): OfflineState.Downloading {
val bytesDownloaded = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
)
val bytesTotal = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
)
val progress = if (bytesTotal > 0) {
((bytesDownloaded * 100) / bytesTotal).toInt()
} else 0
return OfflineState.Downloading(
isPaused = true,
progress = progress
)
}
private fun obtainRunning(cursor: Cursor): OfflineState.Downloading {
val bytesDownloaded = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
)
val bytesTotal = cursor.getLong(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
)
val progress = if (bytesTotal > 0) {
((bytesDownloaded * 100) / bytesTotal).toInt()
} else 0
return OfflineState.Downloading(
isPaused = false,
progress = progress
)
}
}
@@ -0,0 +1,15 @@
package ru.shadowsparky.vbox.shared.di.factory
import android.app.Application
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.app.AppDatabase
@Factory
class AndroidDatabaseDriverFactory(private val app: Application) : DatabaseDriverFactory {
override suspend fun createDriver(): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, app, "videobox.db")
}
}
@@ -0,0 +1,49 @@
package ru.shadowsparky.vbox.shared.di.factory
import android.app.Application
import com.seiko.imageloader.Bitmap
import com.seiko.imageloader.ImageLoader
import com.seiko.imageloader.cache.memory.MemoryCacheBuilder
import com.seiko.imageloader.cache.memory.MemoryKey
import com.seiko.imageloader.cache.memory.maxSizePercent
import com.seiko.imageloader.component.setupDefaultComponents
import com.seiko.imageloader.intercept.bitmapMemoryCacheConfig
import com.seiko.imageloader.option.androidContext
import com.seiko.imageloader.size
import com.seiko.imageloader.util.identityHashCode
import io.ktor.client.HttpClient
import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Factory
@Factory
class AndroidImageLoaderProvider(
private val app: Application,
private val httpClient: HttpClient
) : ImageLoaderProvider {
override fun provide(): ImageLoader {
return ImageLoader {
options {
androidContext(app)
}
components {
setupDefaultComponents { httpClient }
}
interceptor {
// Set the max size to 25% of the app's available memory.
bitmapMemoryCacheConfig(
valueHashProvider = { identityHashCode(it) },
valueSizeProvider = { it.size },
block = fun MemoryCacheBuilder<MemoryKey, Bitmap>.() {
// Set the max size to 25% of the app's available memory.
maxSizePercent(app, 0.25)
}
)
diskCacheConfig {
directory(app.cacheDir.resolve("image_cache").toOkioPath())
maxSizeBytes(512L * 1024 * 1024) // 512MB
}
}
}
}
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.vbox.shared.domain
import kotlinx.coroutines.flow.StateFlow
interface ActivityInfo {
val isActiveFlow: StateFlow<Boolean>
}
@@ -0,0 +1,5 @@
package ru.shadowsparky.vbox.shared.domain
actual fun getCurrentOsType(): OsType {
return OsType.ANDROID
}
@@ -0,0 +1,22 @@
package ru.shadowsparky.vbox.shared.presentation
import android.app.Application
import android.content.Intent
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
// adb shell am start -W -a android.intent.action.VIEW -d "https://box.shadowsparky.ru/deep-link/overview/191" ru.shadowsparky.videobox.multiplatform
@Factory
class AndroidShareManager(private val app: Application) : ShareManager {
override fun shareDetails(details: VideoDetails) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "https://box.shadowsparky.ru/overview/${details.id}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
app.startActivity(intent)
}
override fun canShare(): Boolean = true
}
@@ -0,0 +1,44 @@
package ru.shadowsparky.vbox.shared.presentation
import android.app.Application
import android.content.Intent
import android.widget.Toast
import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.cant_open_video_viewer
import androidx.core.net.toUri
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Factory
@Factory
class AndroidVideoLauncher(
private val app: Application
) : VideoLauncher {
override suspend fun launch(uri: String) {
if (!launchInternal(uri, true)) {
if (!launchInternal(uri, false)) {
Toast.makeText(
app,
getString(Res.string.cant_open_video_viewer),
Toast.LENGTH_LONG
).show()
}
}
}
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
}
}
@@ -0,0 +1,20 @@
package ru.shadowsparky.vbox.shared.presentation.search
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
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.onKeyEvent
import androidx.compose.ui.input.key.type
actual fun Modifier.requestFocusOnDownEvent(focusRequester: FocusRequester): Modifier {
return onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
if (it.key == Key.DirectionDown) {
focusRequester.requestFocus()
}
}
true
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M791,904L567,680L280,680L280,393L56,169L112,112L848,848L791,904ZM360,600L487,600L360,473L360,600ZM680,567L600,487L600,360L473,360L393,280L680,280L680,567ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM280,840L280,760L360,760L360,840L280,840ZM280,200L280,120L360,120L360,200L280,200ZM440,840L440,760L520,760L520,840L440,840ZM440,200L440,120L520,120L520,200L440,200ZM600,840L600,760L680,760L680,840L600,840ZM600,200L600,120L680,120L680,200L600,200ZM760,680L760,600L840,600L840,680L760,680ZM760,520L760,440L840,440L840,520L760,520ZM760,360L760,280L840,280L840,360L760,360ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M382,640L155,413L212,356L382,526L748,160L805,217L382,640ZM200,800L200,720L760,720L760,800L200,800Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640Q555,640 607.5,587.5ZM403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568Q435,568 403.5,536.5ZM214,678.5Q94,597 40,460Q94,323 214,241.5Q334,160 480,160Q626,160 746,241.5Q866,323 920,460Q866,597 746,678.5Q626,760 480,760Q334,760 214,678.5ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Q593,680 687.5,620.5Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#fff"
android:pathData="M400,720L400,640L560,640L560,720L400,720ZM240,520L240,440L720,440L720,520L240,520ZM120,320L120,240L840,240L840,320L120,320Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M434,663L660,436L604,380L434,550L349,465L292,522L434,663ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M700,760L740,760L740,660L840,660L840,620L740,620L740,520L700,520L700,620L600,620L600,660L700,660L700,760ZM720,840Q637,840 578.5,781.5Q520,723 520,640Q520,557 578.5,498.5Q637,440 720,440Q803,440 861.5,498.5Q920,557 920,640Q920,723 861.5,781.5Q803,840 720,840ZM160,760L160,280L480,40L800,280L800,372Q781,366 761,363Q741,360 720,360L720,320L480,140L240,320L240,680L443,680Q446,701 452,721Q458,741 467,760L160,760ZM480,410L480,410L480,410L480,410L480,410Q480,410 480,410Q480,410 480,410Q480,410 480,410Q480,410 480,410Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M480,616L240,376L296,320L480,504L664,320L720,376L480,616Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M480,432L296,616L240,560L480,320L720,560L664,616L480,432Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M322.5,522.5Q340,505 340,480Q340,455 322.5,437.5Q305,420 280,420Q255,420 237.5,437.5Q220,455 220,480Q220,505 237.5,522.5Q255,540 280,540Q305,540 322.5,522.5ZM522.5,522.5Q540,505 540,480Q540,455 522.5,437.5Q505,420 480,420Q455,420 437.5,437.5Q420,455 420,480Q420,505 437.5,522.5Q455,540 480,540Q505,540 522.5,522.5ZM722.5,522.5Q740,505 740,480Q740,455 722.5,437.5Q705,420 680,420Q655,420 637.5,437.5Q620,455 620,480Q620,505 637.5,522.5Q655,540 680,540Q705,540 722.5,522.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280L840,492Q821,484 800.5,481.5Q780,479 760,482L760,313L647,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L440,760L440,764L440,840L200,840ZM200,200L200,313L200,482Q200,485 200,494.5Q200,504 200,519L200,760L200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200ZM520,920L520,797L741,577Q750,568 761,564Q772,560 783,560Q795,560 806,564.5Q817,569 826,578L863,615Q871,624 875.5,635Q880,646 880,657Q880,668 876,679.5Q872,691 863,700L643,920L520,920ZM820,657L820,657L783,620L783,620L820,657ZM580,860L618,860L739,738L721,719L702,701L580,822L580,860ZM721,719L702,701L702,701L739,738L739,738L721,719ZM240,400L600,400L600,240L240,240L240,400ZM480,720Q481,720 482,720Q483,720 484,720L600,605Q600,603 600,602.5Q600,602 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720Z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M80,820L80,500L400,500L400,820L80,820ZM160,740L320,740L320,580L160,580L160,740ZM220,400L440,40L660,400L220,400ZM362,320L518,320L440,194L362,320ZM863,918L757,812Q736,826 711.5,833Q687,840 660,840Q585,840 532.5,787.5Q480,735 480,660Q480,585 532.5,532.5Q585,480 660,480Q735,480 787.5,532.5Q840,585 840,660Q840,686 833,710.5Q826,735 813,756L919,862L863,918ZM660,760Q702,760 731,731Q760,702 760,660Q760,618 731,589Q702,560 660,560Q618,560 589,589Q560,618 560,660Q560,702 589,731Q618,760 660,760ZM320,580L320,580L320,580L320,580ZM440,320L440,320L440,320Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M280,680L280,280L680,280L680,680L280,680ZM360,600L600,600L600,360L360,360L360,600ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM200,200L120,200Q120,167 143.5,143.5Q167,120 200,120L200,200ZM280,840L280,760L360,760L360,840L280,840ZM280,200L280,120L360,120L360,200L280,200ZM440,840L440,760L520,760L520,840L440,840ZM440,200L440,120L520,120L520,200L440,200ZM600,840L600,760L680,760L680,840L600,840ZM600,200L600,120L680,120L680,200L600,200ZM760,840L760,760L840,760Q840,793 816.5,816.5Q793,840 760,840ZM760,680L760,600L840,600L840,680L760,680ZM760,520L760,440L840,440L840,520L760,520ZM760,360L760,280L840,280L840,360L760,360ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200Z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M680,680Q705,680 722.5,662.5Q740,645 740,620Q740,595 722.5,577.5Q705,560 680,560Q655,560 637.5,577.5Q620,595 620,620Q620,645 637.5,662.5Q655,680 680,680ZM680,800Q711,800 737,785.5Q763,771 779,747Q757,734 732,727Q707,720 680,720Q653,720 628,727Q603,734 581,747Q597,771 623,785.5Q649,800 680,800ZM480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,427Q781,419 761,412.5Q741,406 720,403L720,256L480,166L240,256L240,444Q240,491 252.5,538Q265,585 287.5,627.5Q310,670 342,706Q374,742 413,766L413,766Q424,798 442,827Q460,856 483,879Q482,879 481.5,879.5Q481,880 480,880ZM680,880Q597,880 538.5,821.5Q480,763 480,680Q480,597 538.5,538.5Q597,480 680,480Q763,480 821.5,538.5Q880,597 880,680Q880,763 821.5,821.5Q763,880 680,880ZM480,466L480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466L480,466L480,466L480,466L480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Q480,466 480,466Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#fff"
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240Q697,240 708.5,228.5ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M200,840L200,200Q200,167 223.5,143.5Q247,120 280,120L520,120Q520,143 520,160Q520,177 520,200L280,200Q280,200 280,200Q280,200 280,200L280,718L480,632L680,718L680,440Q703,440 720,440Q737,440 760,440L760,840L480,720L200,840ZM280,200L280,200Q280,200 280,200Q280,200 280,200L520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200L520,200L480,200L280,200ZM680,360L680,280L600,280L600,200L680,200L680,120L760,120L760,200L840,200L840,280L760,280L760,360L680,360Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#fff"
android:pathData="M713,360L600,247L656,190L713,247L854,105L911,162L713,360ZM200,840L200,200Q200,167 223.5,143.5Q247,120 280,120L520,120Q520,143 520,160Q520,177 520,200L280,200Q280,200 280,200Q280,200 280,200L280,718L480,632L680,718L680,440Q703,440 720,440Q737,440 760,440L760,840L480,720L200,840ZM280,200L280,200Q280,200 280,200Q280,200 280,200L520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200Q520,200 520,200L520,200L480,200L280,200Z" />
</vector>
@@ -0,0 +1,70 @@
<resources>
<string name="app_name">Videobox</string>
<string name="recommendations">Главная</string>
<string name="retry">Повторить</string>
<string name="recent">Недавнее</string>
<string name="details_serial">Все детали о сериале</string>
<string name="details_movie">Все детали о фильме</string>
<string name="desc">Описание</string>
<string name="watch">Смотреть</string>
<string name="seasons_overview">Доступные сезоны</string>
<string name="select_season">Выберите сезон</string>
<string name="season">Cезон</string>
<string name="episodes_overview">Доступные серии</string>
<string name="chose_episode">Выберите серию</string>
<string name="episode">Серия</string>
<string name="okay">Понятно</string>
<string name="error_title">Ошибка!</string>
<string name="unknown_error">Неизвестная ошибка</string>
<string name="timeout_error">Превышено время ожидания ответа от сервера</string>
<string name="server_not_responding">Сервер не отвечает</string>
<string name="unknown_host">Нет доступа к серверу. Проверьте подключение к интернету</string>
<string name="cant_open_video_viewer">Не найдено приложение для проигрывания видео. Пожалуйста, поставьте видео плеер</string>
<string name="not_found">Ничего не найдено по вашему запросу</string>
<string name="share">Поделиться</string>
<string name="settings">Настройки</string>
<string name="server_settings">Настройка сервера</string>
<string name="custom_serv_addr">Адрес сервера</string>
<string name="edit_custom_serv_addr">Изменение адреса сервера</string>
<string name="account">Аккаунт</string>
<string name="account_auth">Авторизация</string>
<string name="account_reg">Регистрация</string>
<string name="account_logout_auth">Выйти из аккаунта</string>
<string name="account_login">Логин</string>
<string name="account_password">Пароль</string>
<string name="account_reg_hint">У меня нет аккаунта</string>
<string name="empty_login">Логин не может быть пустым</string>
<string name="empty_pass">Пароль не может быть пустым</string>
<string name="theme_currently_active">Тема активна на данный момент</string>
<string name="saved_movies">Сохранённые</string>
<string name="saved_movies_not_found">Вы ничего не сохраняли. Может исправите это недоразумение?</string>
<string name="url_copied">Ссылка скопирована</string>
<string name="copy">Скопировать</string>
<string name="search">Поиск</string>
<string name="search_empty_now">Тут пока ничего нет</string>
<string name="edit_password">Изменить пароль</string>
<string name="old_password">Текущий пароль</string>
<string name="new_password">Новый пароль</string>
<string name="password_changed">Пароль был успешно изменён</string>
<string name="external_player_title">Использовать внешний плеер</string>
<string name="next_episode">Следующий эпизод</string>
<string name="start_from">Продолжить с %1$s</string>
<string name="offline_save">Сохранить видео</string>
<string name="offline_video">Сохранённые видео</string>
<string name="offline_empty">Нет сохраненных видео</string>
<string name="offline_remove">Удалить видео</string>
<string name="offline_state_pending">Ожидает загрузки</string>
<string name="offline_state_downloading">Загрузка: %1$d%</string>
<string name="offline_state_paused">Пауза: %1$d%</string>
<string name="offline_state_error">Ошибка: %1$s</string>
<string name="offline_state_available">Доступно офлайн</string>
<string name="user_tags">Теги</string>
<string name="user_tags_add">Добавить новый тег</string>
<string name="user_tags_save">Сохранить</string>
<string name="user_tags_edit_title">Редактирование тега</string>
<string name="user_tags_new_tag">Новое название</string>
<string name="user_tags_new_tag_save">Изменить название</string>
<string name="user_tags_tag_delete">Удалить</string>
<string name="movie_tags_empty">Настроить теги</string>
<string name="user_tags_filter_disable">Не фильтровать</string>
</resources>
@@ -0,0 +1,15 @@
package ru.shadowsparky.vbox.shared
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import ru.shadowsparky.vbox.shared.di.EventsModule
import ru.shadowsparky.vbox.shared.di.HttpModule
import ru.shadowsparky.vbox.shared.di.SavedModule
@Module(
includes = [EventsModule::class, HttpModule::class, SavedModule::class]
)
@Configuration
@ComponentScan
class VBoxSharedModule
@@ -0,0 +1,32 @@
package ru.shadowsparky.vbox.shared.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.app.AppDatabase
import ru.shadowsparky.vbox.shared.di.factory.DatabaseDriverFactory
@Single
class AppDatabaseProvider(
private val factory: DatabaseDriverFactory,
private val dispatcherProvider: DispatcherProvider
) {
private val mutex = Mutex()
private var appDatabase: AppDatabase? = null
suspend fun get(): AppDatabase = withContext(dispatcherProvider.io) {
appDatabase?.let { return@withContext it }
mutex.withLock {
appDatabase?.let { return@withContext it }
val driver = factory.createDriver()
AppDatabase.Schema.migrate(driver, 2, 3)
AppDatabase.Schema.migrate(driver, 3, 4)
AppDatabase.Schema.migrate(driver, 4, 5)
AppDatabase.Schema.migrate(driver, 5, 6)
AppDatabase.Schema.migrate(driver, 6, 7)
AppDatabase.invoke(driver).also { appDatabase = it }
}
}
}
@@ -0,0 +1,87 @@
package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.utils.io.core.toByteArray
import org.koin.core.annotation.Factory
import ru.shadowsparky.http.domain.HttpException
import ru.shadowsparky.http.domain.TokenInfo
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.TokenRequest
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import ru.shadowsparky.vbox.shared.domain.model.EmptyLoginException
import ru.shadowsparky.vbox.shared.domain.model.EmptyPasswordException
import ru.shadowsparky.vbox.shared.domain.model.LoginInfo
import ru.shadowsparky.vbox.shared.domain.model.ServerLoginException
import ru.shadowsparky.vbox.shared.domain.model.UnknownLoginException
@Factory
class AuthTokenRepositoryImpl(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
private val tokenCache: TokenStorage,
private val hashCalculator: HmacSha512Calc
) : AuthTokenRepository {
override suspend fun register(loginInfo: LoginInfo): TokenInfo {
return post(loginInfo, AuthTokenRepository.REGISTER_PATH, false)
}
override suspend fun login(loginInfo: LoginInfo): TokenInfo {
return post(loginInfo, AuthTokenRepository.LOGIN_PATH, true)
}
override suspend fun update(refresh: TokenRequest): TokenInfo {
return httpClient.post {
serverConfigurationProvider.getServerConfiguration()
.apply(this, AuthTokenRepository.UPDATE_PATH)
setJsonBody(refresh)
}.body<TokenInfo>()
}
override suspend fun changePassword(request: ChangePasswordRequest) {
val oldPassHash = hashCalculator.calc(request.oldPasswordHash.toByteArray())
val newPassHash = hashCalculator.calc(request.newPasswordHash.toByteArray())
httpClient.post {
serverConfigurationProvider.getServerConfiguration()
.apply(this, AuthTokenRepository.CHANGE_PASS_PATH)
setJsonBody(ChangePasswordRequest(oldPassHash.toHexString(), newPassHash.toHexString()))
}
}
override suspend fun revoke(tokenRequest: TokenRequest) {
httpClient.post {
serverConfigurationProvider.getServerConfiguration()
.apply(this, AuthTokenRepository.REVOKE_PATH)
setJsonBody(tokenRequest)
}
}
@OptIn(ExperimentalStdlibApi::class)
private suspend fun post(loginInfo: LoginInfo, path: String, saveToCache: Boolean): TokenInfo {
if (loginInfo.login.trim().isEmpty()) {
throw EmptyLoginException()
} else if (loginInfo.passwordHash.trim().isEmpty()) {
throw EmptyPasswordException()
}
val realPassHash = hashCalculator.calc(loginInfo.passwordHash.toByteArray())
try {
return httpClient.post {
serverConfigurationProvider.getServerConfiguration().apply(this, path)
setJsonBody(loginInfo.copy(passwordHash = realPassHash.toHexString()))
}.body<TokenInfo>().apply {
if (saveToCache) {
tokenCache.updateToken(this)
}
}
} catch (e: HttpException) {
throw ServerLoginException(e.message ?: "Unknown error")
} catch (e: Exception) {
throw UnknownLoginException(e)
}
}
}
@@ -0,0 +1,30 @@
package ru.shadowsparky.vbox.shared.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.KeyValueStorage
import ru.shadowsparky.vbox.shared.domain.ServerConfiguration
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
@Factory
class DefaultServerConfigurationRepository(
private val storage: KeyValueStorage
) : ServerConfigurationRepository {
override val serverConfiguration: Flow<ServerConfiguration> = storage.get(KEY)
.map {
if (it != null) {
ServerConfiguration(it)
} else {
ServerConfiguration()
}
}
override suspend fun setServerConfiguration(conf: ServerConfiguration) {
storage.put(KEY, conf.asHttpStr())
}
private companion object {
const val KEY = "custom_server_uri"
}
}
@@ -0,0 +1,22 @@
package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.domain.HeathCheck
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
@Factory
class HeathCheckImpl(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
) : HeathCheck {
override suspend fun check() {
httpClient.get {
serverConfigurationProvider.getServerConfiguration()
.apply(this, HeathCheck.PATH)
}
}
}
@@ -0,0 +1,20 @@
package ru.shadowsparky.vbox.shared.data
import kotlinx.coroutines.withContext
import okio.Buffer
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Factory
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
@Factory
class HmacSha512Calc(private val dispatcherProvider: DispatcherProvider) {
private val hmacKey =
"cd4204fae910ba12b9eb6650ef90852252cf20e4c55bbb3b21ac1f8cdf8d0ab53a5f49d2298824f011dbefd30fdec21ff0b485ae835dd9bccc9d377ed54c0d80"
@OptIn(ExperimentalStdlibApi::class)
suspend fun calc(data: ByteArray): ByteArray = withContext(dispatcherProvider.io) {
val buffer = Buffer().apply { write(data) }
val hmacStr = hmacKey.hexToByteArray().toByteString()
buffer.hmacSha512(hmacStr).toByteArray()
}
}
@@ -0,0 +1,37 @@
package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import ru.shadowsparky.vbox.shared.domain.ServerConfiguration
fun ServerConfiguration.apply(
builder: HttpRequestBuilder,
path: String
) {
builder.apply {
url(asHttpStr() + "/$path")
}
}
inline fun <reified T> HttpRequestBuilder.setJsonBody(body: T) {
header("Content-Type", "application/json")
setBody(body)
}
suspend inline fun <reified T> HttpClient.get(
path: String,
params: Map<String, String>,
configuration: ServerConfiguration
): T {
val rsp = get {
configuration.apply(this, path)
params.forEach { (key, value) -> parameter(key, value) }
}
return rsp.body()
}
@@ -0,0 +1,31 @@
package ru.shadowsparky.vbox.shared.data
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.KeyValueStorage
import ru.shadowsparky.vbox.shared.domain.PlayerSettingsStorge
import ru.shadowsparky.vbox.shared.domain.isJs
@Factory
class PlayerSettingsStorgeImpl(
private val storage: KeyValueStorage
) : PlayerSettingsStorge {
override val useExternalPlayer = flow {
if (isJs) {
// в js не поддерживается.
emit(null)
} else {
emitAll(storage.getBoolean(KEY).map { it ?: false })
}
}
override suspend fun setUseExternalPlayer(use: Boolean) {
storage.putBoolean(KEY, use)
}
companion object {
private const val KEY = "use_external_player"
}
}
@@ -0,0 +1,107 @@
package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.url
import io.ktor.http.HttpMethod
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.isActive
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.Log
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import kotlin.math.pow
@Factory
class RemoteEventListenerFactory(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage,
private val json: Json,
private val log: Log
) {
fun create(suffix: String): RemoteEventListener {
return RemoteEventListenerImpl(
httpClient,
serverConfigurationRepository,
authTokenCache,
json,
log,
suffix
)
}
}
private class RemoteEventListenerImpl(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage,
private val json: Json,
private val log: Log,
private val suffix: String
) : RemoteEventListener {
private val eventFlow = flow {
val config = serverConfigurationRepository.getServerConfiguration()
val token = authTokenCache.token.first() ?: return@flow
log.d(TAG, "listener $suffix started")
try {
httpClient.webSocket(
method = HttpMethod.Get,
request = { url(config.asWebSocketStr() + "/$suffix") }
) {
outgoing.send(Frame.Text(token.token))
while (currentCoroutineContext().isActive) {
val text = (incoming.receive() as? Frame.Text)?.readText()
if (text != null) {
val decoded = json.decodeFromString<RemoteEvent>(text)
log.d(TAG, "receive $text $decoded")
emit(decoded)
}
}
}
} catch (e: Exception) {
log.e(TAG, e, "websocket closed")
throw e
} finally {
log.d(TAG, "listener $suffix finished")
}
}
override val event: Flow<RemoteEvent> = eventFlow.retryExponential { true }
private fun <T> Flow<T>.retryExponential(
maxRetries: Int = Int.MAX_VALUE,
initialDelay: Long = 5000L,
maxDelay: Long = 300_000L,
factor: Double = 2.0,
shouldRetry: (Throwable) -> Boolean = { true }
): Flow<T> = retryWhen { cause, attempt ->
log.d(TAG, "error occurred $suffix", cause)
if (!shouldRetry(cause) || attempt >= maxRetries || cause is CancellationException) {
false
} else {
val delayTime = (initialDelay * factor.pow(attempt.toDouble()))
.toLong()
.coerceAtMost(maxDelay)
log.d(TAG, "retry after delay $delayTime", cause)
delay(delayTime)
true
}
}
private companion object {
const val TAG = "RemoteSearchEventListener"
}
}
@@ -0,0 +1,52 @@
package ru.shadowsparky.vbox.shared.data
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
typealias WriteCallback<T> = suspend (List<T>) -> Unit
class RemoteSourceSynchronizer<T, K>(
private val isSync: suspend () -> Boolean,
private val setSync: suspend (Boolean) -> Unit,
private val authTokenCache: TokenStorage,
private val queryRemote: suspend () -> List<T>,
private val queryLocal: suspend () -> List<T>,
private val writeRemote: WriteCallback<T>,
private val writeLocal: WriteCallback<T>,
private val selectKey: (T) -> K
) {
suspend fun syncIfNeeded() {
if (!authTokenCache.hasAccount()) return
if (isSync()) return
runCatching {
sync()
setSync(true)
}.onFailure {
setSync(false)
}
}
private suspend fun sync() {
coroutineScope {
val remoteDef = async { queryRemote() }
val localDef = async { queryLocal() }
val remoteList = remoteDef.await()
val localList = localDef.await()
val mergedMap = mutableMapOf<K, T>()
localList.forEach { mergedMap[selectKey(it)] = it }
remoteList.forEach { mergedMap[selectKey(it)] = it }
val combinedResult = mergedMap.values.toList()
val remoteKeys = remoteList.map(selectKey).toSet()
val localKeys = localList.map(selectKey).toSet()
val toRemote = combinedResult.filterNot { selectKey(it) in remoteKeys }
val toLocal = combinedResult.filterNot { selectKey(it) in localKeys }
val writeRemoteJob = async { if (toRemote.isNotEmpty()) writeRemote(toRemote) }
val writeLocalJob = async { if (toLocal.isNotEmpty()) writeLocal(toLocal) }
awaitAll(writeRemoteJob, writeLocalJob)
}
}
}
@@ -0,0 +1,38 @@
package ru.shadowsparky.vbox.shared.data
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.SavedTimeInfo
import ru.shadowsparky.vbox.shared.domain.SavedTimeRepository
@Factory
class SqlSavedTimeRepository(
private val dbProvider: AppDatabaseProvider,
private val dispatcherProvider: DispatcherProvider
) : SavedTimeRepository {
override suspend fun query(movieId: Long, seasonId: Long?, episodeId: Long?): Long? =
withContext(dispatcherProvider.io) {
dbProvider.get().savedTimeQueries.selectSavedTime(
movieId,
seasonId ?: DEFAULT_VALUE,
episodeId ?: DEFAULT_VALUE
).awaitAsOneOrNull()?.time
}
override suspend fun setTime(time: SavedTimeInfo): Unit = withContext(dispatcherProvider.io) {
dbProvider.get().savedTimeQueries
.upsertSavedTime(
time.movieId,
time.seasonId ?: DEFAULT_VALUE,
time.episodeId ?: DEFAULT_VALUE,
time.time
)
}
private companion object {
const val DEFAULT_VALUE = 0L
}
}
@@ -0,0 +1,71 @@
package ru.shadowsparky.vbox.shared.data.api
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.ApiCacheQueries
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
class FallbackVideoApi(
private val dbProvider: AppDatabaseProvider,
private val wrapper: VideoApi,
private val json: Json,
private val dispatcherProvider: DispatcherProvider
) : VideoApi {
override suspend fun fetchNewVideos(query: String?): VideosResponse {
val key = "fetchNewVideos_${query};1;date;"
return try {
wrapper.fetchNewVideos(query).apply {
writeByKey(key, this)
}
} catch (e: Exception) {
getByKey(key) ?: throw e
}
}
override suspend fun fetchDetails(id: Long): VideoDetails {
val key = "fetchDetails_${id};"
return try {
wrapper.fetchDetails(id).apply {
writeByKey(key, this)
}
} catch (e: Exception) {
getByKey(key) ?: throw e
}
}
override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
val key = "fetchVideoLinks_${id};$seasonId;"
return try {
wrapper.fetchVideoLinks(id, seasonId).apply {
writeByKey(key, this)
}
} catch (e: Exception) {
getByKey(key) ?: throw e
}
}
private suspend fun getCache(): ApiCacheQueries {
return dbProvider.get().apiCacheQueries
}
private suspend inline fun <reified T> getByKey(key: String): T? {
val value = getCache().selectByKey(key)
.awaitAsOneOrNull()?.cache_value
?: return null
return json.decodeFromString(value)
}
private suspend inline fun <reified T> writeByKey(key: String, value: T) = withContext(dispatcherProvider.io) {
getCache().insert(
key,
json.encodeToString(value)
)
}
}
@@ -0,0 +1,25 @@
package ru.shadowsparky.vbox.shared.data.api
import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
class PosterWithoutSuffixVideoApi(
private val wrapper: VideoApi
) : VideoApi by wrapper {
override suspend fun fetchNewVideos(query: String?): VideosResponse {
return wrapper.fetchNewVideos(query).let {
it.copy(items = it.items.map { it.copy(poster = it.poster.withoutSuffix()) })
}
}
override suspend fun fetchDetails(id: Long): VideoDetails {
return wrapper.fetchDetails(id).let {
it.copy(poster = it.poster.withoutSuffix())
}
}
}
internal fun String.withoutSuffix(): String {
return replace("_180.jpg", ".jpg")
}
@@ -0,0 +1,44 @@
package ru.shadowsparky.vbox.shared.data.api
import io.ktor.client.HttpClient
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
class VideoApiImpl(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository
) : VideoApi {
override suspend fun fetchNewVideos(query: String?): VideosResponse {
val params = hashMapOf<String, String>().apply {
query?.let { put(VideoApi.SEARCH_ARG, query) }
}
return get(VideoApi.VIDEOS_PATH, params)
}
override suspend fun fetchDetails(id: Long): VideoDetails {
val params = hashMapOf<String, String>().apply {
put(VideoApi.ID_ARG, id.toString())
}
return get(VideoApi.DETAILS_PATH, params)
}
override suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
val params = hashMapOf<String, String>().apply {
put(VideoApi.ID_ARG, id.toString())
seasonId?.let {
put(VideoApi.SEASON_ID_ARG, it.toString())
}
}
return get(VideoApi.LINKS_PATH, params)
}
private suspend inline fun <reified T> get(path: String, params: Map<String, String>): T {
return httpClient.get(path, params, serverConfigurationProvider.getServerConfiguration())
}
}
@@ -0,0 +1,95 @@
package ru.shadowsparky.vbox.shared.data.recent
import io.ktor.client.HttpClient
import io.ktor.client.request.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRequest
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedResponse
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
@Factory
@Named(DbType.REMOTE)
class RemoteRecentlyWatchedRepository(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage,
@Named(EventType.RECENT) private val remoteEventListener: RemoteEventListener,
) : RecentlyWatchedRepository {
private suspend fun obtainConfig() = serverConfigurationRepository.getServerConfiguration()
override suspend fun remove(info: RecentlyWatchedInfo) {
post(info, RecentlyWatchedRepository.REMOVE)
}
override suspend fun write(info: RecentlyWatchedInfo) {
post(info, RecentlyWatchedRepository.WRITE)
}
override suspend fun writeAll(info: List<RecentlyWatchedInfo>) {
if (info.isNotEmpty()) {
post(info, RecentlyWatchedRepository.WRITE)
}
}
private suspend fun post(info: RecentlyWatchedInfo, path: String) {
post(listOf(info), path)
}
private suspend fun post(info: List<RecentlyWatchedInfo>, path: String) {
authTokenCache.token.firstOrNull() ?: return
httpClient.post {
obtainConfig().apply(this, path)
setJsonBody(RecentlyWatchedRequest(info))
}
}
override fun queryRecentlyWatched(
movieId: Long,
seasonId: Long?
): Flow<List<RecentlyWatchedInfo>> {
return remoteEventListener.event.filter { it is RemoteEvent.OnRecent && it.movieId == movieId }
.map { queryInternal(movieId, seasonId) }
.onStart { emit(queryInternal(movieId, seasonId)) }
}
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
return queryInternal(null, null)
}
private suspend fun queryInternal(
movieId: Long?,
seasonId: Long?
): List<RecentlyWatchedInfo> {
authTokenCache.token.firstOrNull() ?: return emptyList()
val map = hashMapOf<String, String>()
if (movieId != null) {
map[RecentlyWatchedRepository.MOVIE_ID_ARG] = movieId.toString()
}
if (seasonId != null) {
map[RecentlyWatchedRepository.SEASON_ID_ARG] = seasonId.toString()
}
return httpClient.get<RecentlyWatchedResponse>(
RecentlyWatchedRepository.GET_INFO,
map,
obtainConfig()
).info
}
}
@@ -0,0 +1,78 @@
package ru.shadowsparky.vbox.shared.data.recent
import app.cash.sqldelight.async.coroutines.awaitAsList
import app.cash.sqldelight.coroutines.asFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.RecentlyWatched
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
@Factory
@Named(DbType.LOCAL)
class SqlRecentlyWatchedRepository(
private val dbProvider: AppDatabaseProvider,
private val dispatcherProvider: DispatcherProvider
) : RecentlyWatchedRepository {
override suspend fun write(info: RecentlyWatchedInfo): Unit = withContext(dispatcherProvider.io) {
dbProvider.get().recentlyWatchedQueries.insertRecentlyWatched(
info.movieId,
info.episode ?: UNKNOWN,
info.season ?: UNKNOWN
)
}
override suspend fun remove(info: RecentlyWatchedInfo): Unit = withContext(dispatcherProvider.io) {
dbProvider.get().recentlyWatchedQueries.deleteRwByIds(
info.movieId,
info.episode ?: UNKNOWN,
info.season ?: UNKNOWN
)
}
override fun queryRecentlyWatched(
movieId: Long,
seasonId: Long?
): Flow<List<RecentlyWatchedInfo>> {
return flow {
emitAll(
dbProvider.get().recentlyWatchedQueries.selectByMovieId(movieId)
.asFlow()
.map { it.awaitAsList().toDomain() }
)
}.flowOn(dispatcherProvider.io)
}
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> =
withContext(dispatcherProvider.io) {
dbProvider.get().recentlyWatchedQueries.selectAll()
.awaitAsList()
.toDomain()
}
private fun List<RecentlyWatched>.toDomain(): List<RecentlyWatchedInfo> {
return map {
val ep = if (it.episode == UNKNOWN) null else it.episode
val season = if (it.season == UNKNOWN) null else it.season
RecentlyWatchedInfo(it.movie_id, ep, season)
}
}
override suspend fun clear(): Unit = withContext(dispatcherProvider.io) {
dbProvider.get().recentlyWatchedQueries.clear()
}
private companion object {
const val UNKNOWN = -1L
}
}
@@ -0,0 +1,125 @@
package ru.shadowsparky.vbox.shared.data.recent
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.OperationInfo
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.SyncRepository
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
import ru.shadowsparky.vbox.shared.domain.query
import ru.shadowsparky.vbox.shared.domain.queryFlow
@Factory
class SyncClientServerRecentlyWatchedRepository(
@Named(DbType.REMOTE) private val remote: RecentlyWatchedRepository,
@Named(DbType.LOCAL) private val local: RecentlyWatchedRepository,
private val pendingRepo: PendingOperationRepository,
private val syncRepository: SyncRepository,
private val authTokenCache: TokenStorage,
private val json: Json,
private val pendingOperationSynchronizer: PendingOperationSynchronizer
) : RecentlyWatchedRepository {
override fun queryRecentlyWatched(movieId: Long, seasonId: Long?): Flow<List<RecentlyWatchedInfo>> {
return authTokenCache.queryFlow(
remote.queryRecentlyWatched(movieId, seasonId),
local.queryRecentlyWatched(movieId, seasonId)
) {
createSync().syncIfNeeded()
pendingOperationSynchronizer.sync(AggregateType.RECENTLY_WATCHED)
}
}
private fun createSync(): RemoteSourceSynchronizer<RecentlyWatchedInfo, String> {
return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.RECENTLY_WATCHED) },
setSync = { syncRepository.set(AggregateType.RECENTLY_WATCHED, true) },
authTokenCache = authTokenCache,
queryRemote = { remote.getAllRecentlyWatched() },
queryLocal = { local.getAllRecentlyWatched() },
writeRemote = { list -> remote.writeAll(list.map { it.copy(id = -1) }) },
writeLocal = { local.writeAll(it) },
selectKey = { "${it.movieId}|${it.season}|${it.episode}" }
)
}
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
return authTokenCache.query(
active = { remote.getAllRecentlyWatched() },
fallback = { local.getAllRecentlyWatched() }
)
}
override suspend fun write(info: RecentlyWatchedInfo) {
local.write(info)
if (authTokenCache.hasAccount()) {
try {
remote.write(info)
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.RECENTLY_WATCHED,
actionType = ACTION_WRITE,
payload = json.encodeToJsonElement(RecentlyWatchedInfo.serializer(), info)
)
)
}
}
}
override suspend fun remove(info: RecentlyWatchedInfo) {
local.remove(info)
if (authTokenCache.hasAccount()) {
try {
remote.remove(info)
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.RECENTLY_WATCHED,
actionType = ACTION_REMOVE,
payload = json.encodeToJsonElement(RecentlyWatchedInfo.serializer(), info)
)
)
}
}
}
override suspend fun writeAll(info: List<RecentlyWatchedInfo>) {
local.writeAll(info)
if (authTokenCache.hasAccount()) {
try {
remote.writeAll(info)
} catch (_: Exception) {
val payload = buildJsonObject {
put(LIST, json.encodeToJsonElement(info))
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.RECENTLY_WATCHED,
actionType = ACTION_WRITE_ALL,
payload = payload
)
)
}
}
}
companion object {
const val LIST = "list"
const val ACTION_WRITE = "WRITE"
const val ACTION_REMOVE = "REMOVE"
const val ACTION_WRITE_ALL = "WRITE_ALL"
}
}
@@ -0,0 +1,19 @@
package ru.shadowsparky.vbox.shared.data.saved
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ru.shadowsparky.vbox.shared.data.api.withoutSuffix
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
class PosterWithoutSuffixSavedMovieRepository(
private val wrapper: SavedMovieRepository
) : SavedMovieRepository by wrapper {
override fun getAll(): Flow<List<VideoDetails>> {
return wrapper.getAll()
.map {
it.map { it.copy(poster = it.poster.withoutSuffix()) }
}
}
}
@@ -0,0 +1,77 @@
package ru.shadowsparky.vbox.shared.data.saved
import io.ktor.client.HttpClient
import io.ktor.client.request.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.IsSavedResponse
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
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.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
class RemoteSavedMovieRepository(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
@Named(EventType.SAVED) private val eventListener: RemoteEventListener
) : SavedMovieRepository {
override fun getAll(): Flow<List<VideoDetails>> {
return eventListener.event
.filter { it is RemoteEvent.OnSaved }
.map { getAllSingle() }
.onStart { emit(getAllSingle()) }
}
private suspend fun obtainConfig() = serverConfigurationRepository.getServerConfiguration()
private suspend fun getAllSingle(): List<VideoDetails> {
return httpClient.get<SavedMoviesResponse>(
SavedMovieRepository.QUERY_ALL,
emptyMap(),
obtainConfig()
).list
}
override suspend fun save(details: VideoDetails) {
httpClient.post {
obtainConfig().apply(this, SavedMovieRepository.WRITE)
setJsonBody(details)
}
}
override fun isSaved(id: Long): Flow<Boolean> {
return eventListener.event
.filter { it is RemoteEvent.OnSaved && it.movieId == id }
.map { isSavedSingle(id) }
.onStart { emit(isSavedSingle(id)) }
}
private suspend fun isSavedSingle(id: Long): Boolean {
return httpClient.get<IsSavedResponse>(
SavedMovieRepository.QUERY_SAVED,
mapOf(
SavedMovieRepository.MOVIE_ID_ARG to id.toString()
),
obtainConfig()
).isSaved
}
override suspend fun remove(id: Long) {
httpClient.post {
obtainConfig().apply(this, SavedMovieRepository.REMOVE)
setJsonBody(RemoveRequest(id))
}
}
}
@@ -0,0 +1,74 @@
package ru.shadowsparky.vbox.shared.data.saved
import app.cash.sqldelight.Query
import app.cash.sqldelight.async.coroutines.awaitAsList
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.SavedMovie
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
class SqlSavedMovieRepository(
private val dbProvider: AppDatabaseProvider,
private val dispatcherProvider: DispatcherProvider
) : SavedMovieRepository {
private suspend fun getRequest(): Query<SavedMovie> = withContext(dispatcherProvider.io) {
dbProvider.get().savedMovieQueries.selectAll()
}
override fun getAll(): Flow<List<VideoDetails>> {
return callbackFlow {
val listener = Query.Listener {
launch { send(obtain()) }
}
val request = getRequest()
request.addListener(listener)
send(obtain())
awaitClose { request.removeListener(listener) }
}.flowOn(dispatcherProvider.io)
}
private suspend fun obtain(): List<VideoDetails> = withContext(dispatcherProvider.io) {
val request = getRequest()
request.awaitAsList().map {
VideoDetails(
it.saved_movie_id,
it.poster_url,
it.description,
it.title,
(it.flags.toInt() and SERIAL_FLAG) != 0
)
}
}
override fun isSaved(id: Long): Flow<Boolean> {
return getAll().map { it.any { id == it.id } }
}
override suspend fun save(details: VideoDetails) = withContext(dispatcherProvider.io) {
val canSave = dbProvider.get().savedMovieQueries.selectAll().awaitAsList()
.none { it.saved_movie_id == details.id }
if (canSave) {
dbProvider.get().savedMovieQueries.insertSavedMovie(
details.id,
details.poster,
details.desc,
details.title,
details.flags.toLong()
)
}
}
override suspend fun remove(id: Long): Unit = withContext(dispatcherProvider.io) {
dbProvider.get().savedMovieQueries.deleteSavedMovie(id)
}
}
@@ -0,0 +1,143 @@
package ru.shadowsparky.vbox.shared.data.saved
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.OperationInfo
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.SyncRepository
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.queryFlow
@Factory
class SyncClientSavedMovieRepository(
@Named(DbType.REMOTE) private val remote: SavedMovieRepository,
@Named(DbType.LOCAL) private val local: SavedMovieRepository,
private val pendingRepo: PendingOperationRepository,
private val syncRepository: SyncRepository,
private val authTokenCache: TokenStorage,
private val json: Json,
private val pendingOperationSynchronizer: PendingOperationSynchronizer
) : SavedMovieRepository {
override suspend fun save(details: VideoDetails) {
local.save(details)
if (authTokenCache.hasAccount()) {
try {
remote.save(details)
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.SAVED_MOVIE,
actionType = ACTION_SAVE,
payload = json.encodeToJsonElement(VideoDetails.serializer(), details)
)
)
}
}
}
override suspend fun remove(id: Long) {
local.remove(id)
if (authTokenCache.hasAccount()) {
try {
remote.remove(id)
} catch (_: Exception) {
val payload = buildJsonObject {
put(KEY_ID, id)
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.SAVED_MOVIE,
actionType = ACTION_REMOVE,
payload = payload
)
)
}
}
}
override fun getAll(): Flow<List<VideoDetails>> {
return authTokenCache.queryFlow(remote.getAll(), local.getAll()) {
createSync().syncIfNeeded()
pendingOperationSynchronizer.sync(AggregateType.SAVED_MOVIE)
}
}
private fun createSync(): RemoteSourceSynchronizer<VideoDetails, Long> {
return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.SAVED_MOVIE) },
setSync = { syncRepository.set(AggregateType.SAVED_MOVIE, true) },
authTokenCache = authTokenCache,
queryRemote = { remote.getAll().first() },
queryLocal = { local.getAll().first() },
writeRemote = { remote.saveAll(it) },
writeLocal = { local.saveAll(it) },
selectKey = { it.id }
)
}
override fun isSaved(id: Long): Flow<Boolean> {
return authTokenCache.queryFlow(remote.isSaved(id), local.isSaved(id))
}
override suspend fun saveAll(details: List<VideoDetails>) {
local.saveAll(details)
if (authTokenCache.hasAccount()) {
try {
remote.saveAll(details)
} catch (_: Exception) {
val payload = buildJsonObject {
put(KEY_LIST, json.encodeToJsonElement(details))
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.SAVED_MOVIE,
actionType = ACTION_SAVE_ALL,
payload = payload
)
)
}
}
}
override suspend fun clear() {
local.clear()
if (authTokenCache.hasAccount()) {
try {
remote.clear()
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.SAVED_MOVIE,
actionType = ACTION_CLEAR,
payload = kotlinx.serialization.json.JsonNull
)
)
}
}
}
companion object {
const val ACTION_SAVE = "SAVE"
const val ACTION_REMOVE = "REMOVE"
const val ACTION_SAVE_ALL = "SAVE_ALL"
const val ACTION_CLEAR = "CLEAR"
const val KEY_ID = "id"
const val KEY_LIST = "list"
}
}
@@ -0,0 +1,76 @@
package ru.shadowsparky.vbox.shared.data.search
import io.ktor.client.HttpClient
import io.ktor.client.request.delete
import io.ktor.client.request.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
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
import ru.shadowsparky.vbox.shared.domain.ServerConfiguration
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
@Named(DbType.REMOTE)
@Factory
class RemoteSearchRepository(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
@Named(EventType.SEARCH) private val removeListener: RemoteEventListener
) : SearchRepository {
override fun search(query: String): Flow<List<String>> {
return removeListener.event
.map { searchSingle(query) }
.onStart { emit(searchSingle(query)) }
}
private suspend fun searchSingle(query: String): List<String> {
return httpClient.get<SearchQueryResponse>(
SearchRepository.SEARCH,
hashMapOf(SearchRepository.QUERY_ARG to query),
obtainConf()
).search
}
override suspend fun addToSearch(query: String) {
addToSearch(listOf(query))
}
override suspend fun addToSearch(query: List<String>) {
httpClient.post {
obtainConf().apply(this, SearchRepository.ADD_TO_SEARCH)
setJsonBody(SearchQueryRequest(query))
}
}
override suspend fun deleteFromSearch(query: String) {
deleteInternal(query)
}
override suspend fun clear() {
deleteInternal(null)
}
private suspend fun deleteInternal(query: String?) {
httpClient.delete {
obtainConf().apply(this, SearchRepository.CLEAR)
setJsonBody(SearchDeleteRequest(query))
}
}
private suspend fun obtainConf(): ServerConfiguration {
return serverConfigurationProvider.getServerConfiguration()
}
}
@@ -0,0 +1,69 @@
package ru.shadowsparky.vbox.shared.data.search
import app.cash.sqldelight.Query
import app.cash.sqldelight.async.coroutines.awaitAsList
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import app.cash.sqldelight.coroutines.asFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.SearchInfo
import ru.shadowsparky.vbox.domain.SearchInfoQueries
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.SearchRepository
@Named(DbType.LOCAL)
@Factory
class SqlSearchRepository(
private val dbProvider: AppDatabaseProvider,
private val dispatcherProvider: DispatcherProvider
) : SearchRepository {
override fun search(query: String): Flow<List<String>> {
return flow {
emitAll(
if (query.isNotEmpty()) {
getQueries().selectByQuery(query.addWildcard()).toDomain()
} else {
getQueries().selectAll().toDomain()
}
)
}
}
private fun Query<SearchInfo>.toDomain(): Flow<List<String>> {
return asFlow()
.map { list ->
list.awaitAsList().map { it.query }
}.flowOn(dispatcherProvider.io)
}
override suspend fun clear(): Unit = withContext(dispatcherProvider.io) {
getQueries().deleteAll()
}
override suspend fun deleteFromSearch(query: String): Unit = withContext(dispatcherProvider.io) {
getQueries().deleteSearchInfo(query)
}
override suspend fun addToSearch(query: String) = withContext(dispatcherProvider.io) {
if (getQueries().selectByQuery(query).awaitAsOneOrNull() == null) {
getQueries().insertSearchInfo(query)
}
}
private suspend fun getQueries(): SearchInfoQueries {
return dbProvider.get().searchInfoQueries
}
private fun String.addWildcard(): String {
return if (this.endsWith("%")) this else "$this%"
}
}
@@ -0,0 +1,119 @@
package ru.shadowsparky.vbox.shared.data.search
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
import ru.shadowsparky.vbox.shared.data.sync.PendingOperationSynchronizer
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.OperationInfo
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
import ru.shadowsparky.vbox.shared.domain.SearchRepository
import ru.shadowsparky.vbox.shared.domain.SyncRepository
import ru.shadowsparky.vbox.shared.domain.queryFlow
@Factory
class SyncClientServerSearchRepository(
@Named(DbType.REMOTE) private val remote: SearchRepository,
@Named(DbType.LOCAL) private val local: SearchRepository,
private val pendingRepo: PendingOperationRepository,
private val syncRepository: SyncRepository,
private val authTokenCache: TokenStorage,
private val pendingOperationSynchronizer: PendingOperationSynchronizer
) : SearchRepository {
override fun search(query: String): Flow<List<String>> {
return authTokenCache.queryFlow(
remote.search(query),
local.search(query)
) {
createSync().syncIfNeeded()
pendingOperationSynchronizer.sync(AggregateType.SEARCH)
}
}
private fun createSync(): RemoteSourceSynchronizer<String, String> {
return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.SEARCH) },
setSync = { syncRepository.set(AggregateType.SEARCH, true) },
authTokenCache = authTokenCache,
queryRemote = { remote.search("").first() },
queryLocal = { local.search("").first() },
writeRemote = { remote.addToSearch(it) },
writeLocal = { local.addToSearch(it) },
selectKey = { it }
)
}
override suspend fun addToSearch(query: String) {
local.addToSearch(query)
if (authTokenCache.hasAccount()) {
try {
remote.addToSearch(query)
} catch (_: Exception) {
val payload = buildJsonObject {
put(KEY_QUERY, query)
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.SEARCH,
actionType = ACTION_ADD,
payload = payload
)
)
}
}
}
override suspend fun deleteFromSearch(query: String) {
local.deleteFromSearch(query)
if (authTokenCache.hasAccount()) {
try {
remote.deleteFromSearch(query)
} catch (_: Exception) {
val payload = buildJsonObject {
put(KEY_QUERY, query)
}
pendingRepo.insert(
OperationInfo(
type = AggregateType.SEARCH,
actionType = ACTION_DELETE,
payload = payload
)
)
}
}
}
override suspend fun clear() {
local.clear()
if (authTokenCache.hasAccount()) {
try {
remote.clear()
} catch (_: Exception) {
pendingRepo.insert(
OperationInfo(
type = AggregateType.SEARCH,
actionType = ACTION_CLEAR,
payload = JsonNull
)
)
}
}
}
companion object {
const val ACTION_ADD = "ADD"
const val ACTION_DELETE = "DELETE"
const val ACTION_CLEAR = "CLEAR"
const val KEY_QUERY = "query"
}
}
@@ -0,0 +1,30 @@
package ru.shadowsparky.vbox.shared.data.sync
import org.koin.core.annotation.Singleton
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
@Singleton
class PendingOperationSynchronizer(
private val pendingRepo: PendingOperationRepository,
strategies: List<AggregateSyncStrategy>
) {
private val strategiesMap = strategies.associateBy { it.aggregateType }
suspend fun sync(type: AggregateType): Boolean {
val strategy = strategiesMap[type] ?: error("No sync strategy registered for type: $type")
val operations = pendingRepo.query(type)
for (operation in operations) {
try {
strategy.execute(operation.actionType, operation.payload)
pendingRepo.delete(operation.id)
} catch (_: IllegalStateException) {
pendingRepo.delete(operation.id)
} catch (_: Exception) {
return false
}
}
return true
}
}
@@ -0,0 +1,55 @@
package ru.shadowsparky.vbox.shared.data.sync
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.vbox.domain.PendingOperationQueries
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.OperationInfo
import ru.shadowsparky.vbox.shared.domain.PendingOperationRepository
import kotlin.time.Clock
@Factory
class SqlPendingOperationRepository(
private val db: AppDatabaseProvider,
private val json: Json,
private val dispatcherProvider: DispatcherProvider
) : PendingOperationRepository {
private suspend fun getQueries(): PendingOperationQueries {
return db.get().pendingOperationQueries
}
override suspend fun delete(id: Long): Unit = withContext(dispatcherProvider.io) {
getQueries().deleteOperation(id)
}
override suspend fun query(type: AggregateType): List<OperationInfo> = withContext(dispatcherProvider.io) {
val records = getQueries().getOperationsByAggregate(type.name).executeAsList()
records.map { record ->
OperationInfo(
id = record.id,
type = type,
actionType = record.actionType,
payload = record.payload?.let { json.parseToJsonElement(it) } ?: JsonNull
)
}
}
override suspend fun insert(operationInfo: OperationInfo): Unit = withContext(dispatcherProvider.io) {
val payloadString = json.encodeToString(operationInfo.payload)
getQueries().insertOperation(
aggregateType = operationInfo.type.name,
actionType = operationInfo.actionType,
payload = payloadString,
createdAt = Clock.System.now().epochSeconds
)
}
override suspend fun clear(): Unit = withContext(dispatcherProvider.io) {
getQueries().clear()
}
}
@@ -0,0 +1,39 @@
package ru.shadowsparky.vbox.shared.data.sync
import kotlinx.coroutines.flow.first
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.KeyValueStorage
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.AggregateType.RECENTLY_WATCHED
import ru.shadowsparky.vbox.shared.domain.AggregateType.SAVED_MOVIE
import ru.shadowsparky.vbox.shared.domain.AggregateType.SEARCH
import ru.shadowsparky.vbox.shared.domain.SyncRepository
@Factory
class SyncRepositoryImpl(
private val storage: KeyValueStorage
) : SyncRepository {
override suspend fun isSynchronized(type: AggregateType): Boolean {
return storage.getBoolean(type.calcKey()).first() ?: false
}
override suspend fun set(type: AggregateType, state: Boolean) {
storage.putBoolean(type.calcKey(), state)
}
private fun AggregateType.calcKey(): String {
return when (this) {
SEARCH -> SEARCH_KEY
RECENTLY_WATCHED -> RECENT_KEY
SAVED_MOVIE -> SAVED_KEY
else -> name.lowercase()
}
}
companion object {
const val SEARCH_KEY = "search"
const val RECENT_KEY = "recent"
const val SAVED_KEY = "saved"
}
}
@@ -0,0 +1,33 @@
package ru.shadowsparky.vbox.shared.data.sync.strategy
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.tag.SyncMovieTagRepository
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
@Factory
class MovieTagSyncStrategy(
@Named(DbType.REMOTE) private val remote: MovieTagRepository
) : AggregateSyncStrategy {
override val aggregateType: AggregateType = AggregateType.MOVIE_TAG
override suspend fun execute(actionType: String, payload: JsonElement) {
val payloadObj = payload.jsonObject
val movieId = payloadObj[SyncMovieTagRepository.KEY_MOVIE_ID]?.jsonPrimitive?.long
?: error("Missing ${SyncMovieTagRepository.KEY_MOVIE_ID}")
val tagId = payloadObj[SyncMovieTagRepository.KEY_TAG_ID]?.jsonPrimitive?.long
?: error("Missing ${SyncMovieTagRepository.KEY_TAG_ID}")
when (actionType) {
SyncMovieTagRepository.ACTION_LINK -> remote.link(movieId, tagId)
SyncMovieTagRepository.ACTION_UNLINK -> remote.unlink(movieId, tagId)
else -> error("Unknown action type: $actionType")
}
}
}
@@ -0,0 +1,42 @@
package ru.shadowsparky.vbox.shared.data.sync.strategy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.recent.SyncClientServerRecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
@Factory
class RecentlyWatchedSyncStrategy(
@Named(DbType.REMOTE) private val remote: RecentlyWatchedRepository,
private val json: Json
) : AggregateSyncStrategy {
override val aggregateType: AggregateType = AggregateType.RECENTLY_WATCHED
override suspend fun execute(actionType: String, payload: JsonElement) {
when (actionType) {
SyncClientServerRecentlyWatchedRepository.ACTION_WRITE -> {
val info = json.decodeFromJsonElement<RecentlyWatchedInfo>(payload)
remote.write(info)
}
SyncClientServerRecentlyWatchedRepository.ACTION_REMOVE -> {
val info = json.decodeFromJsonElement<RecentlyWatchedInfo>(payload)
remote.remove(info)
}
SyncClientServerRecentlyWatchedRepository.ACTION_WRITE_ALL -> {
val payloadObj = payload.jsonObject
val listElement = payloadObj[SyncClientServerRecentlyWatchedRepository.LIST] ?: error("Missing list key")
val list = json.decodeFromJsonElement<List<RecentlyWatchedInfo>>(listElement)
remote.writeAll(list)
}
else -> error("Unknown action type: $actionType")
}
}
}
@@ -0,0 +1,49 @@
package ru.shadowsparky.vbox.shared.data.sync.strategy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.saved.SyncClientSavedMovieRepository
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.AggregateSyncStrategy
import ru.shadowsparky.vbox.shared.domain.AggregateType
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
@Factory
class SavedMovieSyncStrategy(
@Named(DbType.REMOTE) private val remote: SavedMovieRepository,
private val json: Json
) : AggregateSyncStrategy {
override val aggregateType: AggregateType = AggregateType.SAVED_MOVIE
override suspend fun execute(actionType: String, payload: JsonElement) {
when (actionType) {
SyncClientSavedMovieRepository.ACTION_SAVE -> {
val details = json.decodeFromJsonElement<VideoDetails>(payload)
remote.save(details)
}
SyncClientSavedMovieRepository.ACTION_REMOVE -> {
val id = payload.jsonObject[SyncClientSavedMovieRepository.KEY_ID]?.jsonPrimitive?.long
?: error("Missing ${SyncClientSavedMovieRepository.KEY_ID}")
remote.remove(id)
}
SyncClientSavedMovieRepository.ACTION_SAVE_ALL -> {
val listElement = payload.jsonObject[SyncClientSavedMovieRepository.KEY_LIST]
?: error("Missing ${SyncClientSavedMovieRepository.KEY_LIST}")
val list = json.decodeFromJsonElement<List<VideoDetails>>(listElement)
remote.saveAll(list)
}
SyncClientSavedMovieRepository.ACTION_CLEAR -> {
remote.clear()
}
else -> error("Unknown action type: $actionType")
}
}
}

Some files were not shown because too many files have changed in this diff Show More