init projects
@@ -0,0 +1,4 @@
|
||||
/build
|
||||
/videobox
|
||||
/bin
|
||||
back.env
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM bellsoft/liberica-openjdk-alpine:21
|
||||
|
||||
WORKDIR /usr/local/app
|
||||
COPY ./build/libs/server-fat-1.0.jar ./server-fat-1.0.jar
|
||||
EXPOSE 8181
|
||||
CMD ["java", "-jar", "server-fat-1.0.jar"]
|
||||
@@ -0,0 +1,111 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.backend.app)
|
||||
alias(libs.plugins.ktor)
|
||||
alias(libs.plugins.sqldelight)
|
||||
alias(libs.plugins.serialization)
|
||||
alias(libs.plugins.convention.koin)
|
||||
}
|
||||
|
||||
sqldelight {
|
||||
databases {
|
||||
create("AppDatabase") {
|
||||
packageName.set("ru.shadowsparky.vbox.backend")
|
||||
dialect("app.cash.sqldelight:postgresql-dialect:2.0.2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":apps:vbox:common"))
|
||||
implementation(project(":libs:backend-base"))
|
||||
implementation(project(":libs:base"))
|
||||
implementation(project(":libs:http-client"))
|
||||
|
||||
implementation(libs.ktor.server.core.jvm)
|
||||
implementation(libs.ktor.server.host.common.jvm)
|
||||
implementation(libs.ktor.server.status.pages.jvm)
|
||||
implementation(libs.ktor.server.content.negotiation.jvm)
|
||||
implementation(libs.ktor.serialization.kotlinx.json.jvm)
|
||||
implementation(libs.ktor.server.netty.jvm)
|
||||
implementation(libs.ktor.server.auth)
|
||||
implementation(libs.ktor.server.auth.jwt)
|
||||
implementation(libs.ktor.server.websockets.jvm)
|
||||
implementation(libs.ktor.server.cors)
|
||||
|
||||
implementation(libs.androidx.datastore.preferences.core)
|
||||
implementation(libs.kxml2)
|
||||
implementation(libs.jsoup)
|
||||
implementation(libs.jdbc.driver)
|
||||
implementation(libs.postgresql)
|
||||
implementation(libs.coroutines.extensions)
|
||||
|
||||
testImplementation(libs.koin.test)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get())
|
||||
targetCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get())
|
||||
}
|
||||
|
||||
group = "ru.shadowsparky.vbox"
|
||||
version = "1.0"
|
||||
|
||||
val mainClassName = "ru.shadowsparky.vbox.backend.MainKt"
|
||||
|
||||
application {
|
||||
mainClass.set(mainClassName)
|
||||
|
||||
val isDevelopment: Boolean = project.ext.has("development")
|
||||
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
|
||||
}
|
||||
|
||||
tasks {
|
||||
register("fatJar", Jar::class) {
|
||||
archiveBaseName = "server-fat"
|
||||
manifest {
|
||||
attributes["Implementation-Title"] = "Videobox Server"
|
||||
attributes["Implementation-Version"] = version
|
||||
attributes["Main-Class"] = mainClassName
|
||||
}
|
||||
from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) })
|
||||
duplicatesStrategy = DuplicatesStrategy.WARN
|
||||
with(jar.get() as CopySpec)
|
||||
}
|
||||
|
||||
"build" {
|
||||
dependsOn("fatJar")
|
||||
}
|
||||
|
||||
register<Exec>("deployRemote") {
|
||||
dependsOn("fatJar")
|
||||
commandLine(
|
||||
getCommandLine(
|
||||
"docker context use remote",
|
||||
"docker build . -t videobox-server",
|
||||
"docker context use default"
|
||||
)
|
||||
)
|
||||
notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache")
|
||||
}
|
||||
|
||||
register<Exec>("deployLocal") {
|
||||
dependsOn("fatJar")
|
||||
commandLine(
|
||||
getCommandLine(
|
||||
"docker compose down",
|
||||
"docker compose up -d --build --force-recreate"
|
||||
)
|
||||
)
|
||||
notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache")
|
||||
}
|
||||
}
|
||||
private fun getCommandLine(vararg commands: String): List<String> {
|
||||
val osName = System.getProperty("os.name").lowercase()
|
||||
return if (osName.contains("win")) {
|
||||
listOf("cmd", "/c") + commands.joinToString(" && ")
|
||||
} else {
|
||||
listOf("sh", "-c") + commands.joinToString(" && ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: always
|
||||
env_file:
|
||||
- postgres.env
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- serv-network
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
|
||||
videobox:
|
||||
container_name: videobox
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- postgres.env
|
||||
- back.env
|
||||
ports:
|
||||
- 8181:8181
|
||||
restart: always
|
||||
volumes:
|
||||
- ./videobox/cache/:/usr/local/app/cache:rw
|
||||
networks:
|
||||
- serv-network
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
networks:
|
||||
serv-network:
|
||||
driver: bridge
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,8 @@
|
||||
POSTGRES_DB=videobox
|
||||
POSTGRES_USER=login
|
||||
POSTGRES_PASSWORD=password
|
||||
|
||||
VIDEOBOX_SERVER_URI=postgres
|
||||
VIDEOBOX_SECRET=144b5446caea8d7d1a33cc87b0447f3be608f0b12bef3d130ec37264375df912468411e37b432299d7f037db81f099b4b3572d4898fa6cc154e6931d7d12f78f
|
||||
|
||||
ALLOW_REGISTRATION=1
|
||||
@@ -0,0 +1,9 @@
|
||||
# Videobox Server
|
||||
|
||||
## Deploy
|
||||
|
||||
### Remote
|
||||
|
||||
Сначала нужно добавить remote context
|
||||
`docker context create remote --docker "host=ssh://root@localhost"`.
|
||||
Далее публикация происходит таской gradle: `gradle :apps:vbox:backend:deployRemote`
|
||||
@@ -0,0 +1,35 @@
|
||||
package ru.shadowsparky.vbox.backend
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.backend.domain.JwtInfo
|
||||
import ru.shadowsparky.vbox.backend.di.DbModule
|
||||
import ru.shadowsparky.vbox.backend.di.EventModule
|
||||
import ru.shadowsparky.vbox.backend.di.HttpModule
|
||||
|
||||
@Module
|
||||
class VboxModule {
|
||||
|
||||
@Single
|
||||
fun provideJwtInfo(envFetcher: EnvFetcher): JwtInfo {
|
||||
return JwtInfo(
|
||||
envFetcher.get("VIDEOBOX_SECRET", "secret"),
|
||||
"https://shadowsparky.ru/",
|
||||
"https://box.shadowsparky.ru/",
|
||||
"server side data storage"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Module(
|
||||
includes = [
|
||||
DbModule::class,
|
||||
HttpModule::class,
|
||||
EventModule::class,
|
||||
VboxModule::class
|
||||
]
|
||||
)
|
||||
@ComponentScan
|
||||
class BackendModule
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.shadowsparky.vbox.backend
|
||||
|
||||
import io.ktor.server.application.Application
|
||||
import org.koin.core.annotation.KoinApplication
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.plugin.module.dsl.startKoin
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureJwt
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureRouting
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureSerialization
|
||||
import ru.shadowsparky.vbox.backend.presentation.configureWebSocket
|
||||
|
||||
@KoinApplication
|
||||
object VBoxBackend
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
startKoin<VBoxBackend>()
|
||||
io.ktor.server.netty.EngineMain.main(args)
|
||||
}
|
||||
|
||||
fun Application.module() {
|
||||
val koin = object : KoinComponent {}
|
||||
configureSerialization()
|
||||
configureJwt(koin.get())
|
||||
configureWebSocket(koin.get())
|
||||
configureRouting(koin.get(), koin.get())
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
||||
import java.sql.SQLException
|
||||
|
||||
class BackendRecentlyWatchedRepository(
|
||||
private val db: AppDatabase,
|
||||
private val userId: Long,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val remoteEventHandler: RemoteEventHandler
|
||||
) : RecentlyWatchedRepository {
|
||||
|
||||
override suspend fun remove(info: RecentlyWatchedInfo) =
|
||||
withContext(dispatcherProvider.io) {
|
||||
db.recentQueries.deleteRwByIds(info.movieId, info.episode, info.season, userId)
|
||||
notifyChanged(info.movieId)
|
||||
}
|
||||
|
||||
override suspend fun write(info: RecentlyWatchedInfo) =
|
||||
withContext(dispatcherProvider.io) {
|
||||
try {
|
||||
db.recentQueries.insertRecentlyWatched(
|
||||
userId,
|
||||
info.movieId,
|
||||
info.episode,
|
||||
info.season
|
||||
)
|
||||
notifyChanged(info.movieId)
|
||||
} catch (ignored: SQLException) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun toggleInfo(info: RecentlyWatchedInfo) =
|
||||
withContext(dispatcherProvider.io) {
|
||||
val hasInfo =
|
||||
db.recentQueries.selectByIds(info.movieId, info.episode, info.season, userId)
|
||||
.executeAsOneOrNull() != null
|
||||
if (hasInfo) {
|
||||
remove(info)
|
||||
} else {
|
||||
write(info)
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryRecentlyWatched(
|
||||
movieId: Long,
|
||||
seasonId: Long?
|
||||
): Flow<List<RecentlyWatchedInfo>> = flow {
|
||||
val rsp = if (seasonId == null) {
|
||||
db.recentQueries.selectByMovieId(movieId, userId)
|
||||
.executeAsList()
|
||||
.map {
|
||||
RecentlyWatchedInfo(
|
||||
it.movie_id,
|
||||
it.episode,
|
||||
it.season
|
||||
)
|
||||
}
|
||||
} else {
|
||||
db.recentQueries.selectByMovieIdAndSeason(movieId, userId, seasonId)
|
||||
.executeAsList()
|
||||
.map {
|
||||
RecentlyWatchedInfo(
|
||||
it.movie_id,
|
||||
it.episode,
|
||||
seasonId
|
||||
)
|
||||
}
|
||||
}
|
||||
emit(rsp)
|
||||
}
|
||||
|
||||
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
|
||||
return withContext(dispatcherProvider.io) {
|
||||
db.recentQueries.selectAll(userId).executeAsList()
|
||||
.map {
|
||||
RecentlyWatchedInfo(
|
||||
it.movie_id,
|
||||
it.episode,
|
||||
it.season
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun notifyChanged(seasonId: Long) {
|
||||
remoteEventHandler.notify(
|
||||
RemoteEvent.OnRecent(
|
||||
System.currentTimeMillis(),
|
||||
userId,
|
||||
seasonId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
|
||||
val eventLogger: Logger = LoggerFactory.getLogger("events")
|
||||
|
||||
@Factory
|
||||
class BackendRemoteEventHandler(
|
||||
private val json: Json,
|
||||
private val sessionCache: SessionCache
|
||||
) : RemoteEventHandler {
|
||||
|
||||
suspend fun put(userId: Long, socketSession: SessionCache.Writer) {
|
||||
sessionCache.put(userId, socketSession)
|
||||
}
|
||||
|
||||
suspend fun remove(userId: Long, socketSession: SessionCache.Writer) {
|
||||
sessionCache.remove(userId, socketSession)
|
||||
}
|
||||
|
||||
override suspend fun notify(eventInfo: RemoteEvent) {
|
||||
val json = json.encodeToString(eventInfo)
|
||||
val writers = sessionCache.get(eventInfo.userId)
|
||||
if (writers.isNullOrEmpty()) {
|
||||
eventLogger.info("unable to notify $eventInfo. sessions not found, cache $sessionCache")
|
||||
} else {
|
||||
writers.forEach {
|
||||
eventLogger.info("notify[$eventInfo]. session $it", RuntimeException("called"))
|
||||
try {
|
||||
it.writeText(json)
|
||||
} catch (_: Exception) {
|
||||
eventLogger.error("unable to notify $it. delete session")
|
||||
remove(eventInfo.userId, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.shadowsparky.backend.data.Logger
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.server.Saved_movie
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import java.sql.SQLException
|
||||
|
||||
class BackendSavedMovieRepository(
|
||||
private val db: AppDatabase,
|
||||
private val userId: Long,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val logger: Logger,
|
||||
private val eventHandler: RemoteEventHandler
|
||||
) : SavedMovieRepository {
|
||||
|
||||
override fun getAll(): Flow<List<VideoDetails>> {
|
||||
val request = db.saved_movieQueries.selectByUserId(userId)
|
||||
return flow {
|
||||
emit(request.executeAsList().parse().reversed())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun List<Saved_movie>.parse(): List<VideoDetails> =
|
||||
withContext(dispatcherProvider.io) {
|
||||
mapNotNull {
|
||||
val movie = db.movieQueries.selectMovie(it.movie_id)
|
||||
.executeAsOneOrNull() ?: return@mapNotNull null
|
||||
VideoDetails(
|
||||
movie.movie_id,
|
||||
movie.poster_url,
|
||||
movie.description,
|
||||
movie.title,
|
||||
(movie.flags and SERIAL_FLAG) != 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun save(details: VideoDetails) {
|
||||
logger.debug(TAG, "save(${details.id}) ${details.title}")
|
||||
withContext(dispatcherProvider.io) {
|
||||
try {
|
||||
db.saved_movieQueries.addSavedMovie(
|
||||
details.id,
|
||||
userId
|
||||
)
|
||||
eventHandler.notify(RemoteEvent.OnSaved(userId, details.id))
|
||||
} catch (_: SQLException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isSaved(id: Long): Flow<Boolean> {
|
||||
return flow {
|
||||
val request = db.saved_movieQueries.selectByUserIdAndMovieId(userId, id)
|
||||
emit(request.executeAsOneOrNull() != null)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(id: Long) {
|
||||
logger.debug(TAG, "remove(${id})")
|
||||
withContext(dispatcherProvider.io) {
|
||||
db.saved_movieQueries.removeSavedMovie(userId, id)
|
||||
eventHandler.notify(RemoteEvent.OnSaved(userId, id))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "BackendSavedMovieRepository"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||
import java.sql.SQLException
|
||||
|
||||
class BackendSearchRepository(
|
||||
private val db: AppDatabase,
|
||||
private val userId: Long,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val eventHandler: RemoteEventHandler
|
||||
) : SearchRepository {
|
||||
|
||||
override fun search(query: String): Flow<List<String>> {
|
||||
return flow { emit(searchSingle(query)) }
|
||||
}
|
||||
|
||||
private suspend fun searchSingle(query: String): List<String> = withContext(Dispatchers.IO) {
|
||||
if (query.trim().isEmpty()) {
|
||||
db.searchQueries.selectAll(userId)
|
||||
} else {
|
||||
db.searchQueries.selectByQuery(query.addWilcard(), userId)
|
||||
}.executeAsList().map { it.query }
|
||||
}
|
||||
|
||||
private fun String.addWilcard(): String {
|
||||
return if (this.endsWith("%")) this else "$this%"
|
||||
}
|
||||
|
||||
override suspend fun addToSearch(query: String) {
|
||||
withContext(dispatcherProvider.io) {
|
||||
try {
|
||||
db.searchQueries.insertSearchInfo(query, userId)
|
||||
notifyChanged()
|
||||
} catch (_: SQLException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteFromSearch(query: String) {
|
||||
withContext(dispatcherProvider.io) {
|
||||
db.searchQueries.deleteSearchInfo(query, userId)
|
||||
notifyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
withContext(dispatcherProvider.io) {
|
||||
db.searchQueries.deleteAll(userId)
|
||||
notifyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun notifyChanged() {
|
||||
eventHandler.notify(RemoteEvent.OnSearch(System.currentTimeMillis(), userId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package ru.shadowsparky.vbox.backend.data
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Factory
|
||||
import java.util.Collections
|
||||
|
||||
typealias SessionMap = MutableMap<Long, List<SessionCache.Writer>>
|
||||
|
||||
@Factory
|
||||
class SessionCache {
|
||||
private val sessionMap: SessionMap = Collections.synchronizedMap(hashMapOf())
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun put(userId: Long, socketSession: Writer) {
|
||||
eventLogger.info("put[$userId]=$socketSession")
|
||||
mutex.withLock {
|
||||
sessionMap[userId] = (sessionMap[userId] ?: mutableListOf()) + listOf(socketSession)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(userId: Long): List<Writer>? {
|
||||
mutex.withLock {
|
||||
return sessionMap[userId]
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun remove(userId: Long, session: Writer) {
|
||||
eventLogger.info("remove[$userId]=$session")
|
||||
mutex.withLock {
|
||||
val sessionFromMap = sessionMap[userId]?.toMutableList()
|
||||
val item = sessionFromMap?.firstOrNull { it == session } ?: return
|
||||
sessionFromMap.remove(item)
|
||||
sessionMap[userId] = sessionFromMap
|
||||
}
|
||||
}
|
||||
|
||||
fun interface Writer {
|
||||
suspend fun writeText(text: String)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package ru.shadowsparky.vbox.backend.data.auth
|
||||
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.backend.data.JwtPreparer
|
||||
import ru.shadowsparky.backend.data.StringFetcher
|
||||
import ru.shadowsparky.backend.data.StringResource
|
||||
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||
import ru.shadowsparky.domain.Log
|
||||
import ru.shadowsparky.http.domain.BadRequestException
|
||||
import ru.shadowsparky.http.domain.TokenInfo
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.server.Refresh_tokens
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.TokenRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.model.LoginInfo
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.encoding.Base64
|
||||
|
||||
@Factory
|
||||
class AuthTokenRepositoryFactory(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val jwtPreparer: JwtPreparer,
|
||||
private val loginVerifier: LoginVerifier,
|
||||
private val log: Log,
|
||||
private val stringFetcher: StringFetcher,
|
||||
private val envFetcher: EnvFetcher
|
||||
) {
|
||||
|
||||
fun create(userId: Long = -1): AuthTokenRepository {
|
||||
return BackendAuthTokenRepository(
|
||||
db,
|
||||
dispatcherProvider,
|
||||
jwtPreparer,
|
||||
loginVerifier,
|
||||
userId,
|
||||
log,
|
||||
stringFetcher,
|
||||
envFetcher
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class BackendAuthTokenRepository(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val jwtPreparer: JwtPreparer,
|
||||
private val loginVerifier: LoginVerifier,
|
||||
private val userId: Long,
|
||||
private val log: Log,
|
||||
private val stringFetcher: StringFetcher,
|
||||
private val envFetcher: EnvFetcher
|
||||
) : AuthTokenRepository {
|
||||
private val random = SecureRandom()
|
||||
|
||||
override suspend fun register(loginInfo: LoginInfo): TokenInfo {
|
||||
return if (envFetcher.get("ALLOW_REGISTRATION", "0") == "1") {
|
||||
registerInternal(loginInfo)
|
||||
} else {
|
||||
throw BadRequestException(stringFetcher.get(StringResource.REG_BLOCKED))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registerInternal(loginInfo: LoginInfo): TokenInfo {
|
||||
return withContext(dispatcherProvider.io) {
|
||||
val user = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull()
|
||||
if (user != null) throw BadRequestException(stringFetcher.get(StringResource.ALREADY_REGISTERED))
|
||||
db.usersQueries.addUser(
|
||||
loginInfo.login, loginInfo.passwordHash, System.currentTimeMillis(), null
|
||||
)
|
||||
TokenInfo("")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun login(loginInfo: LoginInfo): TokenInfo =
|
||||
withContext(dispatcherProvider.io) {
|
||||
val userInfo = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull()
|
||||
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||
if (loginInfo.passwordHash != userInfo.password_hash) {
|
||||
throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||
}
|
||||
create(loginInfo.login)
|
||||
}
|
||||
|
||||
override suspend fun update(refresh: TokenRequest): TokenInfo {
|
||||
val info = db.refresh_tokensQueries.selectByHash(refresh.token.toHash())
|
||||
.executeAsOneOrNull()
|
||||
?: throw BadRequestException("Token not found")
|
||||
val userInfo = db.usersQueries.selectUserByUserId(info.user_id).executeAsOneOrNull()
|
||||
?: throw BadRequestException("User not found")
|
||||
val newTokens = create(userInfo.login)
|
||||
revokeInternal(refresh)
|
||||
return newTokens
|
||||
}
|
||||
|
||||
override suspend fun revoke(tokenRequest: TokenRequest) {
|
||||
revokeInternal(tokenRequest)
|
||||
}
|
||||
|
||||
private suspend fun revokeInternal(request: TokenRequest) {
|
||||
log.d("BackendAuthTokenRepository", "revoke ${request.token.toHash()}")
|
||||
val info = getTokenAndCheck(request)
|
||||
db.refresh_tokensQueries.revokeToken(info.token_id).await()
|
||||
}
|
||||
|
||||
private suspend fun getTokenAndCheck(tokenRequest: TokenRequest): Refresh_tokens =
|
||||
withContext(dispatcherProvider.io) {
|
||||
val info = db.refresh_tokensQueries.selectByHash(tokenRequest.token.toHash())
|
||||
.executeAsOneOrNull()
|
||||
?: throw BadRequestException("Token not found")
|
||||
if (info.revoked) throw BadRequestException("Token already revoked")
|
||||
if (info.expires_at < System.currentTimeMillis()) {
|
||||
throw BadRequestException(
|
||||
"Refresh token expired. " +
|
||||
"Expires at ${info.expires_at} < ${System.currentTimeMillis()}"
|
||||
)
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
private suspend fun create(login: String): TokenInfo = withContext(dispatcherProvider.io) {
|
||||
loginVerifier.verify(login)
|
||||
val userInfo = db.usersQueries.selectUserByLogin(login).executeAsOneOrNull()
|
||||
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||
val rsp = jwtPreparer.prepare(login, userInfo.user_id)
|
||||
val refresh = ByteArray(32)
|
||||
random.nextBytes(refresh)
|
||||
val refreshStr = refresh.toHexString()
|
||||
db.refresh_tokensQueries.insertToken(
|
||||
userInfo.user_id,
|
||||
refreshStr.toHash(),
|
||||
System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30)
|
||||
).executeAsOneOrNull()
|
||||
log.d(
|
||||
"BackendAuthTokenRepository",
|
||||
"${refreshStr.toHash()} created for user ${userInfo.user_id}"
|
||||
)
|
||||
TokenInfo(rsp, refreshStr)
|
||||
}
|
||||
|
||||
override suspend fun changePassword(request: ChangePasswordRequest): Unit =
|
||||
withContext(dispatcherProvider.io) {
|
||||
val user = db.usersQueries.selectUserByUserId(userId).executeAsOneOrNull()
|
||||
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||
if (user.password_hash != request.oldPasswordHash) {
|
||||
throw BadRequestException(stringFetcher.get(StringResource.INVALID_PASSWORD))
|
||||
} else if (user.password_hash == request.newPasswordHash) {
|
||||
throw BadRequestException(stringFetcher.get(StringResource.NO_CHANGES_PASS))
|
||||
}
|
||||
db.usersQueries.updatePassword(request.newPasswordHash, userId).await()
|
||||
}
|
||||
|
||||
private fun String.toHash(): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(SALT.toByteArray())
|
||||
val result = md.digest(this.toByteArray())
|
||||
return Base64.encode(result)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SALT = "G0yCPoy7gCj1a5OxPaeYLeJm69NvPQ50HbV0ZVYVqahUPdKun4MKgg86u9HbWq2e"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.shadowsparky.vbox.backend.data.auth
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.backend.data.StringFetcher
|
||||
import ru.shadowsparky.backend.data.StringResource
|
||||
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||
import ru.shadowsparky.backend.domain.VerifyTokenException
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.domain.FLAG_USER_BLOCKED
|
||||
|
||||
@Factory
|
||||
class BackendLoginVerifier(
|
||||
private val db: AppDatabase,
|
||||
private val stringFetcher: StringFetcher
|
||||
) : LoginVerifier {
|
||||
override suspend fun verify(login: String) {
|
||||
val user = db.usersQueries.selectUserByLogin(login)
|
||||
.executeAsOneOrNull()
|
||||
?: throw VerifyTokenException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||
user.flags?.let {
|
||||
if ((it and FLAG_USER_BLOCKED) != 0) {
|
||||
throw VerifyTokenException(stringFetcher.get(StringResource.USER_BLOCKED))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.backend.data.StringFetcher
|
||||
import ru.shadowsparky.backend.data.StringResource
|
||||
import ru.shadowsparky.http.domain.NotFoundException
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Season
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
|
||||
|
||||
private const val FALLBACK_SEASON_ID = -1L
|
||||
private const val SEASON_FALLBACK_NAME = "Сезон 1"
|
||||
|
||||
@Factory(binds = [ExternalBackendApi::class])
|
||||
class ExternalBackendApi(
|
||||
private val client: HttpClient,
|
||||
private val mapper: ExternalBackendMapper,
|
||||
private val cache: ExternalCache,
|
||||
private val envFetcher: EnvFetcher,
|
||||
private val stringFetcher: StringFetcher
|
||||
) : VideoApi {
|
||||
private val notFoundException =
|
||||
NotFoundException(stringFetcher.get(StringResource.VIDEO_NOT_FOUND))
|
||||
private val endpoint = envFetcher.get(BACKEND_HOST)
|
||||
|
||||
override suspend fun fetchNewVideos(query: String?): VideosResponse {
|
||||
return coroutineScope {
|
||||
val movies = async { fetchFavoritesInternal("movie", query) }
|
||||
val series = async { fetchFavoritesInternal("series", query) }
|
||||
val anime = async { fetchFavoritesInternal("anime", query) }
|
||||
val result = (series.await() + movies.await() + anime.await())
|
||||
result.map {
|
||||
async { cache.put(it) }
|
||||
}.awaitAll()
|
||||
return@coroutineScope VideosResponse(false, result, 1)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchFavoritesInternal(request: String, search: String?): List<VideoItem> {
|
||||
val rsp = client.get {
|
||||
url("https", endpoint, path = "${envFetcher.get(FAVORITES_PREFIX)}$request")
|
||||
if (search != null) {
|
||||
parameter("search", search)
|
||||
}
|
||||
}
|
||||
val rspText = rsp.bodyAsText()
|
||||
return mapper.movieRspToItems(rspText, request)
|
||||
}
|
||||
|
||||
override suspend fun fetchDetails(id: Long): VideoDetails {
|
||||
val info = cache.get(id) ?: throw notFoundException
|
||||
return VideoDetails(
|
||||
poster = mapper.mapPoster(info.poster),
|
||||
id = info.id,
|
||||
title = info.title,
|
||||
desc = info.description ?: "",
|
||||
isSerial = info.isSerial
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun fetchVideoLinks(
|
||||
id: Long,
|
||||
seasonId: Long?
|
||||
): VideoLinksResponse {
|
||||
val isSerial = cache.get(id)?.isSerial ?: throw notFoundException
|
||||
return if (isSerial) {
|
||||
val seasons = mutableListOf<Season>()
|
||||
if (seasonId == null) {
|
||||
getSerialSeasonIds(id).forEach { seasons.add(Season(emptyList(), it.id, it.title)) }
|
||||
if (seasons.isEmpty()) {
|
||||
seasons.add(Season(emptyList(), FALLBACK_SEASON_ID, SEASON_FALLBACK_NAME))
|
||||
}
|
||||
} else {
|
||||
if (seasonId == FALLBACK_SEASON_ID) {
|
||||
seasons.add(getSerialSeasonFallback(id))
|
||||
} else {
|
||||
val entry = getSerialSeasonIds(id).firstOrNull { it.id == seasonId }
|
||||
?: throw notFoundException
|
||||
seasons.add(getSeason(entry))
|
||||
}
|
||||
}
|
||||
if (seasons.isEmpty()) throw notFoundException
|
||||
VideoLinksResponse(seasons = seasons)
|
||||
} else {
|
||||
val rsp = client.get {
|
||||
url("https", endpoint, path = envFetcher.get(LINKS_PATH))
|
||||
parameter("id", id)
|
||||
parameter("movie", "")
|
||||
}.bodyAsText()
|
||||
VideoLinksResponse(files = mapper.parseMovie(rsp) ?: throw notFoundException)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSerialSeasonIds(id: Long): List<ExternalBackendMapper.SeasonEntry> {
|
||||
val rsp = client.get {
|
||||
url("https", endpoint, path = envFetcher.get(SEASON_IDS_PATH))
|
||||
parameter("id", id)
|
||||
}
|
||||
val text = rsp.bodyAsText()
|
||||
return mapper.categoryRspToSeasonInfo(text)
|
||||
}
|
||||
|
||||
private suspend fun getSerialSeasonFallback(id: Long): Season {
|
||||
val rsp = client.get {
|
||||
url("https", endpoint, path = envFetcher.get(LINKS_PATH))
|
||||
parameter("id", id)
|
||||
}
|
||||
val text = rsp.bodyAsText()
|
||||
return mapper.parseSeason(
|
||||
text,
|
||||
ExternalBackendMapper.SeasonEntry(
|
||||
SEASON_FALLBACK_NAME,
|
||||
FALLBACK_SEASON_ID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getSeason(entry: ExternalBackendMapper.SeasonEntry): Season {
|
||||
val rsp2 = client.get {
|
||||
url("https", endpoint, path = envFetcher.get(SEASON_PATH))
|
||||
parameter("id", entry.id)
|
||||
}.bodyAsText()
|
||||
return mapper.parseSeason(rsp2, entry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Episode
|
||||
import ru.shadowsparky.vbox.shared.domain.model.File
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Season
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||
import java.net.URI
|
||||
|
||||
@Factory
|
||||
class ExternalBackendMapper(
|
||||
private val envFetcher: EnvFetcher
|
||||
) {
|
||||
private val readerFactory: () -> XmlReader = { XmlReader() }
|
||||
|
||||
fun movieRspToItems(rsp: String, rootTag: String): List<VideoItem> {
|
||||
val reader = readerFactory().apply { setInput(rsp) }
|
||||
return readRoot(reader, rootTag).toItems()
|
||||
}
|
||||
|
||||
fun categoryRspToSeasonInfo(rsp: String): List<SeasonEntry> {
|
||||
val reader = readerFactory().apply { setInput(rsp) }
|
||||
val movies = readRoot(reader, "category").unit
|
||||
return movies.map { SeasonEntry(it.title!!, it.id.toLong()) }
|
||||
}
|
||||
|
||||
fun parseSeason(rsp: String, seasonEntry: SeasonEntry): Season {
|
||||
val reader = readerFactory().apply { setInput(rsp) }
|
||||
val videos = readRoot(reader, "video").unit
|
||||
val episodes = mutableListOf<Episode>()
|
||||
videos.forEachIndexed { index, hlamerUnit ->
|
||||
episodes.add(hlamerUnit.toEpisode(index + 1L))
|
||||
}
|
||||
return Season(episodes, seasonEntry.id, seasonEntry.title.parseHtml())
|
||||
}
|
||||
|
||||
fun parseMovie(rsp: String): List<File>? {
|
||||
val reader = readerFactory().apply { setInput(rsp) }
|
||||
val files = readRoot(reader, "video").unit.firstOrNull() ?: return null
|
||||
return listOf(File(false, -1, files.file?.loadMp4FromSparky() ?: return null))
|
||||
}
|
||||
|
||||
private fun ExternalUnit.toEpisode(epNumber: Long): Episode {
|
||||
val hasTitle = title?.trim()?.isNotEmpty() == true
|
||||
val splitTitle = title?.split(""")
|
||||
?.lastOrNull { it.isNotEmpty() }
|
||||
?: title?.removeSurrounding(""")
|
||||
val episodeText = "Серия"
|
||||
return Episode(
|
||||
epNumber,
|
||||
listOf(File(false, -1, file!!.loadMp4FromSparky())),
|
||||
if (hasTitle) "$epNumber. $splitTitle" else "$episodeText $epNumber"
|
||||
)
|
||||
}
|
||||
|
||||
private fun readRoot(reader: XmlReader, rootTag: String): Movie {
|
||||
reader.require(XmlReader.Tag.START, rootTag)
|
||||
val entries = mutableListOf<ExternalUnit>()
|
||||
while (reader.next != XmlReader.Tag.END) {
|
||||
if (reader.eventType != XmlReader.Tag.START) {
|
||||
continue
|
||||
}
|
||||
if (reader.name == "unit") {
|
||||
val entry = readUnitEntry(reader)
|
||||
entries.add(entry)
|
||||
}
|
||||
}
|
||||
return Movie(entries)
|
||||
}
|
||||
|
||||
private fun readUnitEntry(reader: XmlReader): ExternalUnit {
|
||||
reader.require(XmlReader.Tag.START, "unit")
|
||||
val id = reader.getAttributeValue("id")
|
||||
var title: String? = null
|
||||
var description: String? = null
|
||||
var thumb: String? = null
|
||||
var section: String? = null
|
||||
var file: String? = null
|
||||
var image: String? = null
|
||||
while (reader.next != XmlReader.Tag.END) {
|
||||
if (reader.eventType != XmlReader.Tag.START) {
|
||||
continue
|
||||
}
|
||||
when (reader.name) {
|
||||
"title" -> title = readText(reader)
|
||||
"description" -> description = readText(reader)
|
||||
"thumb" -> thumb = readText(reader)
|
||||
"section" -> section = readText(reader)
|
||||
"file" -> file = readText(reader)
|
||||
"image" -> image = readText(reader)
|
||||
else -> readText(reader) // skip
|
||||
}
|
||||
}
|
||||
return ExternalUnit(id, title, description, thumb, section, file, image)
|
||||
}
|
||||
|
||||
private fun readText(reader: XmlReader): String {
|
||||
var result = ""
|
||||
if (reader.next == XmlReader.Tag.TEXT) {
|
||||
result = reader.text
|
||||
reader.nextTag()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Movie.toItems(): List<VideoItem> {
|
||||
return unit.mapNotNull { it.toVideoItem() }
|
||||
}
|
||||
|
||||
private fun ExternalUnit.toVideoItem(): VideoItem? {
|
||||
val poster = thumb?.loadPosterFromSparky() ?: return null
|
||||
if (poster.endsWith("/_180.jpg")) {
|
||||
return null
|
||||
}
|
||||
val isMovie = section == "movie"
|
||||
return VideoItem(
|
||||
id = id.toLong(),
|
||||
title = title?.parseHtml() ?: return null,
|
||||
poster = poster,
|
||||
description = description?.parseHtml(),
|
||||
isSerial = !isMovie
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.loadMp4FromSparky(): String {
|
||||
return mapExternalToBox(this) ?: this
|
||||
}
|
||||
|
||||
private val hostRegex = Regex("""^m(\d+)\.${envFetcher.get(REGEX_HOST)}\.ru$""")
|
||||
|
||||
private fun mapExternalToBox(sourceUrl: String): String? {
|
||||
val uri = try {
|
||||
URI(sourceUrl)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
val host = uri.host ?: return null
|
||||
|
||||
val m = hostRegex.matchEntire(host) ?: return null
|
||||
val idx = m.groupValues[1]
|
||||
|
||||
val rawPath = uri.rawPath ?: return null
|
||||
if (!rawPath.startsWith("/video")) return null
|
||||
|
||||
val rest = rawPath.removePrefix("/video")
|
||||
val newPath = "/video/m$idx$rest"
|
||||
|
||||
return URI(
|
||||
"https",
|
||||
null,
|
||||
"box.shadowsparky.ru",
|
||||
uri.port,
|
||||
newPath,
|
||||
uri.rawQuery,
|
||||
uri.rawFragment
|
||||
).toString()
|
||||
}
|
||||
|
||||
fun mapPoster(poster: String): String {
|
||||
return poster.loadPosterFromSparky()
|
||||
}
|
||||
|
||||
private fun String.loadPosterFromSparky(): String {
|
||||
val imageEndpoint = envFetcher.get(IMAGE_ENDPOINT)
|
||||
if (startsWith(imageEndpoint)) {
|
||||
return replace(imageEndpoint, SPARKY_IMAGE_ENDPOINT)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
data class SeasonEntry(
|
||||
val title: String,
|
||||
val id: Long
|
||||
)
|
||||
|
||||
private data class Movie(
|
||||
val unit: List<ExternalUnit>
|
||||
)
|
||||
|
||||
private data class ExternalUnit(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
val thumb: String?,
|
||||
val section: String?,
|
||||
val file: String?,
|
||||
val image: String?
|
||||
)
|
||||
|
||||
private fun String.parseHtml(): String {
|
||||
return Jsoup.parse(this@parseHtml).wholeText()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SPARKY_IMAGE_ENDPOINT = "https://box.shadowsparky.ru/images/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.model.MOVIE_FLAG
|
||||
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||
|
||||
@Single
|
||||
class ExternalCache(
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val db: AppDatabase
|
||||
) {
|
||||
|
||||
suspend fun put(item: VideoItem) {
|
||||
insertIfNeeded(item)
|
||||
}
|
||||
|
||||
suspend fun get(id: Long): VideoItem? = queryOrNull(id)
|
||||
|
||||
private suspend fun queryOrNull(id: Long): VideoItem? {
|
||||
return withContext(dispatcherProvider.io) {
|
||||
val oldInfo = db.movieQueries.selectMovie(id)
|
||||
.executeAsOneOrNull()
|
||||
?: return@withContext null
|
||||
VideoItem(
|
||||
oldInfo.movie_id,
|
||||
oldInfo.poster_url,
|
||||
oldInfo.title,
|
||||
oldInfo.description,
|
||||
oldInfo.flags == SERIAL_FLAG
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun insertIfNeeded(new: VideoItem) = withContext(dispatcherProvider.io) {
|
||||
val old = queryOrNull(new.id)
|
||||
if (old == null) {
|
||||
db.movieQueries.insertMovie(
|
||||
new.id,
|
||||
new.poster,
|
||||
new.description ?: "",
|
||||
new.title,
|
||||
new.serializeFlags()
|
||||
)
|
||||
} else if (old != new) {
|
||||
db.movieQueries.updateMovie(
|
||||
new.poster,
|
||||
new.description ?: "",
|
||||
new.title,
|
||||
new.serializeFlags(),
|
||||
new.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun VideoItem.serializeFlags(): Int {
|
||||
return if (isSerial) {
|
||||
SERIAL_FLAG
|
||||
} else {
|
||||
MOVIE_FLAG
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
const val REGEX_HOST = "EXTERNAL_HOST"
|
||||
const val IMAGE_ENDPOINT = "EXTERNAL_IMAGE_ENDPOINT"
|
||||
const val CACHE_DIR = "EXTERNAL_CACHE_DIR"
|
||||
const val BACKEND_HOST = "EXTERNAL_BACKEND_HOST"
|
||||
const val FAVORITES_PREFIX = "EXTERNAL_FAVORITES_PREFIX"
|
||||
const val LINKS_PATH = "EXTERNAL_LINKS_PATH"
|
||||
const val SEASON_IDS_PATH = "EXTERNAL_SEASON_IDS_PATH"
|
||||
const val SEASON_PATH = "EXTERNAL_SEASON_PATH"
|
||||
@@ -0,0 +1,61 @@
|
||||
package ru.shadowsparky.vbox.backend.data.http
|
||||
|
||||
import org.kxml2.io.KXmlParser
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.StringReader
|
||||
|
||||
class XmlReader {
|
||||
private var parser: XmlPullParser? = null
|
||||
|
||||
val name: String
|
||||
get() = parser!!.name
|
||||
val eventType: Tag
|
||||
get() = parser!!.eventType.fromType()
|
||||
val next: Tag
|
||||
get() = parser!!.next().fromType()
|
||||
|
||||
fun setInput(raw: String) {
|
||||
parser = KXmlParser().apply {
|
||||
setInput(StringReader(raw))
|
||||
nextTag()
|
||||
}
|
||||
}
|
||||
|
||||
fun require(
|
||||
tag: Tag,
|
||||
name: String
|
||||
) {
|
||||
parser?.require(tag.toType(), null, name)
|
||||
}
|
||||
|
||||
fun Tag.toType(): Int {
|
||||
return when (this) {
|
||||
Tag.START -> XmlPullParser.START_TAG
|
||||
Tag.END -> XmlPullParser.END_TAG
|
||||
Tag.TEXT -> XmlPullParser.TEXT
|
||||
}
|
||||
}
|
||||
|
||||
fun Int.fromType(): Tag {
|
||||
return when (this) {
|
||||
XmlPullParser.START_TAG -> Tag.START
|
||||
XmlPullParser.END_TAG -> Tag.END
|
||||
else -> Tag.TEXT
|
||||
}
|
||||
}
|
||||
|
||||
fun getAttributeValue(name: String): String {
|
||||
return parser!!.getAttributeValue(null, name)
|
||||
}
|
||||
|
||||
fun nextTag() {
|
||||
parser!!.nextTag()
|
||||
}
|
||||
|
||||
enum class Tag {
|
||||
START, END, TEXT
|
||||
}
|
||||
|
||||
val text: String
|
||||
get() = parser!!.text
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package ru.shadowsparky.vbox.backend.data.tags
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
|
||||
@Factory
|
||||
class MovieTagRepositoryFactory(
|
||||
private val appDatabase: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@Named(EventType.MOVIE_TAG) private val movieEventHandler: RemoteEventHandler
|
||||
) {
|
||||
fun create(userId: Long): MovieTagRepository {
|
||||
return BackendMovieTagRepository(
|
||||
appDatabase,
|
||||
dispatcherProvider,
|
||||
userId,
|
||||
movieEventHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class BackendMovieTagRepository(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val userId: Long,
|
||||
private val movieEventHandler: RemoteEventHandler
|
||||
) : MovieTagRepository {
|
||||
private val dbQueries get() = db.tagsQueries
|
||||
|
||||
override fun query(movieId: Long): Flow<List<TagInfo>> = dbQueries
|
||||
.getTagsForMovie(movieId, userId)
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { list ->
|
||||
list.map { row -> TagInfo(id = row.id, tag = row.tag) }
|
||||
}
|
||||
|
||||
override fun queryMovies(tagId: Long): Flow<Set<Long>> = dbQueries
|
||||
.getMoviesByTagId(tagId, userId)
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { list -> list.toSet() }
|
||||
|
||||
override suspend fun link(movieId: Long, tagId: Long) {
|
||||
dbQueries.linkMovieTag(movieId, tagId, userId).await()
|
||||
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
|
||||
}
|
||||
|
||||
override suspend fun unlink(movieId: Long, tagId: Long) {
|
||||
dbQueries.unlinkMovieTag(movieId, tagId, userId).await()
|
||||
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package ru.shadowsparky.vbox.backend.data.tags
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
@Factory
|
||||
class UserTagRepositoryFactory(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@Named(EventType.USER_TAG) private val userTagEventHandler: RemoteEventHandler
|
||||
) {
|
||||
fun create(userId: Long): UserTagRepository {
|
||||
return BackendUserTagRepository(
|
||||
userId,
|
||||
db,
|
||||
dispatcherProvider,
|
||||
userTagEventHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class BackendUserTagRepository(
|
||||
private val userId: Long,
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val userTagEventHandler: RemoteEventHandler
|
||||
) : UserTagRepository {
|
||||
private val queries get() = db.tagsQueries
|
||||
|
||||
override val userTags: Flow<List<TagInfo>> by lazy {
|
||||
queries.getUserTagsByUserId(user_id = userId)
|
||||
.asFlow()
|
||||
.mapToList(dispatcherProvider.io)
|
||||
.map { list ->
|
||||
list.map { row -> TagInfo(id = row.id, tag = row.tag) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun add(tag: String) {
|
||||
queries.insertUserTag(userId, tag).await()
|
||||
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
||||
}
|
||||
|
||||
override suspend fun edit(id: Long, newTag: String) {
|
||||
checkOwner(id)
|
||||
queries.updateTagText(newTag, id).await()
|
||||
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
checkOwner(id)
|
||||
queries.deleteTagById(id).await()
|
||||
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
||||
}
|
||||
|
||||
private suspend fun checkOwner(id: Long) = withContext(dispatcherProvider.io) {
|
||||
val ownerId = queries.getUserIdByTagId(id).executeAsOneOrNull()
|
||||
check(ownerId == userId) { "tag owner mismatch. owner: $ownerId, current user: $userId" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ru.shadowsparky.vbox.backend.di
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.jdbc.asJdbcDriver
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.postgresql.ds.PGSimpleDataSource
|
||||
import ru.shadowsparky.backend.data.EnvFetcher
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
|
||||
@Module
|
||||
class DbModule {
|
||||
|
||||
@Factory
|
||||
fun provideSqlDriver(envFetcher: EnvFetcher): SqlDriver {
|
||||
return PGSimpleDataSource().apply {
|
||||
val db = envFetcher.get("POSTGRES_DB", "videobox")
|
||||
val uri = envFetcher.get("VIDEOBOX_SERVER_URI", "localhost")
|
||||
val login = envFetcher.get("POSTGRES_USER", "login")
|
||||
val pass = envFetcher.get("POSTGRES_PASSWORD", "password")
|
||||
|
||||
setUrl("jdbc:postgresql://$uri:5432/$db")
|
||||
user = login
|
||||
password = pass
|
||||
}.asJdbcDriver()
|
||||
}
|
||||
|
||||
@Single
|
||||
fun provideDb(driver: SqlDriver): AppDatabase {
|
||||
return AppDatabase.Companion.invoke(driver)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package ru.shadowsparky.vbox.backend.di
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.backend.data.TokenVerifier
|
||||
import ru.shadowsparky.backend.domain.JwtInfo
|
||||
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||
import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
|
||||
@Single
|
||||
class RoutingEntryPoint(
|
||||
val videoApi: VideoApi,
|
||||
val searchFactory: SearchRepositoryFactory,
|
||||
val recentlyFactory: RecentlyWatchedRepositoryFactory,
|
||||
val savedMovieFactory: SavedMovieRepositoryFactory,
|
||||
val json: Json,
|
||||
val movieTagFactory: MovieTagRepositoryFactory,
|
||||
val userTagFactory: UserTagRepositoryFactory
|
||||
)
|
||||
|
||||
@Single
|
||||
class WebSocketEntryPoint(
|
||||
@Named(EventType.SEARCH) val searchEventHandler: BackendRemoteEventHandler,
|
||||
@Named(EventType.RECENT) val recentlyEventHandler: BackendRemoteEventHandler,
|
||||
@Named(EventType.SAVED) val savedEventHandler: BackendRemoteEventHandler,
|
||||
@Named(EventType.USER_TAG) val userTagEventHandler: BackendRemoteEventHandler,
|
||||
@Named(EventType.MOVIE_TAG) val movieTagEventHandler: BackendRemoteEventHandler,
|
||||
val tokenVerifier: TokenVerifier
|
||||
)
|
||||
|
||||
@Single
|
||||
class AuthEntryPoint(
|
||||
val loginVerifier: LoginVerifier,
|
||||
val authTokenRepositoryFactory: AuthTokenRepositoryFactory,
|
||||
val jwtInfo: JwtInfo,
|
||||
val tokenVerifier: TokenVerifier
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package ru.shadowsparky.vbox.backend.di
|
||||
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
|
||||
@Module
|
||||
class EventModule {
|
||||
|
||||
@Single(binds = [BackendRemoteEventHandler::class])
|
||||
@Named(EventType.SEARCH)
|
||||
fun provideSearch(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||
|
||||
@Single(binds = [BackendRemoteEventHandler::class])
|
||||
@Named(EventType.RECENT)
|
||||
fun provideRecent(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||
|
||||
@Single(binds = [BackendRemoteEventHandler::class])
|
||||
@Named(EventType.SAVED)
|
||||
fun provideSaved(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||
|
||||
@Single(binds = [BackendRemoteEventHandler::class])
|
||||
@Named(EventType.USER_TAG)
|
||||
fun provideUserTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||
|
||||
@Single(binds = [BackendRemoteEventHandler::class])
|
||||
@Named(EventType.MOVIE_TAG)
|
||||
fun provideMovieTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.shadowsparky.vbox.backend.di
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Module
|
||||
import ru.shadowsparky.vbox.backend.data.http.ExternalBackendApi
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
|
||||
@Module
|
||||
class HttpModule {
|
||||
|
||||
@Factory
|
||||
fun provideVideoApi(impl: ExternalBackendApi): VideoApi = impl
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ru.shadowsparky.vbox.backend.di.factory
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
|
||||
@Factory
|
||||
class RecentlyWatchedRepositoryFactory(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@Named(EventType.RECENT) private val remoteEventHandler: RemoteEventHandler
|
||||
) {
|
||||
fun create(userId: Long): BackendRecentlyWatchedRepository {
|
||||
return BackendRecentlyWatchedRepository(db, userId, dispatcherProvider, remoteEventHandler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package ru.shadowsparky.vbox.backend.di.factory
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.backend.data.Logger
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||
import ru.shadowsparky.vbox.backend.data.BackendSavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
|
||||
@Factory
|
||||
class SavedMovieRepositoryFactory(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val logger: Logger,
|
||||
@Named(EventType.SAVED) private val eventHandler: BackendRemoteEventHandler
|
||||
) {
|
||||
fun create(userId: Long): SavedMovieRepository {
|
||||
return BackendSavedMovieRepository(db, userId, dispatcherProvider, logger, eventHandler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.shadowsparky.vbox.backend.di.factory
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||
import ru.shadowsparky.vbox.backend.data.BackendSearchRepository
|
||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||
|
||||
@Factory
|
||||
class SearchRepositoryFactory(
|
||||
private val db: AppDatabase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@Named(EventType.SEARCH) private val eventHandler: RemoteEventHandler
|
||||
) {
|
||||
fun create(userId: Long): SearchRepository {
|
||||
return BackendSearchRepository(db, userId, dispatcherProvider, eventHandler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.interfaces.DecodedJWT
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.auth.Authentication
|
||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||
import io.ktor.server.auth.jwt.jwt
|
||||
import io.ktor.server.response.respond
|
||||
import ru.shadowsparky.backend.domain.INVALID_TOKEN_MSG
|
||||
import ru.shadowsparky.backend.domain.LOGIN_NAME
|
||||
import ru.shadowsparky.backend.domain.VerifyTokenException
|
||||
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
|
||||
import ru.shadowsparky.vbox.shared.domain.USER_ID_ARG
|
||||
import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo
|
||||
|
||||
const val AUTH_JWT_NAME = "auth-jwt"
|
||||
|
||||
fun Application.configureJwt(authEntryPoint: AuthEntryPoint) {
|
||||
install(Authentication) {
|
||||
jwt(AUTH_JWT_NAME) {
|
||||
val jwtInfo = authEntryPoint.jwtInfo
|
||||
realm = jwtInfo.realm
|
||||
verifier(authEntryPoint.tokenVerifier.verifier)
|
||||
validate { credential ->
|
||||
val tokenRepo = authEntryPoint.loginVerifier
|
||||
val login = credential.payload.getClaim(LOGIN_NAME).asString()
|
||||
try {
|
||||
if (login != null) {
|
||||
tokenRepo.verify(login)
|
||||
if (credential.payload.expiresAt == null) {
|
||||
throw VerifyTokenException("Static tokens not supported!")
|
||||
}
|
||||
JWTPrincipal(credential.payload)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: VerifyTokenException) {
|
||||
routingLogger.error("verify token failed ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
challenge { defaultScheme, realm ->
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ServerExceptionInfo(INVALID_TOKEN_MSG)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.obtainToken(): DecodedJWT {
|
||||
val token = this?.removePrefix("Bearer ")
|
||||
?: throw VerifyTokenException(INVALID_TOKEN_MSG)
|
||||
return JWT.decode(token)
|
||||
}
|
||||
|
||||
fun ApplicationCall.obtainToken(): DecodedJWT {
|
||||
return request.headers["Authorization"].obtainToken()
|
||||
}
|
||||
|
||||
fun ApplicationCall.obtainUserId(): Long {
|
||||
return obtainToken()
|
||||
.getClaim(USER_ID_ARG)
|
||||
.asLong()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.statuspages.StatusPages
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.routing
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import ru.shadowsparky.http.domain.HttpException
|
||||
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
|
||||
import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint
|
||||
import ru.shadowsparky.vbox.backend.presentation.routing.setupAuthMethods
|
||||
import ru.shadowsparky.vbox.backend.presentation.routing.setupBaseMethods
|
||||
import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo
|
||||
|
||||
val routingLogger: Logger = LoggerFactory.getLogger("routing")
|
||||
|
||||
fun Application.configureRouting(
|
||||
routingEntryPoint: RoutingEntryPoint,
|
||||
authEntryPoint: AuthEntryPoint
|
||||
) {
|
||||
install(StatusPages) {
|
||||
exception<HttpException> { call, cause ->
|
||||
routingLogger.error("http exception occurred. returns ${cause.httpCode}", cause)
|
||||
call.respond(
|
||||
status = HttpStatusCode.fromValue(cause.httpCode),
|
||||
message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка")
|
||||
)
|
||||
}
|
||||
exception<Throwable> { call, cause ->
|
||||
routingLogger.error("error occurred. returns 500...", cause)
|
||||
call.respond(
|
||||
status = HttpStatusCode.InternalServerError,
|
||||
message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка")
|
||||
)
|
||||
}
|
||||
}
|
||||
routing {
|
||||
setupBaseMethods(routingEntryPoint.videoApi)
|
||||
setupAuthMethods(routingEntryPoint, authEntryPoint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation
|
||||
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
|
||||
fun Application.configureSerialization() {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation
|
||||
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.pingPeriod
|
||||
import io.ktor.server.websocket.timeout
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import ru.shadowsparky.backend.data.TokenVerifier
|
||||
import ru.shadowsparky.backend.domain.USER_ID_ARG
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||
import ru.shadowsparky.vbox.backend.data.SessionCache
|
||||
import ru.shadowsparky.vbox.backend.di.WebSocketEntryPoint
|
||||
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with(socketEntryPoint) {
|
||||
install(WebSockets) {
|
||||
pingPeriod = (15).seconds
|
||||
timeout = (15).seconds
|
||||
maxFrameSize = Long.MAX_VALUE
|
||||
masking = false
|
||||
}
|
||||
routing {
|
||||
hashMapOf(
|
||||
EventType.SEARCH to searchEventHandler,
|
||||
EventType.RECENT to recentlyEventHandler,
|
||||
EventType.SAVED to savedEventHandler,
|
||||
EventType.USER_TAG to userTagEventHandler,
|
||||
EventType.MOVIE_TAG to movieTagEventHandler
|
||||
).forEach { (key, handler) -> webSocket(key, handler, tokenVerifier) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.webSocket(
|
||||
eventType: String,
|
||||
handler: BackendRemoteEventHandler,
|
||||
tokenVerifier: TokenVerifier
|
||||
) {
|
||||
val path = when (eventType) {
|
||||
EventType.SEARCH -> RemoteEventHandler.SEARCH_CHANGED
|
||||
EventType.RECENT -> RemoteEventHandler.RECENTLY_CHANGED
|
||||
EventType.SAVED -> RemoteEventHandler.SAVED_CHANGED
|
||||
EventType.USER_TAG -> RemoteEventHandler.USER_TAG_CHANGED
|
||||
EventType.MOVIE_TAG -> RemoteEventHandler.MOVIE_TAG_CHANGED
|
||||
else -> error("Unsupported type $eventType")
|
||||
}
|
||||
webSocket(path) {
|
||||
val frame = (incoming.receive() as Frame.Text).readText()
|
||||
val userId = tokenVerifier.verify(frame).getClaim(USER_ID_ARG).asLong()
|
||||
val session = SessionCache.Writer { text -> outgoing.trySend(Frame.Text(text)) }
|
||||
handler.put(userId, session)
|
||||
val deferred = CompletableDeferred<Unit?>()
|
||||
try {
|
||||
outgoing.invokeOnClose { deferred.complete(null) }
|
||||
deferred.await()
|
||||
} finally {
|
||||
handler.remove(userId, session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.auth.authenticate
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Routing
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
|
||||
import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint
|
||||
import ru.shadowsparky.vbox.backend.presentation.AUTH_JWT_NAME
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.HeathCheck
|
||||
|
||||
fun Routing.setupAuthMethods(
|
||||
routingEntryPoint: RoutingEntryPoint,
|
||||
authEntryPoint: AuthEntryPoint
|
||||
) = with(routingEntryPoint) {
|
||||
val default = authEntryPoint.authTokenRepositoryFactory.create()
|
||||
post(AuthTokenRepository.REGISTER_PATH) {
|
||||
call.respond(default.register(call.receive()))
|
||||
}
|
||||
post(AuthTokenRepository.LOGIN_PATH) {
|
||||
call.respond(default.login(call.receive()))
|
||||
}
|
||||
post(AuthTokenRepository.UPDATE_PATH) {
|
||||
call.respond(default.update(call.receive()))
|
||||
}
|
||||
post(AuthTokenRepository.REVOKE_PATH) {
|
||||
default.revoke(call.receive())
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
authenticate(AUTH_JWT_NAME) {
|
||||
|
||||
get("/chech") { call.respond(HttpStatusCode.OK) }
|
||||
get(HeathCheck.PATH) { call.respond(HttpStatusCode.OK) }
|
||||
|
||||
post(AuthTokenRepository.CHANGE_PASS_PATH) {
|
||||
authEntryPoint.authTokenRepositoryFactory.create(call.obtainUserId())
|
||||
.changePassword(call.receive())
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
setupSearch(searchFactory)
|
||||
setupRecentlyWatched(recentlyFactory)
|
||||
setupSavedMovie(savedMovieFactory)
|
||||
setupTagsRouting(userTagFactory, movieTagFactory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Routing
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.util.getOrFail
|
||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||
|
||||
fun Routing.setupBaseMethods(api: VideoApi) {
|
||||
get(VideoApi.VIDEOS_PATH) {
|
||||
val p = call.parameters
|
||||
val obj = api.fetchNewVideos(p[VideoApi.SEARCH_ARG])
|
||||
call.respond(obj)
|
||||
}
|
||||
get(VideoApi.DETAILS_PATH) {
|
||||
val p = call.parameters
|
||||
call.respond(api.fetchDetails(p.getOrFail(VideoApi.ID_ARG).toLong()))
|
||||
}
|
||||
get(VideoApi.LINKS_PATH) {
|
||||
val p = call.parameters
|
||||
call.respond(
|
||||
api.fetchVideoLinks(
|
||||
p.getOrFail(VideoApi.ID_ARG).toLong(),
|
||||
p[VideoApi.SEASON_ID_ARG]?.toLong()
|
||||
)
|
||||
)
|
||||
}
|
||||
get("/") {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import kotlinx.coroutines.flow.first
|
||||
import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedResponse
|
||||
|
||||
fun Route.setupRecentlyWatched(recentlyFactory: RecentlyWatchedRepositoryFactory) {
|
||||
get(RecentlyWatchedRepository.GET_INFO) {
|
||||
val p = call.parameters
|
||||
val recentlyWatchedId = p[RecentlyWatchedRepository.MOVIE_ID_ARG]?.toLong()
|
||||
val seasonId = p[RecentlyWatchedRepository.SEASON_ID_ARG]?.toLong()
|
||||
val repo = recentlyFactory.create(call.obtainUserId())
|
||||
val list = if (recentlyWatchedId != null) {
|
||||
repo.queryRecentlyWatched(recentlyWatchedId, seasonId).first()
|
||||
} else {
|
||||
repo.getAllRecentlyWatched()
|
||||
}
|
||||
call.respond(RecentlyWatchedResponse(list))
|
||||
}
|
||||
post(RecentlyWatchedRepository.WRITE) {
|
||||
val info = call.receive<RecentlyWatchedRequest>()
|
||||
recentlyFactory.create(call.obtainUserId()).writeAll(info.info)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
post(RecentlyWatchedRepository.REMOVE) {
|
||||
val repo = recentlyFactory.create(call.obtainUserId())
|
||||
call.receive<RecentlyWatchedRequest>().info.forEach {
|
||||
repo.remove(it)
|
||||
}
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
post(RecentlyWatchedRepository.TOGGLE) {
|
||||
val repo = recentlyFactory.create(call.obtainUserId())
|
||||
call.receive<RecentlyWatchedRequest>().info.forEach {
|
||||
repo.toggleInfo(it)
|
||||
}
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.util.getOrFail
|
||||
import kotlinx.coroutines.flow.first
|
||||
import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.IsSavedResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.RemoveRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.SavedMoviesResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
|
||||
fun Route.setupSavedMovie(savedMovieFactory: SavedMovieRepositoryFactory) {
|
||||
post(SavedMovieRepository.REMOVE) {
|
||||
val saveRequest = call.receive<RemoveRequest>()
|
||||
savedMovieFactory.create(call.obtainUserId()).remove(saveRequest.movieId)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
post(SavedMovieRepository.WRITE) {
|
||||
val saveRequest = call.receive<VideoDetails>()
|
||||
savedMovieFactory.create(call.obtainUserId()).save(saveRequest)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
get(SavedMovieRepository.QUERY_SAVED) {
|
||||
val p = call.parameters
|
||||
val saved = savedMovieFactory.create(call.obtainUserId())
|
||||
.isSaved(p.getOrFail(SavedMovieRepository.MOVIE_ID_ARG).toLong())
|
||||
.first()
|
||||
call.respond(IsSavedResponse(saved))
|
||||
}
|
||||
get(SavedMovieRepository.QUERY_ALL) {
|
||||
val saved = savedMovieFactory.create(call.obtainUserId())
|
||||
.getAll()
|
||||
.first()
|
||||
call.respond(SavedMoviesResponse(saved))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import kotlinx.coroutines.flow.first
|
||||
import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchDeleteRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchQueryRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchQueryResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||
|
||||
fun Route.setupSearch(searchFactory: SearchRepositoryFactory) {
|
||||
get(SearchRepository.SEARCH) {
|
||||
val p = call.parameters
|
||||
val userId = call.obtainUserId()
|
||||
val search = searchFactory.create(userId)
|
||||
call.respond(SearchQueryResponse(search.search(p[SearchRepository.QUERY_ARG] ?: "").first()))
|
||||
}
|
||||
post(SearchRepository.ADD_TO_SEARCH) {
|
||||
val userId = call.obtainUserId()
|
||||
val request = call.receive<SearchQueryRequest>()
|
||||
searchFactory.create(userId).addToSearch(request.query)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
delete(SearchRepository.CLEAR) {
|
||||
val userId = call.obtainUserId()
|
||||
val request = call.receive<SearchDeleteRequest>()
|
||||
val repo = searchFactory.create(userId)
|
||||
if (request.query == null) {
|
||||
repo.clear()
|
||||
} else {
|
||||
repo.deleteFromSearch(request.query!!)
|
||||
}
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import kotlinx.coroutines.flow.first
|
||||
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.AddRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.DeleteRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.EditRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.LinkRequest
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.LinkedMovies
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.TagResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||
|
||||
fun Route.setupTagsRouting(
|
||||
userTagFactory: UserTagRepositoryFactory,
|
||||
movieTagFactory: MovieTagRepositoryFactory
|
||||
) {
|
||||
setupUserTag(userTagFactory)
|
||||
setupMovieTag(movieTagFactory)
|
||||
}
|
||||
|
||||
private fun Route.setupUserTag(userTagFactory: UserTagRepositoryFactory) {
|
||||
get(UserTagRepository.LIST) {
|
||||
val userTag = userTagFactory.create(call.obtainUserId())
|
||||
call.respond(TagResponse(userTag.userTags.first()))
|
||||
}
|
||||
post(UserTagRepository.ADD) {
|
||||
val userTag = userTagFactory.create(call.obtainUserId())
|
||||
userTag.add(call.receive<AddRequest>().tag)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
post(UserTagRepository.EDIT) {
|
||||
val userTag = userTagFactory.create(call.obtainUserId())
|
||||
val edit = call.receive<EditRequest>()
|
||||
userTag.edit(edit.id, edit.newTag)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
delete(UserTagRepository.DELETE) {
|
||||
val userTag = userTagFactory.create(call.obtainUserId())
|
||||
userTag.delete(call.receive<DeleteRequest>().id)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.setupMovieTag(movieTagFactory: MovieTagRepositoryFactory) {
|
||||
get(MovieTagRepository.QUERY) {
|
||||
val movieTag = movieTagFactory.create(call.obtainUserId())
|
||||
val movieId = call.parameters[MovieTagRepository.MOVIE_ID]?.toLong()
|
||||
val tags = movieTag.query(checkNotNull(movieId) { "movieId required" }).first()
|
||||
call.respond(TagResponse(tags))
|
||||
}
|
||||
get(MovieTagRepository.QUERY_MOVIES) {
|
||||
val movieTag = movieTagFactory.create(call.obtainUserId())
|
||||
val tagId = call.parameters[MovieTagRepository.TAG_ID]?.toLong()
|
||||
val ids = movieTag.queryMovies(checkNotNull(tagId) { "tagId required" }).first()
|
||||
call.respond(LinkedMovies(ids))
|
||||
}
|
||||
post(MovieTagRepository.LINK) {
|
||||
val movieTag = movieTagFactory.create(call.obtainUserId())
|
||||
val link = call.receive<LinkRequest>()
|
||||
movieTag.link(link.movieId, link.tagId)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
delete(MovieTagRepository.UNLINK) {
|
||||
val movieTag = movieTagFactory.create(call.obtainUserId())
|
||||
val link = call.receive<LinkRequest>()
|
||||
movieTag.unlink(link.movieId, link.tagId)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
ktor {
|
||||
deployment {
|
||||
port = 8181
|
||||
}
|
||||
application {
|
||||
modules = [ ru.shadowsparky.vbox.backend.MainKt.module ]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="debug">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
<logger name="org.eclipse.jetty" level="INFO" />
|
||||
<logger name="io.netty" level="INFO" />
|
||||
</configuration>
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Создание таблицы movie
|
||||
|
||||
CREATE TABLE movie (
|
||||
movie_id BIGSERIAL PRIMARY KEY,
|
||||
poster_url VARCHAR(256) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
flags INT NOT NULL
|
||||
);
|
||||
|
||||
-- Создание таблицы user
|
||||
|
||||
CREATE TABLE "user" (
|
||||
user_id BIGSERIAL PRIMARY KEY,
|
||||
login VARCHAR(256) NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
flags INT DEFAULT NULL
|
||||
);
|
||||
|
||||
-- Создание таблицы recently_watched
|
||||
|
||||
CREATE TABLE recently_watched (
|
||||
recently_watched_id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
movie_id BIGINT NOT NULL,
|
||||
episode BIGINT DEFAULT NULL,
|
||||
season BIGINT DEFAULT NULL,
|
||||
UNIQUE (user_id, movie_id),
|
||||
FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
FOREIGN KEY (movie_id) REFERENCES movie (movie_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Создание таблицы saved_movie
|
||||
|
||||
CREATE TABLE saved_movie (
|
||||
saved_movie_id BIGSERIAL PRIMARY KEY,
|
||||
movie_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
UNIQUE (movie_id, user_id),
|
||||
FOREIGN KEY (movie_id) REFERENCES movie (movie_id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Создание таблицы search_info
|
||||
|
||||
CREATE TABLE search_info (
|
||||
search_id BIGSERIAL PRIMARY KEY,
|
||||
query VARCHAR(256) DEFAULT NULL,
|
||||
user_id BIGINT DEFAULT NULL,
|
||||
UNIQUE (query, user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE refresh_tokens (
|
||||
token_id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at BIGINT NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE user_tag (
|
||||
id bigserial NOT NULL,
|
||||
user_id bigint NOT NULL,
|
||||
tag varchar NOT NULL,
|
||||
CONSTRAINT user_tag_pk PRIMARY KEY (id),
|
||||
CONSTRAINT user_tag_user_fk FOREIGN KEY (user_id) REFERENCES "user" (user_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX user_tag_tag_idx ON user_tag (tag,user_id);
|
||||
|
||||
CREATE TABLE movie_tag (
|
||||
id bigserial NOT NULL,
|
||||
movie_id bigint NOT NULL,
|
||||
tag_id bigint NOT NULL,
|
||||
CONSTRAINT movie_tag_pk PRIMARY KEY (id),
|
||||
CONSTRAINT movie_tag_movie_fk FOREIGN KEY (movie_id) REFERENCES movie(movie_id),
|
||||
CONSTRAINT movie_tag_user_tag_fk FOREIGN KEY (tag_id) REFERENCES user_tag(id)
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
insertMovie:
|
||||
INSERT INTO "movie" (movie_id, poster_url, description, title, flags) VALUES (?, ?, ?, ?, ?);
|
||||
|
||||
selectMovie:
|
||||
SELECT * FROM "movie" WHERE movie_id = ?;
|
||||
|
||||
updateMovie:
|
||||
UPDATE "movie"
|
||||
SET poster_url = ?, description = ?, title = ?, flags = ?
|
||||
WHERE movie_id = ?;
|
||||
@@ -0,0 +1,23 @@
|
||||
selectAll:
|
||||
SELECT * FROM "recently_watched" WHERE user_id = ?;
|
||||
|
||||
selectByMovieId:
|
||||
SELECT * FROM "recently_watched" WHERE movie_id = ? AND user_id = ? AND season IS NOT NULL AND episode IS NOT NULL;
|
||||
|
||||
selectByMovieIdAndSeason:
|
||||
SELECT * FROM "recently_watched" WHERE movie_id = ? AND user_id = ? AND season = ? AND episode IS NOT NULL;
|
||||
|
||||
selectByIds:
|
||||
SELECT * FROM "recently_watched" WHERE movie_id = ? AND episode = ? AND season = ? AND user_id = ?;
|
||||
|
||||
insertRecentlyWatched:
|
||||
INSERT INTO "recently_watched" (user_id, movie_id, episode, season) VALUES (?, ?, ?, ?);
|
||||
|
||||
deleteRwByIds:
|
||||
DELETE FROM "recently_watched" WHERE (movie_id = ? AND episode = ? AND season = ? AND user_id = ?);
|
||||
|
||||
deleteRecentlyWatchedById:
|
||||
DELETE FROM "recently_watched" WHERE recently_watched_id = ? AND user_id = ?;
|
||||
|
||||
deleteRecentlyWatchedByMovieId:
|
||||
DELETE FROM "recently_watched" WHERE movie_id = ? AND user_id = ?;
|
||||
@@ -0,0 +1,19 @@
|
||||
selectByHash:
|
||||
SELECT *
|
||||
FROM refresh_tokens
|
||||
WHERE token_hash = ?;
|
||||
|
||||
insertToken:
|
||||
INSERT INTO refresh_tokens(user_id, token_hash, expires_at, revoked)
|
||||
VALUES (?, ?, ?, FALSE)
|
||||
RETURNING token_id;
|
||||
|
||||
revokeToken:
|
||||
UPDATE refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE token_id = ?;
|
||||
|
||||
revokeAllForUser:
|
||||
UPDATE refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE user_id = ?;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- videobox."saved_movie" definition
|
||||
|
||||
selectByUserId:
|
||||
SELECT * FROM "saved_movie" WHERE user_id = ? ORDER BY saved_movie_id;
|
||||
|
||||
selectByUserIdAndMovieId:
|
||||
SELECT * FROM "saved_movie" WHERE user_id = ? AND movie_id = ?;
|
||||
|
||||
addSavedMovie:
|
||||
INSERT INTO "saved_movie" (movie_id, user_id) VALUES (?, ?);
|
||||
|
||||
removeSavedMovie:
|
||||
DELETE FROM "saved_movie" WHERE user_id = ? AND movie_id = ?;
|
||||
@@ -0,0 +1,14 @@
|
||||
selectAll:
|
||||
SELECT * FROM "search_info" WHERE user_id = ? ORDER BY search_id;
|
||||
|
||||
selectByQuery:
|
||||
SELECT * FROM "search_info" WHERE query = ? AND user_id = ? ORDER BY search_id;
|
||||
|
||||
insertSearchInfo:
|
||||
INSERT INTO "search_info" (query, user_id) VALUES (?, ?);
|
||||
|
||||
deleteSearchInfo:
|
||||
DELETE FROM "search_info" WHERE query = ? AND user_id = ?;
|
||||
|
||||
deleteAll:
|
||||
DELETE FROM "search_info" WHERE user_id = ?;
|
||||
@@ -0,0 +1,47 @@
|
||||
getUserTagsByUserId:
|
||||
SELECT id, user_id, tag
|
||||
FROM user_tag
|
||||
WHERE user_id = ?;
|
||||
|
||||
getUserIdByTagId:
|
||||
SELECT user_id
|
||||
FROM user_tag
|
||||
WHERE id = ?;
|
||||
|
||||
insertUserTag:
|
||||
INSERT INTO user_tag (user_id, tag)
|
||||
VALUES (?, ?);
|
||||
|
||||
updateTagText:
|
||||
UPDATE user_tag
|
||||
SET tag = ?
|
||||
WHERE id = ?;
|
||||
|
||||
deleteTagById:
|
||||
DELETE FROM user_tag
|
||||
WHERE id = ?;
|
||||
|
||||
getTagsForMovie:
|
||||
SELECT ut.id, ut.tag
|
||||
FROM movie_tag mt
|
||||
JOIN user_tag ut ON mt.tag_id = ut.id
|
||||
WHERE mt.movie_id = ? AND ut.user_id = ?;
|
||||
|
||||
getMoviesByTagId:
|
||||
SELECT mt.movie_id
|
||||
FROM movie_tag mt
|
||||
JOIN user_tag ut ON mt.tag_id = ut.id
|
||||
WHERE mt.tag_id = ? AND ut.user_id = ?;
|
||||
|
||||
linkMovieTag:
|
||||
INSERT INTO movie_tag (movie_id, tag_id)
|
||||
SELECT ?, ut.id
|
||||
FROM user_tag ut
|
||||
WHERE ut.id = ? AND ut.user_id = ?
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
unlinkMovieTag:
|
||||
DELETE FROM movie_tag
|
||||
WHERE movie_id = ?
|
||||
AND tag_id = ?
|
||||
AND tag_id IN (SELECT id FROM user_tag WHERE user_id = ?);
|
||||
@@ -0,0 +1,16 @@
|
||||
selectUserByLogin:
|
||||
SELECT * FROM "user" WHERE login = ? LIMIT 1;
|
||||
|
||||
selectUserByUserId:
|
||||
SELECT * FROM "user" WHERE user_id = ? LIMIT 1;
|
||||
|
||||
addUser:
|
||||
INSERT INTO "user" (login, password_hash, created_at, flags) VALUES (?, ?, ?, ?);
|
||||
|
||||
removeUser:
|
||||
DELETE FROM "user" WHERE login = ?;
|
||||
|
||||
updatePassword:
|
||||
UPDATE "user"
|
||||
SET password_hash = ?
|
||||
WHERE user_id = ?;
|
||||
@@ -0,0 +1,4 @@
|
||||
keystore.properties
|
||||
release.keystore
|
||||
*.keystore
|
||||
build
|
||||
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.android.app)
|
||||
alias(libs.plugins.convention.compose)
|
||||
alias(libs.plugins.convention.koin)
|
||||
alias(libs.plugins.convention.ktor.client)
|
||||
}
|
||||
|
||||
android {
|
||||
buildFeatures { buildConfig = true }
|
||||
namespace = "ru.shadowsparky.videobox.multiplatform"
|
||||
defaultConfig {
|
||||
applicationId = namespace
|
||||
minSdk = libs.versions.proj.min.get().toInt()
|
||||
targetSdk = libs.versions.proj.target.get().toInt()
|
||||
versionCode = 35
|
||||
versionName = "1.18"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.material)
|
||||
implementation(project(":apps:vbox:client:shared"))
|
||||
implementation(project(":apps:vbox:common"))
|
||||
|
||||
implementation(project(":libs:base"))
|
||||
implementation(project(":libs:ui"))
|
||||
implementation(project(":libs:http-client"))
|
||||
implementation(project(":libs:video-player"))
|
||||
debugImplementation(libs.ui.tooling)
|
||||
compileOnly(libs.ui.tooling.preview.android)
|
||||
testImplementation(libs.koin.test)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(kotlin("test-junit"))
|
||||
androidTestImplementation(libs.junit)
|
||||
androidTestImplementation(libs.runner)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||
-dontwarn org.conscrypt.Conscrypt$Version
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
-dontwarn org.conscrypt.ConscryptHostnameVerifier
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
-dontwarn java.lang.management.ManagementFactory
|
||||
-dontwarn java.lang.management.RuntimeMXBean
|
||||
-dontwarn kotlinx.atomicfu.**
|
||||
-dontwarn io.netty.**
|
||||
-dontwarn com.typesafe.**
|
||||
-dontwarn org.slf4j.**
|
||||
-dontwarn com.sun.nio.file.SensitivityWatchEventModifier
|
||||
-dontwarn org.xmlpull.v1.**
|
||||
-dontwarn javax.xml.namespace.**
|
||||
-dontwarn androidx.test.platform.app.InstrumentationRegistry
|
||||
-dontwarn androidx.window.extensions.WindowExtensions
|
||||
-dontwarn androidx.window.extensions.WindowExtensionsProvider
|
||||
-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation
|
||||
-dontwarn androidx.window.extensions.core.util.function.Consumer
|
||||
-dontwarn androidx.window.extensions.core.util.function.Function
|
||||
-dontwarn androidx.window.extensions.core.util.function.Predicate
|
||||
-dontwarn androidx.window.extensions.layout.DisplayFeature
|
||||
-dontwarn androidx.window.extensions.layout.FoldingFeature
|
||||
-dontwarn androidx.window.extensions.layout.WindowLayoutComponent
|
||||
-dontwarn androidx.window.extensions.layout.WindowLayoutInfo
|
||||
-dontwarn androidx.window.sidecar.SidecarDeviceState
|
||||
-dontwarn androidx.window.sidecar.SidecarDisplayFeature
|
||||
-dontwarn androidx.window.sidecar.SidecarInterface$SidecarCallback
|
||||
-dontwarn androidx.window.sidecar.SidecarInterface
|
||||
-dontwarn androidx.window.sidecar.SidecarProvider
|
||||
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo
|
||||
|
||||
-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent { *; }
|
||||
-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnSaved { *; }
|
||||
-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnRecent { *; }
|
||||
-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnSearch { *; }
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Videobox"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="screenSize|screenLayout|orientation|smallestScreenSize"
|
||||
android:exported="true"
|
||||
android:supportsPictureInPicture="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="ru.shadowsparky.vbox.action.OPEN_SAVED_MOVIES" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="box.shadowsparky.ru" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,34 @@
|
||||
package ru.shadowsparky.videobox.multiplatform
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.annotation.KoinApplication
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.koin.plugin.module.dsl.startKoin
|
||||
import ru.shadowsparky.domain.AppConfig
|
||||
|
||||
@KoinApplication
|
||||
object VBox
|
||||
|
||||
class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
startKoin<VBox> { androidContext(this@App) }
|
||||
if (BuildConfig.DEBUG) StrictMode.enableDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
class AppModule {
|
||||
|
||||
@Single
|
||||
fun provideAppConfig(): AppConfig {
|
||||
return object : AppConfig {
|
||||
override fun isDebug(): Boolean = BuildConfig.DEBUG
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package ru.shadowsparky.videobox.multiplatform
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.arkivanov.decompose.retainedComponent
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.shadowsparky.koin
|
||||
import ru.shadowsparky.vbox.shared.presentation.Nav
|
||||
import ru.shadowsparky.vbox.shared.presentation.RootComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.RootFactory
|
||||
import ru.shadowsparky.vbox.shared.presentation.UrlRoutes
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var root: RootComponent
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
edgeToEdge(false)
|
||||
super.onCreate(savedInstanceState)
|
||||
root = retainedComponent { componentContext ->
|
||||
val routeFromIntent = obtainRouteOrNull(intent?.data)
|
||||
val route = if (routeFromIntent == null) {
|
||||
listOf(Route.default)
|
||||
} else {
|
||||
listOf(Route.default, routeFromIntent)
|
||||
}
|
||||
koin.get<RootFactory>().create(componentContext, route)
|
||||
}
|
||||
if (openSavedIfNeeded(intent) || openUrlIfNeeded(intent.data)) {
|
||||
intent = Intent(intent)
|
||||
.setData(null)
|
||||
.setAction(null)
|
||||
}
|
||||
setContent {
|
||||
Nav(rootComponent = root)
|
||||
edgeToEdge(isSystemInDarkTheme())
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
root.onFinish.collect {
|
||||
finishAndRemoveTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun edgeToEdge(isDarkTheme: Boolean) {
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(
|
||||
Color.TRANSPARENT,
|
||||
Color.TRANSPARENT,
|
||||
{ isDarkTheme }
|
||||
),
|
||||
navigationBarStyle = SystemBarStyle.light(
|
||||
Color.TRANSPARENT,
|
||||
Color.TRANSPARENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun openSavedIfNeeded(intent: Intent?): Boolean {
|
||||
if (intent?.action == "ru.shadowsparky.vbox.action.OPEN_SAVED_MOVIES") {
|
||||
root.nav(Route.Bookmark(), true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (!openSavedIfNeeded(intent)) {
|
||||
val uri = intent.data ?: return
|
||||
val route = obtainRouteOrNull(uri)
|
||||
if (route != null) {
|
||||
root.nav(route, true)
|
||||
} else {
|
||||
openUrlIfNeeded(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUrlIfNeeded(uri: Uri?): Boolean {
|
||||
uri ?: return false
|
||||
if (uri.path?.startsWith("/video/") == true) {
|
||||
root.nav(Route.Overview.Player(url = uri.toString()))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun obtainRouteOrNull(uri: Uri?): Route? {
|
||||
uri ?: return null
|
||||
return UrlRoutes.parseUrl(uri.toString(), basePath = "/")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.shadowsparky.videobox.multiplatform.preview
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import ru.shadowsparky.ui.components.TextPreference
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SwitchPreferencePreview() {
|
||||
TextPreference(Modifier, "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", "summary")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package ru.shadowsparky.videobox.multiplatform.preview
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.shadowsparky.vbox.shared.presentation.search.AppBarWithSearch
|
||||
import ru.shadowsparky.vbox.shared.presentation.search.OldSearchInfo
|
||||
import ru.shadowsparky.vbox.shared.presentation.search.SearchAppBar
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppBarWithSearchPreview() {
|
||||
AppBarWithSearch(
|
||||
title = "TitleWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
|
||||
isDefault = false,
|
||||
onBack = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppBarWithSearchWithBackPreview() {
|
||||
AppBarWithSearch(
|
||||
title = "TitleWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
|
||||
onBack = {}
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("RememberInComposition")
|
||||
@Preview
|
||||
@Composable
|
||||
fun InternalAppBarPreview() {
|
||||
SearchAppBar(
|
||||
TextFieldValue(""),
|
||||
FocusRequester(),
|
||||
{},
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun SearchInfoPreview() {
|
||||
OldSearchInfo(
|
||||
modifier = Modifier,
|
||||
info = listOf(
|
||||
"Почему дора дура?",
|
||||
"клиинка",
|
||||
"симпсоны",
|
||||
"WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
|
||||
),
|
||||
PaddingValues(0.dp),
|
||||
onClick = {},
|
||||
onDeleteRequested = {}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ru.shadowsparky.videobox.multiplatform.preview
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.shadowsparky.vbox.shared.domain.model.Season
|
||||
import ru.shadowsparky.vbox.shared.presentation.overview.season.SeasonsInfo
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SeasonsInfoPreview() {
|
||||
SeasonsInfo(
|
||||
paddingValues = PaddingValues(0.dp),
|
||||
seasons = listOf(Season(listOf(), 23, "Lalalal"))
|
||||
) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ru.shadowsparky.videobox.multiplatform.preview
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.ErrorAndRetry
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.Loading
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ErrorPreview() {
|
||||
ErrorAndRetry(
|
||||
errorMsg = "Неизвестная ошибка"
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LoadingPreview() {
|
||||
Loading()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="#000000"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14zM7.33,12.5l2.14,-1.53 2.14,1.53 -0.83,-2.46 2.15,-1.5h-2.62L9.47,6l-0.84,2.54L6,8.54l2.14,1.49z" />
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="#000000"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM12,14.5v-9l6,4.5 -6,4.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:tint="#000000"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group
|
||||
android:scaleX="0.38866666"
|
||||
android:scaleY="0.38866666"
|
||||
android:translateX="7.336"
|
||||
android:translateY="7.336">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM12,14.5v-9l6,4.5 -6,4.5z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:tint="#000000"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group
|
||||
android:scaleX="0.38866666"
|
||||
android:scaleY="0.38866666"
|
||||
android:translateX="7.336"
|
||||
android:translateY="7.336">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6zM20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM12,14.5v-9l6,4.5 -6,4.5z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,6.5c3.79,0 7.17,2.13 8.82,5.5 -1.65,3.37 -5.02,5.5 -8.82,5.5S4.83,15.37 3.18,12C4.83,8.63 8.21,6.5 12,6.5m0,-2C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,9.5c1.38,0 2.5,1.12 2.5,2.5s-1.12,2.5 -2.5,2.5 -2.5,-1.12 -2.5,-2.5 1.12,-2.5 2.5,-2.5m0,-2c-2.48,0 -4.5,2.02 -4.5,4.5s2.02,4.5 4.5,4.5 4.5,-2.02 4.5,-4.5 -2.02,-4.5 -4.5,-4.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/main_fragment_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
tools:context=".presentation.MainActivity">
|
||||
|
||||
</androidx.fragment.app.FragmentContainerView>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 867 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_banner_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">Videobox</string>
|
||||
<string name="saved_movies">Сохранённые фильмы</string>
|
||||
<string name="saved_movies_long">Просмотр списка ранее сохранённых фильмов</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
|
||||
<style name="BaseTheme" parent="@style/Theme.Material3.DynamicColors.DayNight.NoActionBar" />
|
||||
|
||||
<style name="Theme.Videobox" parent="BaseTheme" />
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/baseline_saved_search_24"
|
||||
android:shortcutId="saved_movies"
|
||||
android:shortcutLongLabel="@string/saved_movies_long"
|
||||
android:shortcutShortLabel="@string/saved_movies">
|
||||
<intent
|
||||
android:action="ru.shadowsparky.vbox.action.OPEN_SAVED_MOVIES"
|
||||
android:targetClass="ru.shadowsparky.videobox.multiplatform.MainActivity"
|
||||
android:targetPackage="ru.shadowsparky.videobox.multiplatform" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts></shortcuts>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,388 @@
|
||||
package app.cash.sqldelight.driver.android
|
||||
|
||||
import android.content.Context
|
||||
import android.database.AbstractWindowedCursor
|
||||
import android.database.Cursor
|
||||
import android.database.CursorWindow
|
||||
import android.os.Build
|
||||
import android.util.LruCache
|
||||
import androidx.annotation.DoNotInline
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteOpenHelper
|
||||
import androidx.sqlite.db.SupportSQLiteProgram
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteStatement
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import app.cash.sqldelight.Query
|
||||
import app.cash.sqldelight.Transacter
|
||||
import app.cash.sqldelight.db.AfterVersion
|
||||
import app.cash.sqldelight.db.QueryResult
|
||||
import app.cash.sqldelight.db.SqlCursor
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.db.SqlPreparedStatement
|
||||
import app.cash.sqldelight.db.SqlSchema
|
||||
import app.cash.sqldelight.driver.android.Api28Impl.setWindowSize
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
private const val DEFAULT_CACHE_SIZE = 20
|
||||
|
||||
// workaround
|
||||
// https://github.com/cashapp/sqldelight/issues/5058
|
||||
class AndroidSqliteDriver private constructor(
|
||||
private val openHelper: SupportSQLiteOpenHelper? = null,
|
||||
database: SupportSQLiteDatabase? = null,
|
||||
private val cacheSize: Int,
|
||||
private val windowSizeBytes: Long? = null,
|
||||
) : SqlDriver {
|
||||
init {
|
||||
require((openHelper != null) xor (database != null))
|
||||
}
|
||||
|
||||
private val transactions = ThreadLocal<Transacter.Transaction>()
|
||||
private val database by lazy {
|
||||
openHelper?.writableDatabase ?: database!!
|
||||
}
|
||||
|
||||
constructor(
|
||||
openHelper: SupportSQLiteOpenHelper,
|
||||
) : this(
|
||||
openHelper = openHelper,
|
||||
database = null,
|
||||
cacheSize = DEFAULT_CACHE_SIZE,
|
||||
windowSizeBytes = null
|
||||
)
|
||||
|
||||
/**
|
||||
* @param [cacheSize] The number of compiled sqlite statements to keep in memory per connection.
|
||||
* Defaults to 20.
|
||||
* @param [useNoBackupDirectory] Sets whether to use a no backup directory or not.
|
||||
* @param [windowSizeBytes] Size of cursor window in bytes, per [CursorWindow] (Android 28+ only), or null to use the default.
|
||||
*/
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
|
||||
context: Context,
|
||||
name: String? = null,
|
||||
factory: SupportSQLiteOpenHelper.Factory = FrameworkSQLiteOpenHelperFactory(),
|
||||
callback: SupportSQLiteOpenHelper.Callback = Callback(schema),
|
||||
cacheSize: Int = DEFAULT_CACHE_SIZE,
|
||||
useNoBackupDirectory: Boolean = false,
|
||||
windowSizeBytes: Long? = null,
|
||||
) : this(
|
||||
database = null,
|
||||
openHelper = factory.create(
|
||||
SupportSQLiteOpenHelper.Configuration.builder(context)
|
||||
.callback(callback)
|
||||
.name(name)
|
||||
.noBackupDirectory(useNoBackupDirectory)
|
||||
.build(),
|
||||
),
|
||||
cacheSize = cacheSize,
|
||||
windowSizeBytes = windowSizeBytes,
|
||||
)
|
||||
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
database: SupportSQLiteDatabase,
|
||||
cacheSize: Int = DEFAULT_CACHE_SIZE,
|
||||
windowSizeBytes: Long? = null,
|
||||
) : this(
|
||||
openHelper = null,
|
||||
database = database,
|
||||
cacheSize = cacheSize,
|
||||
windowSizeBytes = windowSizeBytes
|
||||
)
|
||||
|
||||
private val statements = object : LruCache<Int, AndroidStatement>(cacheSize) {
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: Int,
|
||||
oldValue: AndroidStatement,
|
||||
newValue: AndroidStatement?,
|
||||
) {
|
||||
if (evicted) oldValue.close()
|
||||
}
|
||||
}
|
||||
|
||||
private val listeners = linkedMapOf<String, MutableSet<Query.Listener>>()
|
||||
|
||||
override fun addListener(vararg queryKeys: String, listener: Query.Listener) {
|
||||
synchronized(listeners) {
|
||||
queryKeys.forEach {
|
||||
listeners.getOrPut(it, { linkedSetOf() }).add(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeListener(vararg queryKeys: String, listener: Query.Listener) {
|
||||
synchronized(listeners) {
|
||||
queryKeys.forEach {
|
||||
listeners[it]?.remove(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun notifyListeners(vararg queryKeys: String) {
|
||||
val listenersToNotify = linkedSetOf<Query.Listener>()
|
||||
synchronized(listeners) {
|
||||
queryKeys.forEach { listeners[it]?.let(listenersToNotify::addAll) }
|
||||
}
|
||||
listenersToNotify.forEach(Query.Listener::queryResultsChanged)
|
||||
}
|
||||
|
||||
override fun newTransaction(): QueryResult<Transacter.Transaction> {
|
||||
val enclosing = transactions.get()
|
||||
val transaction = Transaction(enclosing)
|
||||
transactions.set(transaction)
|
||||
|
||||
if (enclosing == null) {
|
||||
database.beginTransactionNonExclusive()
|
||||
}
|
||||
|
||||
return QueryResult.Value(transaction)
|
||||
}
|
||||
|
||||
override fun currentTransaction(): Transacter.Transaction? = transactions.get()
|
||||
|
||||
inner class Transaction(
|
||||
override val enclosingTransaction: Transacter.Transaction?,
|
||||
) : Transacter.Transaction() {
|
||||
override fun endTransaction(successful: Boolean): QueryResult<Unit> {
|
||||
if (enclosingTransaction == null) {
|
||||
if (successful) {
|
||||
database.setTransactionSuccessful()
|
||||
database.endTransaction()
|
||||
} else {
|
||||
database.endTransaction()
|
||||
}
|
||||
}
|
||||
transactions.set(enclosingTransaction)
|
||||
return QueryResult.Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> execute(
|
||||
identifier: Int?,
|
||||
createStatement: () -> AndroidStatement,
|
||||
binders: (SqlPreparedStatement.() -> Unit)?,
|
||||
result: AndroidStatement.() -> T,
|
||||
): QueryResult.Value<T> {
|
||||
var statement: AndroidStatement? = null
|
||||
if (identifier != null) {
|
||||
statement = statements.remove(identifier)
|
||||
}
|
||||
if (statement == null) {
|
||||
statement = createStatement()
|
||||
}
|
||||
try {
|
||||
if (binders != null) {
|
||||
statement.binders()
|
||||
}
|
||||
return QueryResult.Value(statement.result())
|
||||
} finally {
|
||||
if (identifier != null) {
|
||||
statements.put(identifier, statement)?.close()
|
||||
} else {
|
||||
statement.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun execute(
|
||||
identifier: Int?,
|
||||
sql: String,
|
||||
parameters: Int,
|
||||
binders: (SqlPreparedStatement.() -> Unit)?,
|
||||
): QueryResult<Long> = execute(
|
||||
identifier,
|
||||
{ AndroidPreparedStatement(database.compileStatement(sql)) },
|
||||
binders,
|
||||
{ execute() })
|
||||
|
||||
override fun <R> executeQuery(
|
||||
identifier: Int?,
|
||||
sql: String,
|
||||
mapper: (SqlCursor) -> QueryResult<R>,
|
||||
parameters: Int,
|
||||
binders: (SqlPreparedStatement.() -> Unit)?,
|
||||
) = execute(
|
||||
identifier,
|
||||
{ AndroidQuery(sql, database, parameters, windowSizeBytes) },
|
||||
binders
|
||||
) { executeQuery(mapper) }
|
||||
|
||||
override fun close() {
|
||||
statements.evictAll()
|
||||
return openHelper?.close() ?: database.close()
|
||||
}
|
||||
|
||||
open class Callback(
|
||||
private val schema: SqlSchema<QueryResult.AsyncValue<Unit>>,
|
||||
private vararg val callbacks: AfterVersion,
|
||||
) : SupportSQLiteOpenHelper.Callback(
|
||||
if (schema.version > Int.MAX_VALUE) error("Schema version is larger than Int.MAX_VALUE: ${schema.version}.") else schema.version.toInt(),
|
||||
) {
|
||||
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
runBlocking {
|
||||
schema.create(AndroidSqliteDriver(openHelper = null, database = db, cacheSize = 1))
|
||||
.await()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpgrade(
|
||||
db: SupportSQLiteDatabase,
|
||||
oldVersion: Int,
|
||||
newVersion: Int,
|
||||
) {
|
||||
runBlocking {
|
||||
schema.migrate(
|
||||
AndroidSqliteDriver(openHelper = null, database = db, cacheSize = 1),
|
||||
oldVersion.toLong(),
|
||||
newVersion.toLong(),
|
||||
*callbacks,
|
||||
).await()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface AndroidStatement : SqlPreparedStatement {
|
||||
fun execute(): Long
|
||||
fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R
|
||||
fun close()
|
||||
}
|
||||
|
||||
private class AndroidPreparedStatement(
|
||||
private val statement: SupportSQLiteStatement,
|
||||
) : AndroidStatement {
|
||||
override fun bindBytes(index: Int, bytes: ByteArray?) {
|
||||
if (bytes == null) statement.bindNull(index + 1) else statement.bindBlob(index + 1, bytes)
|
||||
}
|
||||
|
||||
override fun bindLong(index: Int, long: Long?) {
|
||||
if (long == null) statement.bindNull(index + 1) else statement.bindLong(index + 1, long)
|
||||
}
|
||||
|
||||
override fun bindDouble(index: Int, double: Double?) {
|
||||
if (double == null) statement.bindNull(index + 1) else statement.bindDouble(
|
||||
index + 1,
|
||||
double
|
||||
)
|
||||
}
|
||||
|
||||
override fun bindString(index: Int, string: String?) {
|
||||
if (string == null) statement.bindNull(index + 1) else statement.bindString(
|
||||
index + 1,
|
||||
string
|
||||
)
|
||||
}
|
||||
|
||||
override fun bindBoolean(index: Int, boolean: Boolean?) {
|
||||
if (boolean == null) {
|
||||
statement.bindNull(index + 1)
|
||||
} else {
|
||||
statement.bindLong(index + 1, if (boolean) 1L else 0L)
|
||||
}
|
||||
}
|
||||
|
||||
override fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun execute(): Long {
|
||||
return statement.executeUpdateDelete().toLong()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
statement.close()
|
||||
}
|
||||
}
|
||||
|
||||
private class AndroidQuery(
|
||||
override val sql: String,
|
||||
private val database: SupportSQLiteDatabase,
|
||||
override val argCount: Int,
|
||||
private val windowSizeBytes: Long?,
|
||||
) : SupportSQLiteQuery, AndroidStatement {
|
||||
private val binds = MutableList<((SupportSQLiteProgram) -> Unit)?>(argCount) { null }
|
||||
|
||||
override fun bindBytes(index: Int, bytes: ByteArray?) {
|
||||
binds[index] =
|
||||
{ if (bytes == null) it.bindNull(index + 1) else it.bindBlob(index + 1, bytes) }
|
||||
}
|
||||
|
||||
override fun bindLong(index: Int, long: Long?) {
|
||||
binds[index] =
|
||||
{ if (long == null) it.bindNull(index + 1) else it.bindLong(index + 1, long) }
|
||||
}
|
||||
|
||||
override fun bindDouble(index: Int, double: Double?) {
|
||||
binds[index] =
|
||||
{ if (double == null) it.bindNull(index + 1) else it.bindDouble(index + 1, double) }
|
||||
}
|
||||
|
||||
override fun bindString(index: Int, string: String?) {
|
||||
binds[index] =
|
||||
{ if (string == null) it.bindNull(index + 1) else it.bindString(index + 1, string) }
|
||||
}
|
||||
|
||||
override fun bindBoolean(index: Int, boolean: Boolean?) {
|
||||
binds[index] = {
|
||||
if (boolean == null) {
|
||||
it.bindNull(index + 1)
|
||||
} else {
|
||||
it.bindLong(index + 1, if (boolean) 1L else 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun execute() = throw UnsupportedOperationException()
|
||||
|
||||
override fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R {
|
||||
return database.query(this)
|
||||
.use { cursor -> mapper(AndroidCursor(cursor, windowSizeBytes)).value }
|
||||
}
|
||||
|
||||
override fun bindTo(statement: SupportSQLiteProgram) {
|
||||
for (action in binds) {
|
||||
action!!(statement)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString() = sql
|
||||
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
private class AndroidCursor(
|
||||
private val cursor: Cursor,
|
||||
windowSizeBytes: Long?,
|
||||
) : SqlCursor {
|
||||
init {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
||||
windowSizeBytes != null &&
|
||||
cursor is AbstractWindowedCursor
|
||||
) {
|
||||
cursor.setWindowSize(windowSizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
override fun next(): QueryResult.Value<Boolean> = QueryResult.Value(cursor.moveToNext())
|
||||
override fun getString(index: Int) = if (cursor.isNull(index)) null else cursor.getString(index)
|
||||
override fun getLong(index: Int) = if (cursor.isNull(index)) null else cursor.getLong(index)
|
||||
override fun getBytes(index: Int) = if (cursor.isNull(index)) null else cursor.getBlob(index)
|
||||
override fun getDouble(index: Int) = if (cursor.isNull(index)) null else cursor.getDouble(index)
|
||||
override fun getBoolean(index: Int) =
|
||||
if (cursor.isNull(index)) null else cursor.getLong(index) == 1L
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
private object Api28Impl {
|
||||
@JvmStatic
|
||||
@DoNotInline
|
||||
fun AbstractWindowedCursor.setWindowSize(windowSizeBytes: Long) {
|
||||
window = CursorWindow(null, windowSizeBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import app.cash.sqldelight.Query
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import app.cash.sqldelight.coroutines.mapToOneOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.vbox.domain.OfflineVideoQueries
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineState
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineStorage
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineVideo
|
||||
|
||||
typealias SqlOfflineVideo = ru.shadowsparky.vbox.domain.OfflineVideo
|
||||
|
||||
@Factory
|
||||
class AndroidOfflineStorage(
|
||||
private val dbProvider: AppDatabaseProvider,
|
||||
private val downloader: OfflineDownloader
|
||||
) : OfflineStorage {
|
||||
|
||||
private suspend fun getQueries(): OfflineVideoQueries {
|
||||
return dbProvider.get().offlineVideoQueries
|
||||
}
|
||||
|
||||
override val videos: Flow<List<OfflineVideo>> = flow {
|
||||
emitAll(getQueries().selectAll().toOfflineVideo())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun Query<SqlOfflineVideo>.toOfflineVideo(): Flow<List<OfflineVideo>> {
|
||||
return asFlow().mapToList(Dispatchers.IO)
|
||||
.flatMapLatest { items: List<SqlOfflineVideo> ->
|
||||
combine(
|
||||
items.map { item ->
|
||||
downloader.query(item.download_id)
|
||||
.mapNotNull { state ->
|
||||
if (state == null) remove(item.id)
|
||||
item.toDomain(state)
|
||||
}
|
||||
}
|
||||
) { it.toList() }.onStart { emit(emptyList()) }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
override suspend fun query(id: Long): Flow<OfflineVideo?> =
|
||||
withContext(Dispatchers.IO) {
|
||||
getQueries()
|
||||
.selectById(id)
|
||||
.asFlow()
|
||||
.mapToOneOrNull(Dispatchers.IO)
|
||||
.map { it?.toDomain() }
|
||||
}
|
||||
|
||||
private suspend fun queryRaw(id: Long): SqlOfflineVideo? =
|
||||
withContext(Dispatchers.IO) {
|
||||
getQueries()
|
||||
.selectById(id)
|
||||
.executeAsOneOrNull()
|
||||
}
|
||||
|
||||
override fun queryByArgs(movieId: Long, seasonId: Long?): Flow<List<OfflineVideo>> = flow {
|
||||
emitAll(
|
||||
getQueries()
|
||||
.selectByArgs(
|
||||
movie_id = movieId,
|
||||
season_id = seasonId
|
||||
).toOfflineVideo()
|
||||
)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
override suspend fun find(movieId: Long, seasonId: Long?, episodeId: Long?): OfflineVideo? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
getQueries().selectByFullArgs(movieId, seasonId, episodeId)
|
||||
.executeAsOneOrNull()
|
||||
?.toDomain()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun add(item: OfflineVideo, uri: String) = withContext(Dispatchers.IO) {
|
||||
val dmId = downloader.enqueue(uri)
|
||||
upsertWithDm(item, dmId)
|
||||
}
|
||||
|
||||
private suspend fun upsertWithDm(item: OfflineVideo, dmId: Long?) {
|
||||
withContext(Dispatchers.IO) {
|
||||
getQueries().upsert(
|
||||
movie_id = item.movieId,
|
||||
season_id = item.seasonId,
|
||||
episode_id = item.episodeId,
|
||||
id = item.id,
|
||||
download_id = dmId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(item: OfflineVideo) {
|
||||
val id = item.id ?: return
|
||||
remove(id)
|
||||
}
|
||||
|
||||
private suspend fun remove(id: Long) {
|
||||
withContext(Dispatchers.IO) {
|
||||
queryRaw(id)?.download_id?.let { downloader.delete(it) }
|
||||
getQueries().deleteById(id)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun SqlOfflineVideo.toDomain(state: OfflineState? = null): OfflineVideo? {
|
||||
val state = state ?: run {
|
||||
download_id?.let { downloader.query(it).firstOrNull() }
|
||||
} ?: return null
|
||||
return OfflineVideo(
|
||||
id = id,
|
||||
movieId = movie_id,
|
||||
seasonId = season_id,
|
||||
episodeId = episode_id,
|
||||
state = state
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.vbox.shared.domain.OfflineState
|
||||
import java.util.UUID
|
||||
|
||||
@Factory
|
||||
class OfflineDownloader(private val context: Context) {
|
||||
private val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
suspend fun enqueue(url: String): Long = withContext(Dispatchers.IO) {
|
||||
val request = DownloadManager.Request(url.toUri())
|
||||
.setDestinationInExternalFilesDir(
|
||||
context,
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
"/Videobox/${UUID.randomUUID()}.mp4"
|
||||
)
|
||||
dm.enqueue(request)
|
||||
}
|
||||
|
||||
suspend fun delete(dmId: Long) = withContext(Dispatchers.IO) {
|
||||
dm.remove(dmId)
|
||||
}
|
||||
|
||||
fun query(dmId: Long?): Flow<OfflineState?> {
|
||||
dmId ?: return flowOf(null)
|
||||
return callbackFlow {
|
||||
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
trySend(Unit)
|
||||
}
|
||||
}
|
||||
trySend(Unit)
|
||||
context.contentResolver.registerContentObserver(getUri(dmId), false, observer)
|
||||
awaitClose {
|
||||
context.contentResolver.unregisterContentObserver(observer)
|
||||
}
|
||||
}.map { obtainState(dmId) }.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
private fun getUri(dmId: Long): Uri {
|
||||
return "content://downloads/my_downloads/$dmId".toUri()
|
||||
}
|
||||
|
||||
private fun obtainState(dmId: Long): OfflineState? {
|
||||
val query = DownloadManager.Query().setFilterById(dmId)
|
||||
dm.query(query).use { cursor ->
|
||||
if (!cursor.moveToFirst()) return null
|
||||
val status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
return when (status) {
|
||||
DownloadManager.STATUS_PENDING -> OfflineState.Pending
|
||||
DownloadManager.STATUS_RUNNING -> obtainRunning(cursor)
|
||||
DownloadManager.STATUS_PAUSED -> obtainPaused(cursor)
|
||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||
val uri = cursor.getString(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)
|
||||
) ?: return OfflineState.Error("Uri not found")
|
||||
runCatching {
|
||||
context.contentResolver.openFileDescriptor(uri.toUri(), "r").use {
|
||||
return OfflineState.Available(uri)
|
||||
}
|
||||
}.getOrDefault(OfflineState.Error("File not found"))
|
||||
}
|
||||
DownloadManager.STATUS_FAILED -> {
|
||||
val reason = cursor.getInt(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON)
|
||||
)
|
||||
OfflineState.Error("Download failed: reason=$reason")
|
||||
}
|
||||
else -> OfflineState.Error("Unknown status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun obtainPaused(cursor: Cursor): OfflineState.Downloading {
|
||||
val bytesDownloaded = cursor.getLong(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
)
|
||||
val bytesTotal = cursor.getLong(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
)
|
||||
|
||||
val progress = if (bytesTotal > 0) {
|
||||
((bytesDownloaded * 100) / bytesTotal).toInt()
|
||||
} else 0
|
||||
|
||||
return OfflineState.Downloading(
|
||||
isPaused = true,
|
||||
progress = progress
|
||||
)
|
||||
}
|
||||
|
||||
private fun obtainRunning(cursor: Cursor): OfflineState.Downloading {
|
||||
val bytesDownloaded = cursor.getLong(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
)
|
||||
val bytesTotal = cursor.getLong(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
)
|
||||
|
||||
val progress = if (bytesTotal > 0) {
|
||||
((bytesDownloaded * 100) / bytesTotal).toInt()
|
||||
} else 0
|
||||
|
||||
return OfflineState.Downloading(
|
||||
isPaused = false,
|
||||
progress = progress
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.shadowsparky.vbox.shared.di.factory
|
||||
|
||||
import android.app.Application
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.vbox.app.AppDatabase
|
||||
|
||||
@Factory
|
||||
class AndroidDatabaseDriverFactory(private val app: Application) : DatabaseDriverFactory {
|
||||
|
||||
override suspend fun createDriver(): SqlDriver {
|
||||
return AndroidSqliteDriver(AppDatabase.Schema, app, "videobox.db")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package ru.shadowsparky.vbox.shared.di.factory
|
||||
|
||||
import android.app.Application
|
||||
import com.seiko.imageloader.Bitmap
|
||||
import com.seiko.imageloader.ImageLoader
|
||||
import com.seiko.imageloader.cache.memory.MemoryCacheBuilder
|
||||
import com.seiko.imageloader.cache.memory.MemoryKey
|
||||
import com.seiko.imageloader.cache.memory.maxSizePercent
|
||||
import com.seiko.imageloader.component.setupDefaultComponents
|
||||
import com.seiko.imageloader.intercept.bitmapMemoryCacheConfig
|
||||
import com.seiko.imageloader.option.androidContext
|
||||
import com.seiko.imageloader.size
|
||||
import com.seiko.imageloader.util.identityHashCode
|
||||
import io.ktor.client.HttpClient
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.koin.core.annotation.Factory
|
||||
|
||||
@Factory
|
||||
class AndroidImageLoaderProvider(
|
||||
private val app: Application,
|
||||
private val httpClient: HttpClient
|
||||
) : ImageLoaderProvider {
|
||||
|
||||
override fun provide(): ImageLoader {
|
||||
return ImageLoader {
|
||||
options {
|
||||
androidContext(app)
|
||||
}
|
||||
components {
|
||||
setupDefaultComponents { httpClient }
|
||||
}
|
||||
interceptor {
|
||||
// Set the max size to 25% of the app's available memory.
|
||||
bitmapMemoryCacheConfig(
|
||||
valueHashProvider = { identityHashCode(it) },
|
||||
valueSizeProvider = { it.size },
|
||||
block = fun MemoryCacheBuilder<MemoryKey, Bitmap>.() {
|
||||
// Set the max size to 25% of the app's available memory.
|
||||
maxSizePercent(app, 0.25)
|
||||
}
|
||||
)
|
||||
diskCacheConfig {
|
||||
directory(app.cacheDir.resolve("image_cache").toOkioPath())
|
||||
maxSizeBytes(512L * 1024 * 1024) // 512MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ActivityInfo {
|
||||
val isActiveFlow: StateFlow<Boolean>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.vbox.shared.domain
|
||||
|
||||
actual fun getCurrentOsType(): OsType {
|
||||
return OsType.ANDROID
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||
|
||||
// adb shell am start -W -a android.intent.action.VIEW -d "https://box.shadowsparky.ru/deep-link/overview/191" ru.shadowsparky.videobox.multiplatform
|
||||
@Factory
|
||||
class AndroidShareManager(private val app: Application) : ShareManager {
|
||||
|
||||
override fun shareDetails(details: VideoDetails) {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, "https://box.shadowsparky.ru/overview/${details.id}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
app.startActivity(intent)
|
||||
}
|
||||
|
||||
override fun canShare(): Boolean = true
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.Res
|
||||
import androiddev.apps.vbox.client.shared.generated.resources.cant_open_video_viewer
|
||||
import androidx.core.net.toUri
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.koin.core.annotation.Factory
|
||||
|
||||
@Factory
|
||||
class AndroidVideoLauncher(
|
||||
private val app: Application
|
||||
) : VideoLauncher {
|
||||
|
||||
override suspend fun launch(uri: String) {
|
||||
if (!launchInternal(uri, true)) {
|
||||
if (!launchInternal(uri, false)) {
|
||||
Toast.makeText(
|
||||
app,
|
||||
getString(Res.string.cant_open_video_viewer),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchInternal(uri: String, withType: Boolean): Boolean {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (withType) {
|
||||
intent.setDataAndType(uri.toUri(), "video/*")
|
||||
} else {
|
||||
intent.data = uri.toUri()
|
||||
}
|
||||
try {
|
||||
app.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.search
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
|
||||
actual fun Modifier.requestFocusOnDownEvent(focusRequester: FocusRequester): Modifier {
|
||||
return onKeyEvent {
|
||||
if (it.type == KeyEventType.KeyUp) {
|
||||
if (it.key == Key.DirectionDown) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" />
|
||||
</vector>
|
||||