init projects

This commit is contained in:
2026-06-17 18:58:41 +03:00
parent 8543c4e241
commit 21a768edd5
505 changed files with 19998 additions and 93 deletions
+23
View File
@@ -0,0 +1,23 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/shared/build
.idea
androidApp/build
cache
.kotlin
kotlin-js-store
release.keystore
keystore.properties
-93
View File
@@ -1,93 +0,0 @@
# android-projects
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin https://gitlab.com/pornking/android-projects.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
* [Set up project integrations](https://gitlab.com/pornking/android-projects/-/settings/integrations)
## Collaborate with your team
* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
## Test and Deploy
Use the built-in continuous integration in GitLab.
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+46
View File
@@ -0,0 +1,46 @@
import com.android.build.api.dsl.ApplicationExtension
plugins {
alias(libs.plugins.convention.android.app)
alias(libs.plugins.convention.compose)
alias(libs.plugins.convention.koin)
}
extensions.getByType<ApplicationExtension>().apply {
namespace = "ru.shadowsparky.callblocker"
defaultConfig {
applicationId = namespace
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = true
}
}
buildFeatures {
buildConfig = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(libs.activity.compose)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.appcompat)
implementation(project(":libs:base"))
implementation(project(":libs:ui"))
debugImplementation(libs.ui.tooling)
compileOnly(libs.ui.tooling.preview.android)
compileOnly(project(":libs:android-stub"))
testImplementation(libs.koin.test)
testImplementation(libs.mockk)
testImplementation(kotlin("test-junit"))
}
@@ -0,0 +1,50 @@
<?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.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<application
android:name=".presentation.App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CallBlocker"
android:windowSoftInputMode="adjustResize"
tools:targetApi="31">
<activity
android:name=".presentation.ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.CallBlocker">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<receiver
android:name=".presentation.RejectCallReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PHONE_STATE" />
</intent-filter>
</receiver>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,24 @@
package ru.shadowsparky.callblocker
import android.app.Application
import android.os.Build
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module
import ru.shadowsparky.callblocker.data.LRejectCallDelegate
import ru.shadowsparky.callblocker.data.PRejectCallDelegate
import ru.shadowsparky.callblocker.domain.RejectCallDelegate
@Module
@ComponentScan
class AppModule {
@Factory
fun provideAlwaysRejectCallDelegate(app: Application): RejectCallDelegate {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
PRejectCallDelegate(app)
} else {
LRejectCallDelegate(app)
}
}
}
@@ -0,0 +1,51 @@
package ru.shadowsparky.callblocker.data
import android.content.Context
import android.database.Cursor
import android.provider.ContactsContract
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.callblocker.domain.ContactEntry
import ru.shadowsparky.callblocker.domain.ContactReader
@Factory
class ContentProviderContactReader(
private val context: Context
) : ContactReader {
override suspend fun read(): Set<ContactEntry> {
val projection = arrayOf(
ContactsContract.Contacts.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER
)
return withContext(Dispatchers.IO) {
context.contentResolver.query(
ContactsContract.Data.CONTENT_URI,
projection,
null,
null,
null
).use { queryContacts(it ?: return@withContext emptySet()) }
}
}
private fun queryContacts(cursor: Cursor): Set<ContactEntry> {
if (!cursor.moveToFirst()) return emptySet()
val result = mutableSetOf<ContactEntry>()
do {
val name = cursor.getString(ContactsContract.Contacts.DISPLAY_NAME)
val phone = cursor.getString(ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER)
if (phone != null && name != null) {
result.add(ContactEntry(name, phone))
}
} while (cursor.moveToNext())
return result
}
private fun Cursor.getString(column: String): String? {
val idx = getColumnIndex(column)
if (idx < 0) return null
return getString(idx)
}
}
@@ -0,0 +1,28 @@
package ru.shadowsparky.callblocker.data
import android.app.Application
import android.content.Context
import android.telephony.TelephonyManager
import android.util.Log
import com.android.internal.telephony.ITelephony
import ru.shadowsparky.callblocker.domain.RejectCallDelegate
class LRejectCallDelegate(private val app: Application) : RejectCallDelegate {
override suspend fun reject() {
getTeleService()?.endCall()
}
private fun getTeleService(): ITelephony? {
val tm = app.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
try {
val c = Class.forName(tm.javaClass.name)
val m = c.getDeclaredMethod("getITelephony")
m.isAccessible = true
return m.invoke(tm) as ITelephony
} catch (e: Exception) {
Log.e("LRejectCallDelegate", "err", e)
}
return null
}
}
@@ -0,0 +1,39 @@
package ru.shadowsparky.callblocker.data
import android.util.Log
import ru.shadowsparky.callblocker.BuildConfig
import ru.shadowsparky.callblocker.domain.ContactReader
import ru.shadowsparky.callblocker.domain.RejectCallDelegate
class NonContactsRejectCallDelegate(
private val contactReader: ContactReader,
private val number: String?,
private val wrapper: RejectCallDelegate
) : RejectCallDelegate {
override suspend fun reject() {
log("reject called with number $number")
if (number == null) {
log("number is null!!! exiting...")
return
}
val contacts = contactReader.read()
log("contacts found ${contacts.joinToString()}")
if (!contacts.any { it.phoneNumber == number }) {
log("calling number not found in contacts. rejecting")
wrapper.reject()
} else {
log("calling number found in contacts!")
}
}
private fun log(msg: String) {
if (BuildConfig.DEBUG) {
Log.d(TAG, msg)
}
}
companion object {
private const val TAG = "NonContactsCallDelegate"
}
}
@@ -0,0 +1,20 @@
package ru.shadowsparky.callblocker.data
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.telecom.TelecomManager
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import ru.shadowsparky.callblocker.domain.RejectCallDelegate
@RequiresApi(Build.VERSION_CODES.P)
class PRejectCallDelegate(private val context: Context) : RejectCallDelegate {
@SuppressLint("MissingPermission") // пофиг
@Suppress("DEPRECATION") // еще больше пофиг, пока работает будем использовать!
override suspend fun reject() {
Log.e("PRejectCallDelegate", "reject called", RuntimeException())
context.getSystemService<TelecomManager>()?.endCall()
}
}
@@ -0,0 +1,44 @@
package ru.shadowsparky.callblocker.data
import org.koin.core.annotation.Factory
import ru.shadowsparky.callblocker.domain.ContactReader
import ru.shadowsparky.callblocker.domain.RejectCallDelegate
import ru.shadowsparky.callblocker.domain.RejectPolicy
import ru.shadowsparky.callblocker.domain.RejectPolicyManager
@Factory
class PolicyBasedRejectDelegateFactory(
private val rejectPolicyManager: RejectPolicyManager,
private val contactReader: ContactReader,
private val rejectDelegate: RejectCallDelegate
) {
fun create(callingNumber: String?): PolicyBasedRejectDelegate {
return PolicyBasedRejectDelegate(
rejectPolicyManager,
callingNumber,
contactReader,
rejectDelegate
)
}
}
class PolicyBasedRejectDelegate(
private val rejectPolicyManager: RejectPolicyManager,
private val callingNumber: String?,
private val contactReader: ContactReader,
private val rejectDelegate: RejectCallDelegate
) : RejectCallDelegate {
override suspend fun reject() {
val wrapper = when (rejectPolicyManager.get()) {
RejectPolicy.All -> rejectDelegate
RejectPolicy.NonContacts -> NonContactsRejectCallDelegate(
contactReader,
callingNumber,
rejectDelegate
)
RejectPolicy.None -> null
}
wrapper?.reject()
}
}
@@ -0,0 +1,27 @@
package ru.shadowsparky.callblocker.data
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import ru.shadowsparky.callblocker.domain.RejectPolicy
import ru.shadowsparky.callblocker.domain.RejectPolicyManager
import ru.shadowsparky.domain.KeyValueStorage
@Factory
class PreferenceRejectPolicyManager(
private val keyValueStorage: KeyValueStorage
) : RejectPolicyManager {
override val flow = keyValueStorage.getInt(POLICY_KEY).map {
RejectPolicy.findByCode(it ?: RejectPolicy.None.code)
}
override suspend fun set(newPolicy: RejectPolicy) {
keyValueStorage.putInt(POLICY_KEY, newPolicy.code)
}
override suspend fun get(): RejectPolicy = flow.first()
companion object {
private const val POLICY_KEY = "policy"
}
}
@@ -0,0 +1,11 @@
package ru.shadowsparky.callblocker.domain
interface ContactReader {
suspend fun read(): Set<ContactEntry>
}
data class ContactEntry(
val name: String,
val phoneNumber: String
)
@@ -0,0 +1,5 @@
package ru.shadowsparky.callblocker.domain
interface RejectCallDelegate {
suspend fun reject()
}
@@ -0,0 +1,24 @@
package ru.shadowsparky.callblocker.domain
import kotlinx.coroutines.flow.Flow
interface RejectPolicyManager {
val flow: Flow<RejectPolicy>
suspend fun set(newPolicy: RejectPolicy)
suspend fun get(): RejectPolicy
}
sealed class RejectPolicy(val code: Int) {
data object None : RejectPolicy(0)
data object All : RejectPolicy(1)
data object NonContacts : RejectPolicy(2)
companion object {
val values: Set<RejectPolicy> get() = setOf(None, All, NonContacts)
fun findByCode(code: Int): RejectPolicy {
return values.first { it.code == code }
}
}
}
@@ -0,0 +1,19 @@
package ru.shadowsparky.callblocker.presentation
import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.core.annotation.KoinApplication
import org.koin.plugin.module.dsl.startKoin
import ru.shadowsparky.callblocker.AppModule
@KoinApplication(modules = [AppModule::class])
object CallBlocker
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin<CallBlocker> {
androidContext(this@App)
}
}
}
@@ -0,0 +1,32 @@
package ru.shadowsparky.callblocker.presentation
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.telephony.TelephonyManager
import android.util.Log
import ru.shadowsparky.callblocker.BuildConfig
class RejectCallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val keys = intent?.extras?.keySet() ?: return
val values = keys.map { it to intent.extras?.get(it).toString() }
if (BuildConfig.DEBUG) {
Log.d("RejectCallReceiver", "start")
values.forEach {
Log.d("RejectCallReceiver", "key ${it.first}; value: ${it.second}")
}
Log.d("RejectCallReceiver", "end")
}
if (intent.action == TelephonyManager.ACTION_PHONE_STATE_CHANGED && context != null) {
val state = intent.getStringExtra(TelephonyManager.EXTRA_STATE)
if (state == TelephonyManager.EXTRA_STATE_RINGING) {
@Suppress("DEPRECATION")
val number = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER)
RejectCallWorker.start(context, number)
}
}
}
}
@@ -0,0 +1,36 @@
package ru.shadowsparky.callblocker.presentation
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import ru.shadowsparky.callblocker.data.PolicyBasedRejectDelegateFactory
import ru.shadowsparky.koin
class RejectCallWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
private val delegateFactory by koin.inject<PolicyBasedRejectDelegateFactory>()
override suspend fun doWork(): Result {
val callingNumber = inputData.getString(NUMBER_KEY)
delegateFactory.create(callingNumber).reject()
return Result.success()
}
companion object {
private const val NUMBER_KEY = "number"
fun start(context: Context?, number: String?) {
context ?: return
val wm = WorkManager.getInstance(context)
val request = OneTimeWorkRequestBuilder<RejectCallWorker>()
.setInputData(workDataOf(NUMBER_KEY to number))
.build()
wm.enqueue(request)
}
}
}
@@ -0,0 +1,42 @@
package ru.shadowsparky.callblocker.presentation.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import ru.shadowsparky.callblocker.R
import ru.shadowsparky.callblocker.domain.RejectPolicyManager
import ru.shadowsparky.koin
import ru.shadowsparky.ui.BaseScreen
class MainActivity : ComponentActivity() {
private val rejectPolicyManager by koin.inject<RejectPolicyManager>()
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val permissionRequest = newPermissionRequest()
setContent {
BaseScreen(
appBar = {
TopAppBar(title = { Text(stringResource(R.string.app_name)) })
}
) {
CompositionLocalProvider(LocalPermissionRequest provides permissionRequest) {
Box(modifier = Modifier.padding(it)) {
PolicyContent(rejectPolicyManager)
}
}
}
}
}
}
@@ -0,0 +1,147 @@
package ru.shadowsparky.callblocker.presentation.ui
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.content.ContextCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import ru.shadowsparky.callblocker.R
interface PermissionRequest {
val hasPermission: StateFlow<Boolean>
fun getTitle(): String
fun getDescription(): String
fun requestPermission()
}
abstract class MutablePermissionRequest(private val hasPermissionCallback: () -> Boolean) :
PermissionRequest {
override val hasPermission = MutableStateFlow(hasPermissionCallback())
init {
initState()
}
override fun requestPermission() {
if (!hasPermission.value) requestPermissionInternal()
}
private fun initState() {
if (!hasPermission.value) {
this.hasPermission.tryEmit(hasPermissionCallback())
}
}
protected abstract fun requestPermissionInternal()
}
class MultiplePermissionRequest(private val requests: List<PermissionRequest>) : PermissionRequest {
override val hasPermission
get() = getLatestDeclinedRequest()?.hasPermission ?: MutableStateFlow(true)
override fun getTitle(): String {
return getLatestDeclinedRequest()?.getTitle() ?: "null"
}
override fun getDescription(): String {
return getLatestDeclinedRequest()?.getDescription() ?: "null"
}
private fun getLatestDeclinedRequest(): PermissionRequest? {
return requests.firstOrNull { !it.hasPermission.value }
}
override fun requestPermission() {
getLatestDeclinedRequest()?.requestPermission()
}
}
val LocalPermissionRequest: ProvidableCompositionLocal<PermissionRequest> =
staticCompositionLocalOf { throw IllegalStateException("request must be initialized here") }
fun ComponentActivity.newPermissionRequest(): PermissionRequest {
val list = mutableListOf(
ReadContactsPermissionRequest(this),
ReadCallLogPermissionRequest(this),
newAnswerPhonePermissionRequest(),
ReadPhoneStatePermissionRequest(this),
)
return MultiplePermissionRequest(list)
}
abstract class AndroidPermissionRequest(
private val activity: ComponentActivity,
private val permission: String,
hasPermissionCallback: () -> Boolean = {
ContextCompat.checkSelfPermission(
activity,
permission
) == PackageManager.PERMISSION_GRANTED
}
) : MutablePermissionRequest(hasPermissionCallback) {
private val launcher =
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) {
hasPermission.tryEmit(it)
}
override fun requestPermissionInternal() {
launcher.launch(permission)
}
protected fun getString(code: Int, vararg args: Any): String {
return activity.getString(code, args)
}
}
class ReadContactsPermissionRequest(
activity: ComponentActivity
) : AndroidPermissionRequest(activity, Manifest.permission.READ_CONTACTS) {
override fun getDescription(): String = getString(R.string.read_contacts_desc)
override fun getTitle(): String = getString(R.string.read_contacts)
}
class ReadCallLogPermissionRequest(
activity: ComponentActivity
) : AndroidPermissionRequest(activity, Manifest.permission.READ_CALL_LOG) {
override fun getDescription(): String = getString(R.string.read_call_log_desc)
override fun getTitle(): String = getString(R.string.read_call_log)
}
class ReadPhoneStatePermissionRequest(
activity: ComponentActivity
) : AndroidPermissionRequest(activity, Manifest.permission.READ_PHONE_STATE) {
override fun getDescription(): String = getString(R.string.read_phone_state_desc)
override fun getTitle(): String = getString(R.string.read_phone_state)
}
private fun ComponentActivity.newAnswerPhonePermissionRequest(): PermissionRequest {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
AnswerPhoneCallPermissionRequest(this)
} else {
CallPhonePermissionRequest(this)
}
}
class CallPhonePermissionRequest(
activity: ComponentActivity
) : AndroidPermissionRequest(activity, Manifest.permission.CALL_PHONE) {
override fun getDescription(): String = getString(R.string.answer_phone_calls_desc)
override fun getTitle(): String = getString(R.string.answer_phone_calls)
}
@RequiresApi(Build.VERSION_CODES.O)
class AnswerPhoneCallPermissionRequest(
activity: ComponentActivity
) : AndroidPermissionRequest(activity, Manifest.permission.ANSWER_PHONE_CALLS) {
override fun getDescription(): String = getString(R.string.answer_phone_calls_desc)
override fun getTitle(): String = getString(R.string.answer_phone_calls)
}
@@ -0,0 +1,159 @@
package ru.shadowsparky.callblocker.presentation.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import ru.shadowsparky.callblocker.R
import ru.shadowsparky.callblocker.domain.RejectPolicy
import ru.shadowsparky.callblocker.domain.RejectPolicyManager
import ru.shadowsparky.ui.components.ListPreference
@Composable
fun PolicyContent(policyManager: RejectPolicyManager) {
val state = policyManager.flow.collectAsState(null)
if (state.value != null) {
InitializedPolicyContent(state, policyManager, LocalPermissionRequest.current)
}
}
@Preview(showBackground = true)
@Composable
fun InitializedPolicyScreenPreview() {
val state = remember { mutableStateOf<RejectPolicy>(RejectPolicy.None) }
InitializedPolicyContent(
state = state,
manager = object : RejectPolicyManager {
override val flow = MutableStateFlow<RejectPolicy>(RejectPolicy.None)
override suspend fun set(newPolicy: RejectPolicy) = flow.emit(newPolicy)
override suspend fun get(): RejectPolicy = flow.value
},
MultiplePermissionRequest(
listOf(
NoOpPermissionRequest("1"),
NoOpPermissionRequest("2"),
NoOpPermissionRequest("3")
)
)
)
}
private class NoOpPermissionRequest(private val prefix: String) : PermissionRequest {
override val hasPermission = MutableStateFlow(false)
override fun getTitle(): String {
return "$prefix noop"
}
override fun getDescription(): String {
return "$prefix noop"
}
override fun requestPermission() {
hasPermission.tryEmit(true)
}
}
@Composable
private fun InitializedPolicyContent(
state: State<RejectPolicy?>,
manager: RejectPolicyManager,
permissionRequest: PermissionRequest
) {
val coroutineScope = rememberCoroutineScope()
Column {
val items = RejectPolicy.values.associateWith { it.getTitle() }
val item = remember { mutableStateOf(state.value!!) }
val hasPermission by permissionRequest.hasPermission.collectAsState()
if (!hasPermission) {
MissingPermissionCard(
permissionRequest,
modifier = Modifier.padding(4.dp),
)
}
ListPreference(
title = stringResource(id = R.string.selected_policy),
items = items,
selectedItem = item.value,
onItemSelected = {
item.value = it
coroutineScope.launch {
manager.set(it)
}
}
)
}
}
@Composable
private fun RejectPolicy.getTitle(): String {
val code = when (this) {
RejectPolicy.All -> R.string.reject_all
RejectPolicy.NonContacts -> R.string.reject_non_contacts
RejectPolicy.None -> R.string.reject_none
}
return stringResource(code)
}
@Preview
@Composable
fun MissingPermissionCardPreview() {
MissingPermissionCard(NoOpPermissionRequest("1"))
}
@Composable
fun MissingPermissionCard(
request: PermissionRequest,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(
stringResource(id = R.string.warning),
style = MaterialTheme.typography.titleMedium
)
Text(
stringResource(
id = R.string.mission_permission_text,
request.getTitle(),
request.getDescription()
)
)
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = { request.requestPermission() },
) {
Text(stringResource(id = R.string.grant_permission))
}
}
}
}
}
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</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.464"
android:scaleY="0.464"
android:translateX="6.432"
android:translateY="6.432">
<path
android:fillColor="@android:color/white"
android:pathData="M13,9h-2v2h2L13,9zM17,9h-2v2h2L17,9zM20,15.5c-1.25,0 -2.45,-0.2 -3.57,-0.57 -0.35,-0.11 -0.74,-0.03 -1.02,0.24l-2.2,2.2c-2.83,-1.44 -5.15,-3.75 -6.59,-6.58l2.2,-2.21c0.28,-0.27 0.36,-0.66 0.25,-1.01C8.7,6.45 8.5,5.25 8.5,4c0,-0.55 -0.45,-1 -1,-1L4,3c-0.55,0 -1,0.45 -1,1 0,9.39 7.61,17 17,17 0.55,0 1,-0.45 1,-1v-3.5c0,-0.55 -0.45,-1 -1,-1zM19,9v2h2L21,9h-2z" />
</group>
</vector>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="@android:color/white"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:scaleX="0.464"
android:scaleY="0.464"
android:translateX="6.432"
android:translateY="6.432">
<path
android:fillColor="@android:color/white"
android:pathData="M13,9h-2v2h2L13,9zM17,9h-2v2h2L17,9zM20,15.5c-1.25,0 -2.45,-0.2 -3.57,-0.57 -0.35,-0.11 -0.74,-0.03 -1.02,0.24l-2.2,2.2c-2.83,-1.44 -5.15,-3.75 -6.59,-6.58l2.2,-2.21c0.28,-0.27 0.36,-0.66 0.25,-1.01C8.7,6.45 8.5,5.25 8.5,4c0,-0.55 -0.45,-1 -1,-1L4,3c-0.55,0 -1,0.45 -1,1 0,9.39 7.61,17 17,17 0.55,0 1,-0.45 1,-1v-3.5c0,-0.55 -0.45,-1 -1,-1zM19,9v2h2L21,9h-2z" />
</group>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</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,22 @@
<resources>
<string name="app_name">Блокировка звонков</string>
<string name="permission_rejected">Права не выданы</string>
<string name="reject_none">Выключено</string>
<string name="reject_all">Отменять любые звонки</string>
<string name="reject_non_contacts">Отменять звонки не из контактов</string>
<string name="selected_policy">Режим работы</string>
<string name="read_contacts">чтения контактов</string>
<string name="read_contacts_desc">распознавания находится ли номер в списке контактов или нет</string>
<string name="warning">Внимание!</string>
<string name="mission_permission_text">Не выданы права для %1$s. Приложению эти права необходимы для %2$s. Пожалуйста, выдайте права, иначе приложение может работать некорректно"</string>
<string name="read_call_log_desc">просмотра номера звонящего</string>
<string name="read_call_log">просмотра информации о звонке</string>
<string name="read_phone_state_desc">получения уведомлений о звонках</string>
<string name="read_phone_state">чтение состояния телефона</string>
<string name="answer_phone_calls_desc">сброса входящего звонка</string>
<string name="answer_phone_calls">ответа на звонок</string>
<string name="grant_permission">Выдать права</string>
<string name="settings">Настройки</string>
<string name="about_app">О приложении</string>
<string name="version">Версия</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.CallBlocker" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:statusBarColor">@color/black</item>
</style>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,4 @@
keystore.properties
release.keystore
*.keystore
build
@@ -0,0 +1,41 @@
import com.android.build.api.dsl.ApplicationExtension
plugins {
alias(libs.plugins.convention.android.app)
alias(libs.plugins.convention.compose)
alias(libs.plugins.convention.koin)
}
extensions.getByType<ApplicationExtension>().apply {
buildFeatures { buildConfig = true }
namespace = "ru.shadowsparky.experimentarium"
defaultConfig {
applicationId = namespace
minSdk = libs.versions.proj.min.get().toInt()
targetSdk = libs.versions.proj.target.get().toInt()
versionCode = 1000
versionName = "1.0"
}
}
koinCompiler {
compileSafety = false
unsafeDslChecks = false
}
dependencies {
implementation(libs.activity.compose)
implementation(libs.material)
implementation(project(":apps:experimentarium:shared"))
implementation(project(":libs:video-player"))
implementation(project(":libs:ui"))
implementation(project(":libs:base"))
debugImplementation(libs.ui.tooling)
compileOnly(libs.ui.tooling.preview.android)
testImplementation(libs.koin.test)
testImplementation(libs.mockk)
testImplementation(kotlin("test-junit"))
androidTestImplementation(libs.junit)
androidTestImplementation(libs.runner)
}
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Material3.DayNight.NoActionBar">
<activity
android:name=".MainActivity"
android:configChanges="screenSize|screenLayout|orientation|smallestScreenSize"
android:exported="true"
android:launchMode="singleInstance"
android:supportsPictureInPicture="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,18 @@
package ru.shadowsparky.experimentarium
import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.core.annotation.KoinApplication
import org.koin.plugin.module.dsl.startKoin
@KoinApplication
object Experimentarium
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin<Experimentarium> {
androidContext(this@App)
}
}
}
@@ -0,0 +1,15 @@
package ru.shadowsparky.experimentarium
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import ru.shadowsparky.experimentarium.shared.TestPlayer
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TestPlayer()
}
}
}
@@ -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="960"
android:viewportHeight="960">
<group
android:scaleX="0.58"
android:scaleY="0.58"
android:translateX="201.6"
android:translateY="201.6">
<path
android:fillColor="@android:color/white"
android:pathData="M40,720Q49,613 105.5,523Q162,433 256,380L182,252Q176,243 179,233Q182,223 192,218Q200,213 210,216Q220,219 226,228L300,356Q386,320 480,320Q574,320 660,356L734,228Q740,219 750,216Q760,213 768,218Q778,223 781,233Q784,243 778,252L704,380Q798,433 854.5,523Q911,613 920,720L40,720ZM280,610Q301,610 315.5,595.5Q330,581 330,560Q330,539 315.5,524.5Q301,510 280,510Q259,510 244.5,524.5Q230,539 230,560Q230,581 244.5,595.5Q259,610 280,610ZM680,610Q701,610 715.5,595.5Q730,581 730,560Q730,539 715.5,524.5Q701,510 680,510Q659,510 644.5,524.5Q630,539 630,560Q630,581 644.5,595.5Q659,610 680,610Z" />
</group>
</vector>
@@ -0,0 +1,5 @@
<?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" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?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" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Experimentarium</string>
</resources>
+1
View File
@@ -0,0 +1 @@
build
@@ -0,0 +1,20 @@
plugins {
alias(libs.plugins.convention.kmp)
alias(libs.plugins.convention.android.lib)
alias(libs.plugins.convention.compose)
alias(libs.plugins.convention.serialization)
alias(libs.plugins.convention.koin)
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(project(":libs:base"))
implementation(project(":libs:video-player"))
}
androidMain.dependencies {
}
}
android {
namespace = "ru.shadowsparky.experimentarium.shared"
}
}
@@ -0,0 +1,20 @@
package ru.shadowsparky.experimentarium.shared
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import ru.shadowsparky.koin
import ru.shadowsparky.videoplayer.PlayerController
import ru.shadowsparky.videoplayer.ui.VideoPlayer
private val controller = koin.get<PlayerController>()
@Composable
fun TestPlayer() {
VideoPlayer(controller, modifier = Modifier.fillMaxSize())
LaunchedEffect(controller) {
controller.load("https://samplelib.com/mp4/sample-5s.mp4")
controller.play()
}
}
+4
View File
@@ -0,0 +1,4 @@
build
videobox.db
cache
bin
+32
View File
@@ -0,0 +1,32 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
alias(libs.plugins.convention.kmp)
alias(libs.plugins.convention.compose)
alias(libs.plugins.convention.koin)
}
kotlin {
sourceSets {
val wasmJsMain by getting {
dependencies {
implementation(project(":libs:base"))
implementation(project(":libs:ui"))
implementation(project(":libs:video-player"))
implementation(project(":apps:experimentarium:shared"))
}
}
}
wasmJs {
browser {
commonWebpackConfig {
outputFileName = "composeApp.js"
}
}
binaries.executable()
}
}
@@ -0,0 +1,26 @@
package ru.shadowsparky.experimentarium
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import com.arkivanov.essenty.lifecycle.LifecycleRegistry
import com.arkivanov.essenty.lifecycle.doOnResume
import kotlinx.browser.document
import org.koin.core.context.startKoin
import org.koin.ksp.generated.module
import org.w3c.dom.HTMLElement
import ru.shadowsparky.CommonModule
import ru.shadowsparky.experimentarium.shared.TestPlayer
import ru.shadowsparky.ui.hideElementById
import ru.shadowsparky.ui.hookPageVisibility
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
val lifecycle = LifecycleRegistry()
startKoin { modules(CommonModule().module) }
val composeRoot = document.getElementById("compose-root") as HTMLElement
hookPageVisibility(lifecycle)
lifecycle.doOnResume { hideElementById("loader") }
ComposeViewport(viewportContainer = composeRoot) {
TestPlayer()
}
}
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test Layers</title>
<style>
#loader {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background: #111;
z-index: 9999;
}
.spinner {
width: 50px;
height: 50px;
border: 6px solid #ccc;
border-top-color: #09f;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
}
</style>
<script defer src="/composeApp.js"></script>
</head>
<body>
<div id="app-container" style="position:fixed; inset:0; overflow:hidden;">
<div id="loader">
<div class="spinner"></div>
</div>
<div id="video-layer"
style="position:absolute; inset:0; background:#000; visibility: hidden;"></div>
<div id="compose-root" style="position:absolute; inset:0;"></div>
</div>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
/build
/videobox
/bin
back.env
+6
View File
@@ -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"]
+111
View File
@@ -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(" && ")
}
}
+39
View File
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
POSTGRES_DB=videobox
POSTGRES_USER=login
POSTGRES_PASSWORD=password
VIDEOBOX_SERVER_URI=postgres
VIDEOBOX_SECRET=144b5446caea8d7d1a33cc87b0447f3be608f0b12bef3d130ec37264375df912468411e37b432299d7f037db81f099b4b3572d4898fa6cc154e6931d7d12f78f
ALLOW_REGISTRATION=1
+9
View File
@@ -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("&quot;")
?.lastOrNull { it.isNotEmpty() }
?: title?.removeSurrounding("&quot;")
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)
}
}

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