init projects
This commit is contained in:
@@ -0,0 +1 @@
|
||||
build
|
||||
@@ -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>
|
||||
+388
@@ -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)
|
||||
}
|
||||
}
|
||||
+134
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+127
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
@@ -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")
|
||||
}
|
||||
}
|
||||
+49
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ActivityInfo {
|
||||
val isActiveFlow: StateFlow<Boolean>
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
actual fun getCurrentOsType(): OsType {
|
||||
return OsType.ANDROID
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
+44
@@ -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
|
||||
}
|
||||
}
|
||||
+20
@@ -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>
|
||||
+10
@@ -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>
|
||||
+10
@@ -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>
|
||||
+15
@@ -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
|
||||
+32
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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"
|
||||
}
|
||||
}
|
||||
+22
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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()
|
||||
}
|
||||
}
|
||||
+37
@@ -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()
|
||||
}
|
||||
+31
@@ -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"
|
||||
}
|
||||
}
|
||||
+107
@@ -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"
|
||||
}
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+71
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+25
@@ -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")
|
||||
}
|
||||
+44
@@ -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())
|
||||
}
|
||||
}
|
||||
+95
@@ -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
|
||||
}
|
||||
}
|
||||
+78
@@ -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
|
||||
}
|
||||
}
|
||||
+125
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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)
|
||||
}
|
||||
}
|
||||
+143
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -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()
|
||||
}
|
||||
}
|
||||
+69
@@ -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%"
|
||||
}
|
||||
}
|
||||
+119
@@ -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"
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
}
|
||||
+55
@@ -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()
|
||||
}
|
||||
}
|
||||
+39
@@ -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"
|
||||
}
|
||||
}
|
||||
+33
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package ru.shadowsparky.vbox.shared.data.sync.strategy
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.shared.data.search.SyncClientServerSearchRepository
|
||||
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.SearchRepository
|
||||
|
||||
@Factory
|
||||
class SearchSyncStrategy(
|
||||
@Named(DbType.REMOTE) private val remote: SearchRepository
|
||||
) : AggregateSyncStrategy {
|
||||
override val aggregateType: AggregateType = AggregateType.SEARCH
|
||||
|
||||
override suspend fun execute(actionType: String, payload: JsonElement) {
|
||||
when (actionType) {
|
||||
SyncClientServerSearchRepository.ACTION_ADD -> {
|
||||
val query = payload.jsonObject[SyncClientServerSearchRepository.KEY_QUERY]?.jsonPrimitive?.content
|
||||
?: error("Missing ${SyncClientServerSearchRepository.KEY_QUERY}")
|
||||
remote.addToSearch(query)
|
||||
}
|
||||
SyncClientServerSearchRepository.ACTION_DELETE -> {
|
||||
val query = payload.jsonObject[SyncClientServerSearchRepository.KEY_QUERY]?.jsonPrimitive?.content
|
||||
?: error("Missing ${SyncClientServerSearchRepository.KEY_QUERY}")
|
||||
remote.deleteFromSearch(query)
|
||||
}
|
||||
SyncClientServerSearchRepository.ACTION_CLEAR -> remote.clear()
|
||||
else -> error("Unknown action type: $actionType")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
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.SyncUserTagRepository
|
||||
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.UserTagRepository
|
||||
|
||||
@Factory
|
||||
class UserTagSyncStrategy(
|
||||
@Named(DbType.REMOTE) private val remote: UserTagRepository
|
||||
) : AggregateSyncStrategy {
|
||||
override val aggregateType: AggregateType = AggregateType.USER_TAG
|
||||
|
||||
override suspend fun execute(actionType: String, payload: JsonElement) {
|
||||
val payloadObj = payload.jsonObject
|
||||
when (actionType) {
|
||||
SyncUserTagRepository.ACTION_ADD -> {
|
||||
val tag = payloadObj[SyncUserTagRepository.KEY_TAG]?.jsonPrimitive?.content
|
||||
?: error("Missing ${SyncUserTagRepository.KEY_TAG}")
|
||||
remote.add(tag)
|
||||
}
|
||||
SyncUserTagRepository.ACTION_EDIT -> {
|
||||
val id = payloadObj[SyncUserTagRepository.KEY_ID]?.jsonPrimitive?.long
|
||||
?: error("Missing ${SyncUserTagRepository.KEY_ID}")
|
||||
val newTag = payloadObj[SyncUserTagRepository.KEY_NEW_TAG]?.jsonPrimitive?.content
|
||||
?: error("Missing ${SyncUserTagRepository.KEY_NEW_TAG}")
|
||||
remote.edit(id, newTag)
|
||||
}
|
||||
SyncUserTagRepository.ACTION_DELETE -> {
|
||||
val id = payloadObj[SyncUserTagRepository.KEY_ID]?.jsonPrimitive?.long
|
||||
?: error("Missing ${SyncUserTagRepository.KEY_ID}")
|
||||
remote.delete(id)
|
||||
}
|
||||
else -> error("Unknown action type: $actionType")
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
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.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
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.domain.DispatcherProvider
|
||||
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.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.tag.LinkRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.LinkedMovies
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagResponse
|
||||
|
||||
@Named(DbType.REMOTE)
|
||||
@Factory
|
||||
class RemoteMovieTagRepository(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationProvider: ServerConfigurationRepository,
|
||||
@Named(EventType.MOVIE_TAG) private val movieTagListener: RemoteEventListener,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : MovieTagRepository {
|
||||
|
||||
override fun query(movieId: Long): Flow<List<TagInfo>> {
|
||||
return movieTagListener.event.filterIsInstance<RemoteEvent.OnMovieTag>()
|
||||
.filter { it.movieId == movieId }
|
||||
.map { querySingle(movieId) }
|
||||
.onStart { emit(querySingle(movieId)) }
|
||||
.flowOn(dispatcherProvider.io)
|
||||
}
|
||||
|
||||
private suspend fun querySingle(movieId: Long): List<TagInfo> {
|
||||
return httpClient.get<TagResponse>(
|
||||
MovieTagRepository.QUERY,
|
||||
hashMapOf(MovieTagRepository.MOVIE_ID to movieId.toString()),
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
).tags
|
||||
}
|
||||
|
||||
override fun queryMovies(tagId: Long): Flow<Set<Long>> {
|
||||
return movieTagListener.event.filterIsInstance<RemoteEvent.OnMovieTag>()
|
||||
.map { queryMoviesSingle(tagId) }
|
||||
.onStart { emit(queryMoviesSingle(tagId)) }
|
||||
.flowOn(dispatcherProvider.io)
|
||||
}
|
||||
|
||||
private suspend fun queryMoviesSingle(tagId: Long): Set<Long> {
|
||||
return httpClient.get<LinkedMovies>(
|
||||
MovieTagRepository.QUERY_MOVIES,
|
||||
hashMapOf(MovieTagRepository.TAG_ID to tagId.toString()),
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
).ids
|
||||
}
|
||||
|
||||
override suspend fun link(movieId: Long, tagId: Long) {
|
||||
httpClient.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, MovieTagRepository.LINK)
|
||||
setJsonBody(LinkRequest(movieId, tagId))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unlink(movieId: Long, tagId: Long) {
|
||||
httpClient.delete {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, MovieTagRepository.UNLINK)
|
||||
setJsonBody(LinkRequest(movieId, tagId))
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
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.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
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.domain.DispatcherProvider
|
||||
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.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.tag.AddRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.DeleteRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.EditRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
@Named(DbType.REMOTE)
|
||||
class RemoteUserTagRepository(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationProvider: ServerConfigurationRepository,
|
||||
@Named(EventType.USER_TAG) private val userTagListener: RemoteEventListener,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : UserTagRepository {
|
||||
|
||||
override val userTags: Flow<List<TagInfo>> = userTagListener.event.filterIsInstance<RemoteEvent.OnUserTag>()
|
||||
.map { queryUserTagsSingle() }
|
||||
.onStart { emit(queryUserTagsSingle()) }
|
||||
.flowOn(dispatcherProvider.io)
|
||||
|
||||
private suspend fun queryUserTagsSingle(): List<TagInfo> {
|
||||
return httpClient.get<TagResponse>(
|
||||
UserTagRepository.LIST,
|
||||
emptyMap(),
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
).tags
|
||||
}
|
||||
|
||||
override suspend fun add(tag: String) {
|
||||
httpClient.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, UserTagRepository.ADD)
|
||||
setJsonBody(AddRequest(tag))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun edit(id: Long, newTag: String) {
|
||||
httpClient.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, UserTagRepository.EDIT)
|
||||
setJsonBody(EditRequest(id, newTag))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
httpClient.delete {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, UserTagRepository.DELETE)
|
||||
setJsonBody(DeleteRequest(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.domain.MovieTagQueries
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
|
||||
@Named(DbType.LOCAL)
|
||||
@Factory
|
||||
class SqlMovieTagRepository(
|
||||
private val dbProvider: AppDatabaseProvider,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : MovieTagRepository {
|
||||
|
||||
override fun query(movieId: Long): Flow<List<TagInfo>> = flow {
|
||||
emitAll(
|
||||
getQueries().query(movieId = movieId)
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { tags ->
|
||||
tags.map { tag ->
|
||||
TagInfo(
|
||||
id = tag.id,
|
||||
tag = tag.tag
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun queryMovies(tagId: Long): Flow<Set<Long>> = flow {
|
||||
emitAll(
|
||||
getQueries().queryMovies(tagId)
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { it.toSet() }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun link(movieId: Long, tagId: Long) {
|
||||
getQueries().link(
|
||||
movieId = movieId,
|
||||
tagId = tagId
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun unlink(movieId: Long, tagId: Long) {
|
||||
getQueries().unlink(
|
||||
movieId = movieId,
|
||||
tagId = tagId
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
getQueries().clear()
|
||||
}
|
||||
|
||||
private suspend fun getQueries(): MovieTagQueries {
|
||||
return dbProvider.get().movieTagQueries
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.domain.UserTagQueries
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Named(DbType.LOCAL)
|
||||
@Factory
|
||||
class SqlUserTagRepository(
|
||||
private val dbProvider: AppDatabaseProvider,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : UserTagRepository {
|
||||
|
||||
override val userTags: Flow<List<TagInfo>> = flow {
|
||||
emitAll(
|
||||
getQueries().selectAll()
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { tags ->
|
||||
tags.map { tag ->
|
||||
TagInfo(
|
||||
id = tag.id,
|
||||
tag = tag.tag
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun add(tag: String) {
|
||||
runCatching { getQueries().insert(tag) }
|
||||
}
|
||||
|
||||
override suspend fun edit(id: Long, newTag: String) {
|
||||
getQueries().update(
|
||||
id = id,
|
||||
newTag = newTag
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
runCatching { getQueries().delete(id) }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
getQueries().clear()
|
||||
}
|
||||
|
||||
private suspend fun getQueries(): UserTagQueries {
|
||||
return dbProvider.get().userTagQueries
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.Serializable
|
||||
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.SyncRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.queryFlow
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
class SyncMovieTagRepository(
|
||||
@Named(DbType.LOCAL) private val local: MovieTagRepository,
|
||||
@Named(DbType.REMOTE) private val remote: MovieTagRepository,
|
||||
@Named(DbType.REMOTE) private val userTagRemote: UserTagRepository,
|
||||
@Named(DbType.LOCAL) private val userTagLocal: UserTagRepository,
|
||||
private val pendingRepo: PendingOperationRepository,
|
||||
private val tokenStorage: TokenStorage,
|
||||
private val syncRepository: SyncRepository,
|
||||
private val pendingOperationSynchronizer: PendingOperationSynchronizer
|
||||
) : MovieTagRepository {
|
||||
|
||||
override fun query(movieId: Long): Flow<List<TagInfo>> {
|
||||
return tokenStorage.queryFlow(remote.query(movieId), local.query(movieId)) {
|
||||
createSync().syncIfNeeded()
|
||||
pendingOperationSynchronizer.sync(AggregateType.MOVIE_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSync(): RemoteSourceSynchronizer<MovieTagLink, String> {
|
||||
return RemoteSourceSynchronizer(
|
||||
isSync = { syncRepository.isSynchronized(AggregateType.MOVIE_TAG) },
|
||||
setSync = { syncRepository.set(AggregateType.MOVIE_TAG, true) },
|
||||
authTokenCache = tokenStorage,
|
||||
queryRemote = {
|
||||
val allTags = userTagRemote.userTags.first()
|
||||
allTags.flatMap { tag ->
|
||||
val tagId = tag.id ?: return@flatMap emptyList()
|
||||
remote.queryMovies(tagId).first().map { movieId ->
|
||||
MovieTagLink(movieId = movieId, tagId = tagId)
|
||||
}
|
||||
}
|
||||
},
|
||||
queryLocal = {
|
||||
val allTags = userTagLocal.userTags.first()
|
||||
allTags.flatMap { tag ->
|
||||
val tagId = tag.id ?: return@flatMap emptyList()
|
||||
local.queryMovies(tagId).first().map { movieId ->
|
||||
MovieTagLink(movieId = movieId, tagId = tagId)
|
||||
}
|
||||
}
|
||||
},
|
||||
writeRemote = { list ->
|
||||
list.forEach { link -> remote.link(movieId = link.movieId, tagId = link.tagId) }
|
||||
},
|
||||
writeLocal = { list ->
|
||||
list.forEach { link -> local.link(movieId = link.movieId, tagId = link.tagId) }
|
||||
},
|
||||
selectKey = { "${it.movieId}|${it.tagId}" }
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MovieTagLink(
|
||||
val movieId: Long,
|
||||
val tagId: Long
|
||||
)
|
||||
|
||||
override fun queryMovies(tagId: Long): Flow<Set<Long>> {
|
||||
return tokenStorage.queryFlow(remote.queryMovies(tagId), local.queryMovies(tagId))
|
||||
}
|
||||
|
||||
override suspend fun link(movieId: Long, tagId: Long) {
|
||||
local.link(movieId, tagId)
|
||||
if (tokenStorage.hasAccount()) {
|
||||
try {
|
||||
remote.link(movieId, tagId)
|
||||
} catch (_: Exception) {
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.MOVIE_TAG,
|
||||
actionType = ACTION_LINK,
|
||||
payload = buildPayload(movieId, tagId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unlink(movieId: Long, tagId: Long) {
|
||||
local.unlink(movieId, tagId)
|
||||
if (tokenStorage.hasAccount()) {
|
||||
try {
|
||||
remote.unlink(movieId, tagId)
|
||||
} catch (_: Exception) {
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.MOVIE_TAG,
|
||||
actionType = ACTION_UNLINK,
|
||||
payload = buildPayload(movieId, tagId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
local.clear()
|
||||
if (tokenStorage.hasAccount()) remote.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_LINK = "LINK"
|
||||
const val ACTION_UNLINK = "UNLINK"
|
||||
|
||||
const val KEY_MOVIE_ID = "movieId"
|
||||
const val KEY_TAG_ID = "tagId"
|
||||
|
||||
private fun buildPayload(movieId: Long, tagId: Long) = buildJsonObject {
|
||||
put(KEY_MOVIE_ID, movieId)
|
||||
put(KEY_TAG_ID, tagId)
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package ru.shadowsparky.vbox.shared.data.tag
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
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.SyncRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.queryFlow
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
class SyncUserTagRepository(
|
||||
@Named(DbType.LOCAL) private val local: UserTagRepository,
|
||||
@Named(DbType.REMOTE) private val remote: UserTagRepository,
|
||||
private val pendingRepo: PendingOperationRepository,
|
||||
private val tokenStorage: TokenStorage,
|
||||
private val syncRepository: SyncRepository,
|
||||
private val pendingOperationSynchronizer: PendingOperationSynchronizer
|
||||
) : UserTagRepository {
|
||||
|
||||
override val userTags: Flow<List<TagInfo>> = tokenStorage.queryFlow(
|
||||
remote.userTags,
|
||||
local.userTags
|
||||
) {
|
||||
createSync().syncIfNeeded()
|
||||
pendingOperationSynchronizer.sync(AggregateType.USER_TAG)
|
||||
}
|
||||
|
||||
private fun createSync(): RemoteSourceSynchronizer<TagInfo, String> {
|
||||
return RemoteSourceSynchronizer(
|
||||
isSync = { syncRepository.isSynchronized(AggregateType.USER_TAG) },
|
||||
setSync = { syncRepository.set(AggregateType.USER_TAG, true) },
|
||||
authTokenCache = tokenStorage,
|
||||
queryRemote = { remote.userTags.first() },
|
||||
queryLocal = { local.userTags.first() },
|
||||
writeRemote = { list ->
|
||||
list.forEach { tagInfo -> remote.add(tagInfo.tag) }
|
||||
},
|
||||
writeLocal = { list ->
|
||||
list.forEach { tagInfo -> local.add(tagInfo.tag) }
|
||||
},
|
||||
selectKey = { it.tag }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun add(tag: String) {
|
||||
local.add(tag)
|
||||
if (tokenStorage.hasAccount()) {
|
||||
try {
|
||||
remote.add(tag)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject { put(KEY_TAG, tag) }
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.USER_TAG,
|
||||
actionType = ACTION_ADD,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun edit(id: Long, newTag: String) {
|
||||
local.edit(id, newTag)
|
||||
if (tokenStorage.hasAccount()) {
|
||||
try {
|
||||
remote.edit(id, newTag)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject {
|
||||
put(KEY_ID, id)
|
||||
put(KEY_NEW_TAG, newTag)
|
||||
}
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.USER_TAG,
|
||||
actionType = ACTION_EDIT,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
local.delete(id)
|
||||
if (tokenStorage.hasAccount()) {
|
||||
try {
|
||||
remote.delete(id)
|
||||
} catch (_: Exception) {
|
||||
val payload = buildJsonObject { put(KEY_ID, id) }
|
||||
pendingRepo.insert(
|
||||
OperationInfo(
|
||||
type = AggregateType.USER_TAG,
|
||||
actionType = ACTION_DELETE,
|
||||
payload = payload
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
local.clear()
|
||||
if (tokenStorage.hasAccount()) remote.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_ADD = "ADD"
|
||||
const val ACTION_EDIT = "EDIT"
|
||||
const val ACTION_DELETE = "DELETE"
|
||||
|
||||
const val KEY_ID = "id"
|
||||
const val KEY_TAG = "tag"
|
||||
const val KEY_NEW_TAG = "newTag"
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package ru.shadowsparky.vbox.shared.data.token
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.http.domain.RefreshTokenAction
|
||||
import ru.shadowsparky.http.domain.TokenInfo
|
||||
import ru.shadowsparky.vbox.shared.data.apply
|
||||
import ru.shadowsparky.vbox.shared.data.setJsonBody
|
||||
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.TokenRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
|
||||
@Factory
|
||||
class RefreshTokenActionImpl(
|
||||
private val serverConfigurationProvider: ServerConfigurationRepository
|
||||
) : RefreshTokenAction {
|
||||
override suspend fun refresh(client: HttpClient, refreshToken: String): TokenInfo {
|
||||
val result = client.post {
|
||||
serverConfigurationProvider.getServerConfiguration()
|
||||
.apply(this, AuthTokenRepository.UPDATE_PATH)
|
||||
setJsonBody(TokenRequest(refreshToken))
|
||||
}
|
||||
return result.body()
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package ru.shadowsparky.vbox.shared.data.token
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.KeyValueStorage
|
||||
import ru.shadowsparky.http.domain.TokenInfo
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
|
||||
@Factory
|
||||
class TokenStorageImpl(
|
||||
private val storage: KeyValueStorage
|
||||
) : TokenStorage {
|
||||
private val auth = storage.get(KEY)
|
||||
private val refresh = storage.get(REFRESH_TOKEN)
|
||||
|
||||
override val realm = "server side data storage"
|
||||
|
||||
override val token: Flow<TokenInfo?> = auth.combine(refresh) { auth, refresh ->
|
||||
auth ?: return@combine null
|
||||
TokenInfo(auth, refresh)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
override suspend fun updateToken(newToken: TokenInfo) {
|
||||
storage.put(KEY, newToken.token)
|
||||
newToken.refresh?.let { storage.put(REFRESH_TOKEN, it) }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
storage.remove(KEY)
|
||||
storage.remove(REFRESH_TOKEN)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY = "auth_token"
|
||||
const val REFRESH_TOKEN = "refresh_token"
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package ru.shadowsparky.vbox.shared.di
|
||||
|
||||
object DbType {
|
||||
const val REMOTE = "remote"
|
||||
const val LOCAL = "local"
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package ru.shadowsparky.vbox.shared.di
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.shared.data.RemoteEventListenerFactory
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||
|
||||
@Module
|
||||
class EventsModule {
|
||||
|
||||
@Factory
|
||||
@Named(EventType.RECENT)
|
||||
fun provideRecentRemoteEventListener(
|
||||
factory: RemoteEventListenerFactory
|
||||
): RemoteEventListener {
|
||||
return factory.create(RemoteEventHandler.RECENTLY_CHANGED)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(EventType.SEARCH)
|
||||
fun provideSearchRemoteEventListener(
|
||||
factory: RemoteEventListenerFactory
|
||||
): RemoteEventListener {
|
||||
return factory.create(RemoteEventHandler.SEARCH_CHANGED)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(EventType.SAVED)
|
||||
fun provideSavedRemoteEventListener(
|
||||
factory: RemoteEventListenerFactory
|
||||
): RemoteEventListener {
|
||||
return factory.create(RemoteEventHandler.SAVED_CHANGED)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(EventType.MOVIE_TAG)
|
||||
fun provideMovieTagRemoteEventListener(
|
||||
factory: RemoteEventListenerFactory
|
||||
): RemoteEventListener {
|
||||
return factory.create(RemoteEventHandler.MOVIE_TAG_CHANGED)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(EventType.USER_TAG)
|
||||
fun provideUserTagRemoteEventListener(
|
||||
factory: RemoteEventListenerFactory
|
||||
): RemoteEventListener {
|
||||
return factory.create(RemoteEventHandler.USER_TAG_CHANGED)
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package ru.shadowsparky.vbox.shared.di
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.data.api.FallbackVideoApi
|
||||
import ru.shadowsparky.vbox.shared.data.api.PosterWithoutSuffixVideoApi
|
||||
import ru.shadowsparky.vbox.shared.data.api.VideoApiImpl
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
|
||||
@Module
|
||||
class HttpModule {
|
||||
|
||||
@Single
|
||||
fun provideVideoApi(
|
||||
httpClient: HttpClient,
|
||||
serverConfigurationProvider: ServerConfigurationRepository,
|
||||
json: Json,
|
||||
dbProvider: AppDatabaseProvider,
|
||||
dispatcherProvider: DispatcherProvider
|
||||
): VideoApi {
|
||||
val impl = VideoApiImpl(httpClient, serverConfigurationProvider)
|
||||
return PosterWithoutSuffixVideoApi(
|
||||
FallbackVideoApi(dbProvider, impl, json, dispatcherProvider)
|
||||
)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package ru.shadowsparky.vbox.shared.di
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
|
||||
import ru.shadowsparky.vbox.shared.data.saved.PosterWithoutSuffixSavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.data.saved.RemoteSavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.data.saved.SqlSavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
|
||||
@Module
|
||||
class SavedModule {
|
||||
|
||||
@Factory
|
||||
@Named(DbType.LOCAL)
|
||||
fun provideSavedLocalRepository(
|
||||
dbProvider: AppDatabaseProvider,
|
||||
dispatcherProvider: DispatcherProvider
|
||||
): SavedMovieRepository {
|
||||
val sql = SqlSavedMovieRepository(dbProvider, dispatcherProvider)
|
||||
return PosterWithoutSuffixSavedMovieRepository(sql)
|
||||
}
|
||||
|
||||
@Factory
|
||||
@Named(DbType.REMOTE)
|
||||
fun provideSavedRemoteRepository(
|
||||
httpClient: HttpClient,
|
||||
serverConfigurationRepository: ServerConfigurationRepository,
|
||||
@Named(EventType.SAVED) eventListener: RemoteEventListener
|
||||
): SavedMovieRepository {
|
||||
val impl = RemoteSavedMovieRepository(httpClient, serverConfigurationRepository, eventListener)
|
||||
return PosterWithoutSuffixSavedMovieRepository(impl)
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.vbox.shared.di.factory
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
|
||||
interface DatabaseDriverFactory {
|
||||
suspend fun createDriver(): SqlDriver
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.vbox.shared.di.factory
|
||||
|
||||
import com.seiko.imageloader.ImageLoader
|
||||
|
||||
interface ImageLoaderProvider {
|
||||
fun provide(): ImageLoader
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
|
||||
@Factory
|
||||
class AddOfflineVideoUseCase(private val offlineStorage: OfflineStorage?) {
|
||||
|
||||
suspend fun add(item: OfflineVideo, uri: String) {
|
||||
offlineStorage ?: return
|
||||
val old = offlineStorage.find(item.movieId, item.seasonId, item.episodeId)
|
||||
if (old == null) {
|
||||
offlineStorage.add(item, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
interface AggregateSyncStrategy {
|
||||
val aggregateType: AggregateType
|
||||
|
||||
suspend fun execute(actionType: String, payload: JsonElement)
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.http.domain.hasAccount
|
||||
|
||||
suspend fun TokenStorage.use(
|
||||
active: suspend () -> Unit,
|
||||
fallback: suspend () -> Unit
|
||||
) {
|
||||
if (hasAccount()) {
|
||||
runCatching { active() }.onFailure { fallback() }
|
||||
} else {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> TokenStorage.queryFlow(
|
||||
active: Flow<T>,
|
||||
fallback: Flow<T>,
|
||||
init: suspend () -> Unit = {}
|
||||
): Flow<T> {
|
||||
return flow {
|
||||
init()
|
||||
emitAll(if (hasAccount()) {
|
||||
active.catch { emitAll(fallback) }
|
||||
} else {
|
||||
fallback
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> TokenStorage.query(
|
||||
active: suspend () -> T,
|
||||
fallback: suspend () -> T
|
||||
): T {
|
||||
return if (hasAccount()) {
|
||||
runCatching { active() }.getOrDefault(fallback())
|
||||
} else {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.http.domain.LogoutAction
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.vbox.shared.di.DbType
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
class LogoutUseCase(
|
||||
private val syncRepository: SyncRepository,
|
||||
@Named(DbType.LOCAL) private val localSearchRepo: SearchRepository,
|
||||
@Named(DbType.LOCAL) private val localRecentlyRepo: RecentlyWatchedRepository,
|
||||
@Named(DbType.LOCAL) private val localSavedMovieRepository: SavedMovieRepository,
|
||||
@Named(DbType.LOCAL) private val localMovieTagRepository: MovieTagRepository,
|
||||
@Named(DbType.LOCAL) private val localUserTagRepository: UserTagRepository,
|
||||
private val bearerAuthProvider: BearerAuthProvider,
|
||||
private val tokenStorage: TokenStorage,
|
||||
private val authTokenRepository: AuthTokenRepository,
|
||||
private val pendingOperationRepository: PendingOperationRepository
|
||||
) : LogoutAction {
|
||||
|
||||
override suspend fun logout() {
|
||||
tokenStorage.token.firstOrNull()?.refresh?.let {
|
||||
runCatching { authTokenRepository.revoke(TokenRequest(it)) }
|
||||
}
|
||||
tokenStorage.clear()
|
||||
localSearchRepo.clear()
|
||||
localRecentlyRepo.clear()
|
||||
localSavedMovieRepository.clear()
|
||||
bearerAuthProvider.clearToken()
|
||||
localMovieTagRepository.clear()
|
||||
localUserTagRepository.clear()
|
||||
AggregateType.entries.forEach { syncRepository.set(it, false) }
|
||||
pendingOperationRepository.clear()
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface OfflineStorage {
|
||||
val videos: Flow<List<OfflineVideo>>
|
||||
|
||||
suspend fun query(id: Long): Flow<OfflineVideo?>
|
||||
fun queryByArgs(movieId: Long, seasonId: Long? = null): Flow<List<OfflineVideo>>
|
||||
|
||||
suspend fun find(movieId: Long, seasonId: Long? = null, episodeId: Long? = null): OfflineVideo?
|
||||
suspend fun add(item: OfflineVideo, uri: String)
|
||||
suspend fun remove(item: OfflineVideo)
|
||||
}
|
||||
|
||||
data class OfflineVideo(
|
||||
val id: Long? = null,
|
||||
val movieId: Long,
|
||||
val seasonId: Long? = null,
|
||||
val episodeId: Long? = null,
|
||||
val state: OfflineState = OfflineState.Pending
|
||||
)
|
||||
|
||||
sealed interface OfflineState {
|
||||
|
||||
data object Pending : OfflineState
|
||||
|
||||
data class Downloading(
|
||||
val isPaused: Boolean,
|
||||
val progress: Int
|
||||
) : OfflineState
|
||||
|
||||
data class Error(
|
||||
val msg: String
|
||||
) : OfflineState
|
||||
|
||||
data class Available(
|
||||
val uri: String
|
||||
) : OfflineState
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Episode
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
|
||||
|
||||
@Factory
|
||||
class OfflineVideoInteractor(
|
||||
private val videoApi: VideoApi,
|
||||
private val offlineStorage: OfflineStorage?
|
||||
) {
|
||||
private val detailsCache = hashMapOf<Long, VideoDetails>()
|
||||
private val linksCache = hashMapOf<LinksKey, VideoLinksResponse>()
|
||||
|
||||
val info: Flow<List<OfflineVideoInfo>> = offlineStorage?.videos
|
||||
?.map { videos ->
|
||||
videos.groupBy { it.movieId }
|
||||
.map { info ->
|
||||
val list = info.value.groupBy { it.seasonId }
|
||||
.mapNotNull { seasonEntry ->
|
||||
val seasonId = seasonEntry.key
|
||||
val season = fetchVideoLinks(info.key, seasonId).seasons
|
||||
?.firstOrNull()
|
||||
if (seasonId == null || season == null) {
|
||||
null
|
||||
} else {
|
||||
SeasonOfflineVideo(
|
||||
seasonId,
|
||||
season.title,
|
||||
seasonEntry.value.mapNotNull { episode ->
|
||||
val links = fetchVideoLinks(episode.movieId, seasonId).seasons
|
||||
?.firstOrNull()
|
||||
?.episodes?.firstOrNull { it.episode == episode.episodeId }
|
||||
if (links == null) {
|
||||
null
|
||||
} else {
|
||||
EpisodeOfflineVideo(links, episode)
|
||||
}
|
||||
}.sortedBy { it.episode.episode }
|
||||
)
|
||||
}
|
||||
}.sortedBy { it.seasonId }
|
||||
val details = fetchDetails(info.key)
|
||||
OfflineVideoInfo(
|
||||
info.key,
|
||||
details.title,
|
||||
list,
|
||||
if (list.isEmpty()) {
|
||||
offlineStorage.find(info.key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
} ?: emptyFlow()
|
||||
|
||||
suspend fun remove(video: OfflineVideo) {
|
||||
offlineStorage?.remove(video)
|
||||
}
|
||||
|
||||
private suspend fun fetchDetails(id: Long): VideoDetails {
|
||||
detailsCache[id]?.let { return it }
|
||||
return videoApi.fetchDetails(id).apply { detailsCache[id] = this }
|
||||
}
|
||||
|
||||
private suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse {
|
||||
linksCache[LinksKey(id, seasonId)]?.let { return it }
|
||||
return videoApi.fetchVideoLinks(id, seasonId).apply {
|
||||
linksCache[LinksKey(id, seasonId)] = this
|
||||
}
|
||||
}
|
||||
|
||||
private data class LinksKey(val id: Long, val sessionId: Long?)
|
||||
}
|
||||
|
||||
data class OfflineVideoInfo(
|
||||
val movieId: Long,
|
||||
val title: String,
|
||||
val seasons: List<SeasonOfflineVideo>,
|
||||
val movieState: OfflineVideo?
|
||||
)
|
||||
|
||||
data class SeasonOfflineVideo(
|
||||
val seasonId: Long,
|
||||
val title: String,
|
||||
val episodes: List<EpisodeOfflineVideo>
|
||||
)
|
||||
|
||||
data class EpisodeOfflineVideo(
|
||||
val episode: Episode,
|
||||
val video: OfflineVideo
|
||||
)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
enum class OsType {
|
||||
JVM,
|
||||
ANDROID,
|
||||
IOS,
|
||||
JS
|
||||
}
|
||||
|
||||
expect fun getCurrentOsType(): OsType
|
||||
|
||||
val isJs: Boolean get() = getCurrentOsType() == OsType.JS
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
interface PendingOperationRepository {
|
||||
suspend fun delete(id: Long)
|
||||
suspend fun query(type: AggregateType): List<OperationInfo>
|
||||
suspend fun insert(operationInfo: OperationInfo)
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
data class OperationInfo(
|
||||
val type: AggregateType,
|
||||
val actionType: String,
|
||||
val payload: JsonElement,
|
||||
val id: Long = 0
|
||||
)
|
||||
|
||||
enum class AggregateType {
|
||||
MOVIE_TAG,
|
||||
USER_TAG,
|
||||
SEARCH,
|
||||
RECENTLY_WATCHED,
|
||||
SAVED_MOVIE
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Episode
|
||||
import ru.shadowsparky.vbox.shared.domain.model.File
|
||||
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
|
||||
import ru.shadowsparky.videoplayer.ExternalPlayer
|
||||
import ru.shadowsparky.videoplayer.PlayerController
|
||||
import ru.shadowsparky.videoplayer.PlayerEvent
|
||||
import ru.shadowsparky.videoplayer.PlayerState
|
||||
import kotlin.math.abs
|
||||
|
||||
@Factory
|
||||
class PlayerInteractor(
|
||||
private val videoApi: VideoApi,
|
||||
private val playerSettingsStorge: PlayerSettingsStorge,
|
||||
private val externalPlayer: ExternalPlayer,
|
||||
val playerController: PlayerController,
|
||||
private val recentlyWatchedRepository: RecentlyWatchedRepository,
|
||||
private val savedTimeRepository: SavedTimeRepository,
|
||||
private val offlineStorage: OfflineStorage?
|
||||
) {
|
||||
private var lastMillis = 0L
|
||||
|
||||
val state: Flow<PlayerState> = playerController.state
|
||||
.onEach {
|
||||
if (abs(it.currentPositionMs - lastMillis) > 5_000) saveTime(it)
|
||||
}
|
||||
val events: Flow<PlayerEvent> = playerController.events
|
||||
val useExternalPlayer = playerSettingsStorge.useExternalPlayer
|
||||
private val playMutex = Mutex()
|
||||
private var cache = Cache()
|
||||
|
||||
val canPlayNextEpisode: Boolean
|
||||
get() {
|
||||
val nextIndex = (cache.currentEpisodeIndex ?: return false) + 1
|
||||
return cache.episodes?.getOrNull(nextIndex) != null
|
||||
}
|
||||
|
||||
val savedMillis get() = cache.savedMillis ?: 0L
|
||||
|
||||
suspend fun play(
|
||||
movieId: Long?,
|
||||
seasonId: Long?,
|
||||
episodeId: Long?,
|
||||
startTime: Long?,
|
||||
url: String?
|
||||
): Boolean = playMutex.withLock {
|
||||
cache = cache.copy(movieId = movieId, seasonId = seasonId)
|
||||
val offlineUrl = url ?: loadOfflineUrl(movieId, seasonId, episodeId)
|
||||
return if (seasonId == null || episodeId == null) {
|
||||
val url = offlineUrl ?: fetchLinks(movieId, seasonId).files.loadUrl()
|
||||
play(url, startTime)
|
||||
} else {
|
||||
val episodes = loadEpisodes(movieId, seasonId)
|
||||
val index = episodes.indexOfFirst { it.episode == episodeId }
|
||||
if (index == -1) error("Unable to find current episode id")
|
||||
cache = cache.copy(currentEpisodeIndex = index)
|
||||
play(offlineUrl ?: episodes[index].files.loadUrl(), startTime)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadOfflineUrl(
|
||||
movieId: Long?,
|
||||
seasonId: Long?,
|
||||
episodeId: Long?,
|
||||
): String? {
|
||||
movieId ?: return null
|
||||
val isExternalPlayer = useExternalPlayer.firstOrNull() ?: true
|
||||
return if (!isExternalPlayer) {
|
||||
val state = offlineStorage?.find(movieId, seasonId, episodeId)?.state
|
||||
(state as? OfflineState.Available)?.uri
|
||||
} else null
|
||||
}
|
||||
|
||||
suspend fun playNext(): Boolean {
|
||||
if (playMutex.isLocked) return true
|
||||
playMutex.withLock {
|
||||
val nextIndex = (cache.currentEpisodeIndex ?: return false) + 1
|
||||
val nextEpisode = cache.episodes?.getOrNull(nextIndex) ?: return false
|
||||
cache = cache.copy(currentEpisodeIndex = nextIndex)
|
||||
return play(
|
||||
loadOfflineUrl(
|
||||
cache.movieId ?: return false,
|
||||
cache.seasonId,
|
||||
nextEpisode.episode
|
||||
) ?: nextEpisode.files.loadUrl(), 0L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveWatched(episodeId: Long) {
|
||||
recentlyWatchedRepository.write(
|
||||
RecentlyWatchedInfo(
|
||||
cache.movieId ?: return,
|
||||
episodeId,
|
||||
cache.seasonId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun release() {
|
||||
playerController.release()
|
||||
}
|
||||
|
||||
suspend fun pause() {
|
||||
playerController.pause()
|
||||
}
|
||||
|
||||
private suspend fun saveTime(state: PlayerState) {
|
||||
with(cache) {
|
||||
movieId?.let {
|
||||
savedTimeRepository.setTime(
|
||||
SavedTimeInfo(
|
||||
movieId,
|
||||
seasonId,
|
||||
currentEpisodeIndex?.let { episodes?.getOrNull(it) }?.episode,
|
||||
state.currentPositionMs
|
||||
)
|
||||
)
|
||||
lastMillis = state.currentPositionMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<File>?.loadUrl(): String {
|
||||
return this?.firstOrNull()?.url ?: error("Unable to load video url")
|
||||
}
|
||||
|
||||
private suspend fun play(url: String, startTime: Long?): Boolean {
|
||||
cache.episodes?.let {
|
||||
cache.currentEpisodeIndex?.let { idx -> saveWatched(it[idx].episode) }
|
||||
}
|
||||
if (useExternalPlayer.firstOrNull() ?: true) {
|
||||
externalPlayer.play(url)
|
||||
return false
|
||||
} else {
|
||||
loadTime()
|
||||
playerController.load(url, startTime ?: state.first().currentPositionMs)
|
||||
playerController.play()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun loadTime() {
|
||||
with(cache) {
|
||||
val millis = movieId?.let {
|
||||
savedTimeRepository.query(
|
||||
movieId,
|
||||
seasonId,
|
||||
currentEpisodeIndex?.let { episodes?.getOrNull(it) }?.episode
|
||||
)
|
||||
}
|
||||
cache = cache.copy(savedMillis = millis)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadEpisodes(movieId: Long?, seasonId: Long): List<Episode> {
|
||||
cache.episodes?.let { return it }
|
||||
checkNotNull(movieId) { "movieId required" }
|
||||
val links = videoApi.fetchVideoLinks(movieId, seasonId)
|
||||
val season = links.seasons?.firstOrNull()
|
||||
return season?.episodes.apply {
|
||||
cache = cache.copy(episodes = this)
|
||||
} ?: error("Unable to load episodes")
|
||||
}
|
||||
|
||||
private suspend fun fetchLinks(movieId: Long?, seasonId: Long?): VideoLinksResponse {
|
||||
cache.response?.let { return it }
|
||||
checkNotNull(movieId) { "movieId required" }
|
||||
return videoApi.fetchVideoLinks(movieId, seasonId).apply {
|
||||
cache = cache.copy(response = this)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Cache(
|
||||
val movieId: Long? = null,
|
||||
val seasonId: Long? = null,
|
||||
val episodes: List<Episode>? = null,
|
||||
val currentEpisodeIndex: Int? = null,
|
||||
val response: VideoLinksResponse? = null,
|
||||
val savedMillis: Long? = null
|
||||
)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface PlayerSettingsStorge {
|
||||
val useExternalPlayer: Flow<Boolean?>
|
||||
|
||||
suspend fun setUseExternalPlayer(use: Boolean)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
data class SavedTimeInfo(
|
||||
val movieId: Long,
|
||||
val seasonId: Long?,
|
||||
val episodeId: Long?,
|
||||
val time: Long
|
||||
)
|
||||
|
||||
interface SavedTimeRepository {
|
||||
suspend fun query(movieId: Long, seasonId: Long?, episodeId: Long?): Long?
|
||||
suspend fun setTime(time: SavedTimeInfo)
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
interface ServerConfigurationRepository {
|
||||
val serverConfiguration: Flow<ServerConfiguration>
|
||||
|
||||
suspend fun setServerConfiguration(conf: ServerConfiguration)
|
||||
}
|
||||
|
||||
suspend fun ServerConfigurationRepository.getServerConfiguration(): ServerConfiguration {
|
||||
return serverConfiguration.first()
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
interface SyncRepository {
|
||||
suspend fun isSynchronized(type: AggregateType): Boolean
|
||||
suspend fun set(type: AggregateType, state: Boolean)
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.seiko.imageloader.LocalImageLoader
|
||||
import ru.shadowsparky.koin
|
||||
import ru.shadowsparky.ui.components.BackButton
|
||||
import ru.shadowsparky.ui.nav.Nav
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.VideoScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.loading.LoadingScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Child
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
import ru.shadowsparky.vbox.shared.presentation.offline.OfflineVideoScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.overview.OverviewScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.overview.episode.EpisodesOverviewScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.overview.season.SeasonOverviewScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.player.PlayerScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.auth.AuthScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.pass.EditPasswordScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.register.RegisterScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.serv.EditServScreen
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.tag.TagScreen
|
||||
|
||||
@Composable
|
||||
fun getColorScheme(): ColorScheme {
|
||||
return ru.shadowsparky.ui.theme.getColorScheme()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.imePaddingInternal(): Modifier {
|
||||
return koin.get<UiConfiguration>().imePadding(this)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Nav(rootComponent: RootComponent) {
|
||||
CompositionLocalProvider(
|
||||
LocalImageLoader provides remember { rootComponent.imageLoaderProvider.provide() }
|
||||
) { NavInternal(rootComponent) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavInternal(rootComponent: RootComponent) {
|
||||
Nav(rootComponent) { child, route ->
|
||||
when (child) {
|
||||
is Child.PlayerChild -> PlayerScreen(child.component)
|
||||
is Child.OverviewChild -> OverviewScreen(child.component)
|
||||
is Child.SeasonOverviewChild -> SeasonOverviewScreen(child.component)
|
||||
is Child.EpisodeOverviewChild -> EpisodesOverviewScreen(child.component)
|
||||
is Child.EditServerAddrChild -> EditServScreen(child.component)
|
||||
is Child.TagChild -> TagScreen(child.component)
|
||||
is Child.AuthChild -> AuthScreen(child.component)
|
||||
is Child.RegisterChild -> RegisterScreen(child.component)
|
||||
is Child.EditPasswordChild -> EditPasswordScreen(child.component)
|
||||
is Child.LoadingChild -> LoadingScreen(child.component)
|
||||
is Child.OfflineVideoChild -> OfflineVideoScreen(child.component)
|
||||
is Child.VideosChild,
|
||||
is Child.SearchChild,
|
||||
is Child.SettingsChild,
|
||||
is Child.SavedMovieChild -> {
|
||||
if (child is Child.VideosChild && child.component.search != null) {
|
||||
VideoScreen(child.component)
|
||||
} else {
|
||||
MainScreen(
|
||||
route as Route,
|
||||
child,
|
||||
rootComponent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SimpleAppBar(
|
||||
title: String,
|
||||
doBack: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
doBack?.let { BackButton(it) }
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BaseScreen(
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
snackbarState: SnackbarHostState? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (padding: PaddingValues) -> Unit,
|
||||
appBar: @Composable () -> Unit
|
||||
) {
|
||||
ru.shadowsparky.ui.BaseScreen(
|
||||
modifier,
|
||||
scrollBehavior,
|
||||
snackbarState,
|
||||
getColorScheme(),
|
||||
content = content,
|
||||
appBar = { appBar() }
|
||||
)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.Res
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.home
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.recommendations
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.saved_movies
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.search
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.settings
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.star
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.shadowsparky.ui.NavigationEntry
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
|
||||
@Composable
|
||||
fun createNavigationList(): List<NavigationEntry<Route>> {
|
||||
val list = listOf(
|
||||
NavigationEntry(
|
||||
Route.default,
|
||||
stringResource(Res.string.recommendations),
|
||||
painterResource(Res.drawable.home)
|
||||
),
|
||||
NavigationEntry(
|
||||
Route.Bookmark(),
|
||||
stringResource(Res.string.saved_movies),
|
||||
painterResource(Res.drawable.star)
|
||||
),
|
||||
NavigationEntry(
|
||||
Route.SearchVideos(),
|
||||
stringResource(Res.string.search),
|
||||
painterResource(Res.drawable.search)
|
||||
),
|
||||
NavigationEntry(
|
||||
Route.Settings(),
|
||||
stringResource(Res.string.settings),
|
||||
painterResource(Res.drawable.settings)
|
||||
)
|
||||
)
|
||||
return list
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.Res
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.url_copied
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun getClipboardText(): String {
|
||||
return stringResource(Res.string.url_copied)
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.Res
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.server_not_responding
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.unknown_error
|
||||
import com.arkivanov.essenty.lifecycle.LifecycleOwner
|
||||
import com.arkivanov.essenty.lifecycle.doOnDestroy
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.io.IOException
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import ru.shadowsparky.domain.Log
|
||||
import ru.shadowsparky.http.domain.HttpException
|
||||
import ru.shadowsparky.koin
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
|
||||
fun LifecycleOwner.createComponentScope(onDestroy: suspend () -> Unit = {}): CoroutineScope {
|
||||
val main = koin.getOrNull<DispatcherProvider>()?.main ?: Dispatchers.Main
|
||||
val scope = CoroutineScope(SupervisorJob() + main)
|
||||
lifecycle.doOnDestroy {
|
||||
scope.launch {
|
||||
onDestroy()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
fun createExceptionHandler(
|
||||
scope: CoroutineScope,
|
||||
onError: suspend (String) -> Unit
|
||||
): CoroutineExceptionHandler {
|
||||
return CoroutineExceptionHandler { _, throwable ->
|
||||
scope.launch {
|
||||
koin.getOrNull<Log>()?.e("Error", throwable)
|
||||
when (throwable) {
|
||||
is HttpException -> {
|
||||
onError(throwable.message ?: throwable.httpCode.toString())
|
||||
}
|
||||
|
||||
else -> {
|
||||
val code = when (throwable) {
|
||||
is IOException -> Res.string.server_not_responding
|
||||
else -> Res.string.unknown_error
|
||||
}
|
||||
onError(getString(code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import ru.shadowsparky.ui.BaseScreen
|
||||
import ru.shadowsparky.ui.LocalBlurState
|
||||
import ru.shadowsparky.ui.USE_EXPERIMENTAL_RAIL
|
||||
import ru.shadowsparky.ui.applyBlurEffect
|
||||
import ru.shadowsparky.ui.currentWindowType
|
||||
import ru.shadowsparky.ui.isCompact
|
||||
import ru.shadowsparky.ui.isExpanded
|
||||
import ru.shadowsparky.ui.rememberBlurState
|
||||
import ru.shadowsparky.ui.theme.ComponentDefaults
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.VideoContent
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Child
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
import ru.shadowsparky.vbox.shared.presentation.saved.SavedMovieContent
|
||||
import ru.shadowsparky.vbox.shared.presentation.search.SearchAppBar
|
||||
import ru.shadowsparky.vbox.shared.presentation.search.SearchVideoContent
|
||||
import ru.shadowsparky.vbox.shared.presentation.settings.SettingsContent
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
route: Route,
|
||||
child: Child,
|
||||
rootComponent: RootComponent
|
||||
) {
|
||||
if (child is Child.SearchChild) {
|
||||
SearchScreenInternal(child, route, rootComponent)
|
||||
} else {
|
||||
MainScreenInternal(route, child, rootComponent)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SearchScreenInternal(
|
||||
child: Child.SearchChild,
|
||||
route: Route,
|
||||
rootComponent: RootComponent,
|
||||
) {
|
||||
val component = child.component
|
||||
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue())
|
||||
}
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val requester by remember { mutableStateOf(FocusRequester()) }
|
||||
val windowType = currentWindowType()
|
||||
val searchAppBar: (@Composable () -> Unit) = {
|
||||
SearchAppBar(
|
||||
textFieldValue = textFieldValue,
|
||||
onDone = { component.done(textFieldValue.text) },
|
||||
onValueChange = { textFieldValue = it },
|
||||
focusRequester = requester
|
||||
)
|
||||
}
|
||||
val blurState = rememberBlurState()
|
||||
CompositionLocalProvider(LocalBlurState provides blurState) {
|
||||
BaseScreen(
|
||||
appBar = {
|
||||
if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) searchAppBar()
|
||||
},
|
||||
currentRoute = route,
|
||||
onSelectNavigation = { rootComponent.nav(it, true) },
|
||||
navigationEntries = createNavigationList(),
|
||||
colorScheme = getColorScheme(),
|
||||
scrollBehavior = scrollBehavior
|
||||
) {
|
||||
val content: @Composable () -> Unit = {
|
||||
SearchVideoContent(
|
||||
child.component,
|
||||
it,
|
||||
requester
|
||||
) { textFieldValue = it }
|
||||
}
|
||||
if (windowType.isExpanded && USE_EXPERIMENTAL_RAIL) {
|
||||
Column {
|
||||
searchAppBar()
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreenInternal(route: Route, child: Child, rootComponent: RootComponent) {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
val navList = createNavigationList()
|
||||
val windowType = currentWindowType()
|
||||
val blurState = rememberBlurState()
|
||||
CompositionLocalProvider(LocalBlurState provides blurState) {
|
||||
BaseScreen(
|
||||
appBar = {
|
||||
if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) {
|
||||
navList.firstOrNull { it.route == route }?.title?.let {
|
||||
LargeTopAppBar(
|
||||
title = { Text(it) },
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = Modifier.applyBlurEffect(blurState),
|
||||
colors = ComponentDefaults.topAppBarColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
currentRoute = route,
|
||||
onSelectNavigation = { rootComponent.navReplace(it) },
|
||||
navigationEntries = navList,
|
||||
colorScheme = getColorScheme(),
|
||||
scrollBehavior = scrollBehavior
|
||||
) { padding ->
|
||||
when (child) {
|
||||
is Child.VideosChild -> VideoContent(child.component, padding)
|
||||
is Child.SettingsChild -> SettingsContent(child.component, padding)
|
||||
is Child.SavedMovieChild -> SavedMovieContent(child.component, padding)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.ExperimentalDecomposeApi
|
||||
import com.arkivanov.decompose.router.stack.childStackWebNavigation
|
||||
import com.arkivanov.decompose.router.webhistory.WebNavigation
|
||||
import com.arkivanov.decompose.router.webhistory.WebNavigationOwner
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.KSerializer
|
||||
import org.koin.core.Koin
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.http.domain.TokenStorage
|
||||
import ru.shadowsparky.ui.nav.BaseRootComponent
|
||||
import ru.shadowsparky.vbox.shared.di.factory.ImageLoaderProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.HeathCheck
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
|
||||
@OptIn(ExperimentalDecomposeApi::class)
|
||||
class RootComponent(
|
||||
componentContext: ComponentContext,
|
||||
tokenStorage: TokenStorage,
|
||||
override val initStack: List<Route>,
|
||||
private val healthCheck: HeathCheck,
|
||||
val imageLoaderProvider: ImageLoaderProvider,
|
||||
override val serializer: KSerializer<Route> = Route.serializer(),
|
||||
override val koin: Koin = ru.shadowsparky.koin
|
||||
) : BaseRootComponent<Route>(componentContext), WebNavigationOwner {
|
||||
val isLoggedIn = tokenStorage.token.map {
|
||||
if (it != null) {
|
||||
runCatching { healthCheck.check() }.getOrNull() ?: return@map false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override val webNavigation: WebNavigation<*> =
|
||||
childStackWebNavigation(
|
||||
navigator = nav,
|
||||
stack = childStack,
|
||||
serializer = Route.serializer(),
|
||||
pathMapper = { child ->
|
||||
UrlRoutes.build(child.configuration).path
|
||||
},
|
||||
parametersMapper = { child ->
|
||||
UrlRoutes.build(child.configuration).params
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Factory
|
||||
class RootFactory(
|
||||
private val tokenStorage: TokenStorage,
|
||||
private val heathCheck: HeathCheck,
|
||||
private val imageLoaderProvider: ImageLoaderProvider
|
||||
) {
|
||||
fun create(
|
||||
context: ComponentContext,
|
||||
initStack: Route
|
||||
): RootComponent {
|
||||
return create(context, listOf(initStack))
|
||||
}
|
||||
|
||||
fun create(
|
||||
context: ComponentContext,
|
||||
initStack: List<Route> = listOf(Route.default)
|
||||
): RootComponent {
|
||||
return RootComponent(
|
||||
context,
|
||||
tokenStorage,
|
||||
listOf(Route.Loading(next = initStack.toSet())),
|
||||
heathCheck,
|
||||
imageLoaderProvider
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
|
||||
interface ShareManager {
|
||||
fun shareDetails(details: VideoDetails)
|
||||
fun canShare(): Boolean
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class UiConfiguration {
|
||||
@Composable
|
||||
fun imePadding(modifier: Modifier): Modifier {
|
||||
return modifier
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user