diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7702bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/shared/build +.idea +androidApp/build +cache +.kotlin +kotlin-js-store +release.keystore +keystore.properties diff --git a/README.md b/README.md deleted file mode 100644 index 4844ad4..0000000 --- a/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# android-projects - - - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files -* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin https://gitlab.com/pornking/android-projects.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools - -* [Set up project integrations](https://gitlab.com/pornking/android-projects/-/settings/integrations) - -## Collaborate with your team - -* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/) -* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/) -* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically) -* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/) -* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/) -* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/) -* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/) -* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/) -* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README - -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. diff --git a/apps/call-blocker/.gitignore b/apps/call-blocker/.gitignore new file mode 100644 index 0000000..348c102 --- /dev/null +++ b/apps/call-blocker/.gitignore @@ -0,0 +1,2 @@ +/build +/bin diff --git a/apps/call-blocker/build.gradle.kts b/apps/call-blocker/build.gradle.kts new file mode 100644 index 0000000..05d0cac --- /dev/null +++ b/apps/call-blocker/build.gradle.kts @@ -0,0 +1,46 @@ + +import com.android.build.api.dsl.ApplicationExtension + +plugins { + alias(libs.plugins.convention.android.app) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.koin) +} + +extensions.getByType().apply { + namespace = "ru.shadowsparky.callblocker" + defaultConfig { + applicationId = namespace + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + } + } + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.datastore.preferences) + implementation(libs.koin.android) + implementation(libs.koin.androidx.compose) + implementation(libs.activity.compose) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.appcompat) + + implementation(project(":libs:base")) + implementation(project(":libs:ui")) + + debugImplementation(libs.ui.tooling) + compileOnly(libs.ui.tooling.preview.android) + compileOnly(project(":libs:android-stub")) + testImplementation(libs.koin.test) + testImplementation(libs.mockk) + testImplementation(kotlin("test-junit")) +} diff --git a/apps/call-blocker/src/main/AndroidManifest.xml b/apps/call-blocker/src/main/AndroidManifest.xml new file mode 100644 index 0000000..da8dff8 --- /dev/null +++ b/apps/call-blocker/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/call-blocker/src/main/ic_launcher-playstore.png b/apps/call-blocker/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..01f49b3 Binary files /dev/null and b/apps/call-blocker/src/main/ic_launcher-playstore.png differ diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/AppModule.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/AppModule.kt new file mode 100644 index 0000000..6693d7a --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/AppModule.kt @@ -0,0 +1,24 @@ +package ru.shadowsparky.callblocker + +import android.app.Application +import android.os.Build +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Module +import ru.shadowsparky.callblocker.data.LRejectCallDelegate +import ru.shadowsparky.callblocker.data.PRejectCallDelegate +import ru.shadowsparky.callblocker.domain.RejectCallDelegate + +@Module +@ComponentScan +class AppModule { + + @Factory + fun provideAlwaysRejectCallDelegate(app: Application): RejectCallDelegate { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PRejectCallDelegate(app) + } else { + LRejectCallDelegate(app) + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/ContentProviderContactReader.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/ContentProviderContactReader.kt new file mode 100644 index 0000000..736700c --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/ContentProviderContactReader.kt @@ -0,0 +1,51 @@ +package ru.shadowsparky.callblocker.data + +import android.content.Context +import android.database.Cursor +import android.provider.ContactsContract +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Factory +import ru.shadowsparky.callblocker.domain.ContactEntry +import ru.shadowsparky.callblocker.domain.ContactReader + +@Factory +class ContentProviderContactReader( + private val context: Context +) : ContactReader { + + override suspend fun read(): Set { + val projection = arrayOf( + ContactsContract.Contacts.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER + ) + return withContext(Dispatchers.IO) { + context.contentResolver.query( + ContactsContract.Data.CONTENT_URI, + projection, + null, + null, + null + ).use { queryContacts(it ?: return@withContext emptySet()) } + } + } + + private fun queryContacts(cursor: Cursor): Set { + if (!cursor.moveToFirst()) return emptySet() + val result = mutableSetOf() + do { + val name = cursor.getString(ContactsContract.Contacts.DISPLAY_NAME) + val phone = cursor.getString(ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER) + if (phone != null && name != null) { + result.add(ContactEntry(name, phone)) + } + } while (cursor.moveToNext()) + return result + } + + private fun Cursor.getString(column: String): String? { + val idx = getColumnIndex(column) + if (idx < 0) return null + return getString(idx) + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/LRejectCallDelegate.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/LRejectCallDelegate.kt new file mode 100644 index 0000000..3b91d02 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/LRejectCallDelegate.kt @@ -0,0 +1,28 @@ +package ru.shadowsparky.callblocker.data + +import android.app.Application +import android.content.Context +import android.telephony.TelephonyManager +import android.util.Log +import com.android.internal.telephony.ITelephony +import ru.shadowsparky.callblocker.domain.RejectCallDelegate + +class LRejectCallDelegate(private val app: Application) : RejectCallDelegate { + + override suspend fun reject() { + getTeleService()?.endCall() + } + + private fun getTeleService(): ITelephony? { + val tm = app.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + try { + val c = Class.forName(tm.javaClass.name) + val m = c.getDeclaredMethod("getITelephony") + m.isAccessible = true + return m.invoke(tm) as ITelephony + } catch (e: Exception) { + Log.e("LRejectCallDelegate", "err", e) + } + return null + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/NonContactsRejectCallDelegate.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/NonContactsRejectCallDelegate.kt new file mode 100644 index 0000000..b7848a3 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/NonContactsRejectCallDelegate.kt @@ -0,0 +1,39 @@ +package ru.shadowsparky.callblocker.data + +import android.util.Log +import ru.shadowsparky.callblocker.BuildConfig +import ru.shadowsparky.callblocker.domain.ContactReader +import ru.shadowsparky.callblocker.domain.RejectCallDelegate + +class NonContactsRejectCallDelegate( + private val contactReader: ContactReader, + private val number: String?, + private val wrapper: RejectCallDelegate +) : RejectCallDelegate { + + override suspend fun reject() { + log("reject called with number $number") + if (number == null) { + log("number is null!!! exiting...") + return + } + val contacts = contactReader.read() + log("contacts found ${contacts.joinToString()}") + if (!contacts.any { it.phoneNumber == number }) { + log("calling number not found in contacts. rejecting") + wrapper.reject() + } else { + log("calling number found in contacts!") + } + } + + private fun log(msg: String) { + if (BuildConfig.DEBUG) { + Log.d(TAG, msg) + } + } + + companion object { + private const val TAG = "NonContactsCallDelegate" + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PRejectCallDelegate.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PRejectCallDelegate.kt new file mode 100644 index 0000000..ab5bc51 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PRejectCallDelegate.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.callblocker.data + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.telecom.TelecomManager +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import ru.shadowsparky.callblocker.domain.RejectCallDelegate + +@RequiresApi(Build.VERSION_CODES.P) +class PRejectCallDelegate(private val context: Context) : RejectCallDelegate { + @SuppressLint("MissingPermission") // пофиг + @Suppress("DEPRECATION") // еще больше пофиг, пока работает будем использовать! + override suspend fun reject() { + Log.e("PRejectCallDelegate", "reject called", RuntimeException()) + context.getSystemService()?.endCall() + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PolicyBasedRejectDelegate.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PolicyBasedRejectDelegate.kt new file mode 100644 index 0000000..e070554 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PolicyBasedRejectDelegate.kt @@ -0,0 +1,44 @@ +package ru.shadowsparky.callblocker.data + +import org.koin.core.annotation.Factory +import ru.shadowsparky.callblocker.domain.ContactReader +import ru.shadowsparky.callblocker.domain.RejectCallDelegate +import ru.shadowsparky.callblocker.domain.RejectPolicy +import ru.shadowsparky.callblocker.domain.RejectPolicyManager + +@Factory +class PolicyBasedRejectDelegateFactory( + private val rejectPolicyManager: RejectPolicyManager, + private val contactReader: ContactReader, + private val rejectDelegate: RejectCallDelegate +) { + fun create(callingNumber: String?): PolicyBasedRejectDelegate { + return PolicyBasedRejectDelegate( + rejectPolicyManager, + callingNumber, + contactReader, + rejectDelegate + ) + } +} + +class PolicyBasedRejectDelegate( + private val rejectPolicyManager: RejectPolicyManager, + private val callingNumber: String?, + private val contactReader: ContactReader, + private val rejectDelegate: RejectCallDelegate +) : RejectCallDelegate { + override suspend fun reject() { + val wrapper = when (rejectPolicyManager.get()) { + RejectPolicy.All -> rejectDelegate + RejectPolicy.NonContacts -> NonContactsRejectCallDelegate( + contactReader, + callingNumber, + rejectDelegate + ) + + RejectPolicy.None -> null + } + wrapper?.reject() + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PreferenceRejectPolicyManager.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PreferenceRejectPolicyManager.kt new file mode 100644 index 0000000..bf54cf4 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/data/PreferenceRejectPolicyManager.kt @@ -0,0 +1,27 @@ +package ru.shadowsparky.callblocker.data + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Factory +import ru.shadowsparky.callblocker.domain.RejectPolicy +import ru.shadowsparky.callblocker.domain.RejectPolicyManager +import ru.shadowsparky.domain.KeyValueStorage + +@Factory +class PreferenceRejectPolicyManager( + private val keyValueStorage: KeyValueStorage +) : RejectPolicyManager { + override val flow = keyValueStorage.getInt(POLICY_KEY).map { + RejectPolicy.findByCode(it ?: RejectPolicy.None.code) + } + + override suspend fun set(newPolicy: RejectPolicy) { + keyValueStorage.putInt(POLICY_KEY, newPolicy.code) + } + + override suspend fun get(): RejectPolicy = flow.first() + + companion object { + private const val POLICY_KEY = "policy" + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/ContactReader.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/ContactReader.kt new file mode 100644 index 0000000..c0374c2 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/ContactReader.kt @@ -0,0 +1,11 @@ +package ru.shadowsparky.callblocker.domain + +interface ContactReader { + + suspend fun read(): Set +} + +data class ContactEntry( + val name: String, + val phoneNumber: String +) diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectCallDelegate.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectCallDelegate.kt new file mode 100644 index 0000000..68d0cc6 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectCallDelegate.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.callblocker.domain + +interface RejectCallDelegate { + suspend fun reject() +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectPolicyManager.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectPolicyManager.kt new file mode 100644 index 0000000..cd2d546 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/domain/RejectPolicyManager.kt @@ -0,0 +1,24 @@ +package ru.shadowsparky.callblocker.domain + +import kotlinx.coroutines.flow.Flow + +interface RejectPolicyManager { + val flow: Flow + + suspend fun set(newPolicy: RejectPolicy) + suspend fun get(): RejectPolicy +} + +sealed class RejectPolicy(val code: Int) { + data object None : RejectPolicy(0) + data object All : RejectPolicy(1) + data object NonContacts : RejectPolicy(2) + + companion object { + val values: Set get() = setOf(None, All, NonContacts) + + fun findByCode(code: Int): RejectPolicy { + return values.first { it.code == code } + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/App.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/App.kt new file mode 100644 index 0000000..1e14e72 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/App.kt @@ -0,0 +1,19 @@ +package ru.shadowsparky.callblocker.presentation + +import android.app.Application +import org.koin.android.ext.koin.androidContext +import org.koin.core.annotation.KoinApplication +import org.koin.plugin.module.dsl.startKoin +import ru.shadowsparky.callblocker.AppModule + +@KoinApplication(modules = [AppModule::class]) +object CallBlocker + +class App : Application() { + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@App) + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallReceiver.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallReceiver.kt new file mode 100644 index 0000000..c0b05fc --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallReceiver.kt @@ -0,0 +1,32 @@ +package ru.shadowsparky.callblocker.presentation + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.telephony.TelephonyManager +import android.util.Log +import ru.shadowsparky.callblocker.BuildConfig + +class RejectCallReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val keys = intent?.extras?.keySet() ?: return + val values = keys.map { it to intent.extras?.get(it).toString() } + + if (BuildConfig.DEBUG) { + Log.d("RejectCallReceiver", "start") + values.forEach { + Log.d("RejectCallReceiver", "key ${it.first}; value: ${it.second}") + } + Log.d("RejectCallReceiver", "end") + } + + if (intent.action == TelephonyManager.ACTION_PHONE_STATE_CHANGED && context != null) { + val state = intent.getStringExtra(TelephonyManager.EXTRA_STATE) + if (state == TelephonyManager.EXTRA_STATE_RINGING) { + @Suppress("DEPRECATION") + val number = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER) + RejectCallWorker.start(context, number) + } + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallWorker.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallWorker.kt new file mode 100644 index 0000000..c9063f7 --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/RejectCallWorker.kt @@ -0,0 +1,36 @@ +package ru.shadowsparky.callblocker.presentation + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import ru.shadowsparky.callblocker.data.PolicyBasedRejectDelegateFactory +import ru.shadowsparky.koin + +class RejectCallWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + private val delegateFactory by koin.inject() + + override suspend fun doWork(): Result { + val callingNumber = inputData.getString(NUMBER_KEY) + delegateFactory.create(callingNumber).reject() + return Result.success() + } + + companion object { + private const val NUMBER_KEY = "number" + + fun start(context: Context?, number: String?) { + context ?: return + val wm = WorkManager.getInstance(context) + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(NUMBER_KEY to number)) + .build() + wm.enqueue(request) + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/MainActivity.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/MainActivity.kt new file mode 100644 index 0000000..f6e1f8d --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/MainActivity.kt @@ -0,0 +1,42 @@ +package ru.shadowsparky.callblocker.presentation.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import ru.shadowsparky.callblocker.R +import ru.shadowsparky.callblocker.domain.RejectPolicyManager +import ru.shadowsparky.koin +import ru.shadowsparky.ui.BaseScreen + +class MainActivity : ComponentActivity() { + private val rejectPolicyManager by koin.inject() + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val permissionRequest = newPermissionRequest() + setContent { + BaseScreen( + appBar = { + TopAppBar(title = { Text(stringResource(R.string.app_name)) }) + } + ) { + CompositionLocalProvider(LocalPermissionRequest provides permissionRequest) { + Box(modifier = Modifier.padding(it)) { + PolicyContent(rejectPolicyManager) + } + } + } + } + } +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PermissionRequest.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PermissionRequest.kt new file mode 100644 index 0000000..0ce66db --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PermissionRequest.kt @@ -0,0 +1,147 @@ +package ru.shadowsparky.callblocker.presentation.ui + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.core.content.ContextCompat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import ru.shadowsparky.callblocker.R + +interface PermissionRequest { + val hasPermission: StateFlow + + fun getTitle(): String + + fun getDescription(): String + + fun requestPermission() +} + +abstract class MutablePermissionRequest(private val hasPermissionCallback: () -> Boolean) : + PermissionRequest { + override val hasPermission = MutableStateFlow(hasPermissionCallback()) + + init { + initState() + } + + override fun requestPermission() { + if (!hasPermission.value) requestPermissionInternal() + } + + private fun initState() { + if (!hasPermission.value) { + this.hasPermission.tryEmit(hasPermissionCallback()) + } + } + + protected abstract fun requestPermissionInternal() +} + +class MultiplePermissionRequest(private val requests: List) : PermissionRequest { + override val hasPermission + get() = getLatestDeclinedRequest()?.hasPermission ?: MutableStateFlow(true) + + override fun getTitle(): String { + return getLatestDeclinedRequest()?.getTitle() ?: "null" + } + + override fun getDescription(): String { + return getLatestDeclinedRequest()?.getDescription() ?: "null" + } + + private fun getLatestDeclinedRequest(): PermissionRequest? { + return requests.firstOrNull { !it.hasPermission.value } + } + + override fun requestPermission() { + getLatestDeclinedRequest()?.requestPermission() + } +} + +val LocalPermissionRequest: ProvidableCompositionLocal = + staticCompositionLocalOf { throw IllegalStateException("request must be initialized here") } + +fun ComponentActivity.newPermissionRequest(): PermissionRequest { + val list = mutableListOf( + ReadContactsPermissionRequest(this), + ReadCallLogPermissionRequest(this), + newAnswerPhonePermissionRequest(), + ReadPhoneStatePermissionRequest(this), + ) + return MultiplePermissionRequest(list) +} + +abstract class AndroidPermissionRequest( + private val activity: ComponentActivity, + private val permission: String, + hasPermissionCallback: () -> Boolean = { + ContextCompat.checkSelfPermission( + activity, + permission + ) == PackageManager.PERMISSION_GRANTED + } +) : MutablePermissionRequest(hasPermissionCallback) { + private val launcher = + activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { + hasPermission.tryEmit(it) + } + + override fun requestPermissionInternal() { + launcher.launch(permission) + } + + protected fun getString(code: Int, vararg args: Any): String { + return activity.getString(code, args) + } +} + +class ReadContactsPermissionRequest( + activity: ComponentActivity +) : AndroidPermissionRequest(activity, Manifest.permission.READ_CONTACTS) { + override fun getDescription(): String = getString(R.string.read_contacts_desc) + override fun getTitle(): String = getString(R.string.read_contacts) +} + +class ReadCallLogPermissionRequest( + activity: ComponentActivity +) : AndroidPermissionRequest(activity, Manifest.permission.READ_CALL_LOG) { + override fun getDescription(): String = getString(R.string.read_call_log_desc) + override fun getTitle(): String = getString(R.string.read_call_log) +} + +class ReadPhoneStatePermissionRequest( + activity: ComponentActivity +) : AndroidPermissionRequest(activity, Manifest.permission.READ_PHONE_STATE) { + override fun getDescription(): String = getString(R.string.read_phone_state_desc) + override fun getTitle(): String = getString(R.string.read_phone_state) +} + +private fun ComponentActivity.newAnswerPhonePermissionRequest(): PermissionRequest { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AnswerPhoneCallPermissionRequest(this) + } else { + CallPhonePermissionRequest(this) + } +} + +class CallPhonePermissionRequest( + activity: ComponentActivity +) : AndroidPermissionRequest(activity, Manifest.permission.CALL_PHONE) { + override fun getDescription(): String = getString(R.string.answer_phone_calls_desc) + override fun getTitle(): String = getString(R.string.answer_phone_calls) +} + +@RequiresApi(Build.VERSION_CODES.O) +class AnswerPhoneCallPermissionRequest( + activity: ComponentActivity +) : AndroidPermissionRequest(activity, Manifest.permission.ANSWER_PHONE_CALLS) { + override fun getDescription(): String = getString(R.string.answer_phone_calls_desc) + override fun getTitle(): String = getString(R.string.answer_phone_calls) +} diff --git a/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PolicyScreen.kt b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PolicyScreen.kt new file mode 100644 index 0000000..dbf3d0d --- /dev/null +++ b/apps/call-blocker/src/main/kotlin/ru/shadowsparky/callblocker/presentation/ui/PolicyScreen.kt @@ -0,0 +1,159 @@ +package ru.shadowsparky.callblocker.presentation.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import ru.shadowsparky.callblocker.R +import ru.shadowsparky.callblocker.domain.RejectPolicy +import ru.shadowsparky.callblocker.domain.RejectPolicyManager +import ru.shadowsparky.ui.components.ListPreference + +@Composable +fun PolicyContent(policyManager: RejectPolicyManager) { + val state = policyManager.flow.collectAsState(null) + if (state.value != null) { + InitializedPolicyContent(state, policyManager, LocalPermissionRequest.current) + } +} + +@Preview(showBackground = true) +@Composable +fun InitializedPolicyScreenPreview() { + val state = remember { mutableStateOf(RejectPolicy.None) } + InitializedPolicyContent( + state = state, + manager = object : RejectPolicyManager { + override val flow = MutableStateFlow(RejectPolicy.None) + override suspend fun set(newPolicy: RejectPolicy) = flow.emit(newPolicy) + override suspend fun get(): RejectPolicy = flow.value + }, + MultiplePermissionRequest( + listOf( + NoOpPermissionRequest("1"), + NoOpPermissionRequest("2"), + NoOpPermissionRequest("3") + ) + ) + ) +} + +private class NoOpPermissionRequest(private val prefix: String) : PermissionRequest { + override val hasPermission = MutableStateFlow(false) + + override fun getTitle(): String { + return "$prefix noop" + } + + override fun getDescription(): String { + return "$prefix noop" + } + + override fun requestPermission() { + hasPermission.tryEmit(true) + } +} + +@Composable +private fun InitializedPolicyContent( + state: State, + manager: RejectPolicyManager, + permissionRequest: PermissionRequest +) { + val coroutineScope = rememberCoroutineScope() + Column { + val items = RejectPolicy.values.associateWith { it.getTitle() } + val item = remember { mutableStateOf(state.value!!) } + val hasPermission by permissionRequest.hasPermission.collectAsState() + if (!hasPermission) { + MissingPermissionCard( + permissionRequest, + modifier = Modifier.padding(4.dp), + ) + } + ListPreference( + title = stringResource(id = R.string.selected_policy), + items = items, + selectedItem = item.value, + onItemSelected = { + item.value = it + coroutineScope.launch { + manager.set(it) + } + } + ) + } +} + +@Composable +private fun RejectPolicy.getTitle(): String { + val code = when (this) { + RejectPolicy.All -> R.string.reject_all + RejectPolicy.NonContacts -> R.string.reject_non_contacts + RejectPolicy.None -> R.string.reject_none + } + return stringResource(code) +} + +@Preview +@Composable +fun MissingPermissionCardPreview() { + MissingPermissionCard(NoOpPermissionRequest("1")) +} + +@Composable +fun MissingPermissionCard( + request: PermissionRequest, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + stringResource(id = R.string.warning), + style = MaterialTheme.typography.titleMedium + ) + Text( + stringResource( + id = R.string.mission_permission_text, + request.getTitle(), + request.getDescription() + ) + ) + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = { request.requestPermission() }, + ) { + Text(stringResource(id = R.string.grant_permission)) + } + } + } + } +} diff --git a/apps/call-blocker/src/main/res/drawable/ic_launcher_background.xml b/apps/call-blocker/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/apps/call-blocker/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/call-blocker/src/main/res/drawable/ic_launcher_foreground.xml b/apps/call-blocker/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..cb40282 --- /dev/null +++ b/apps/call-blocker/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/call-blocker/src/main/res/drawable/ic_launcher_monochrome.xml b/apps/call-blocker/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..1305eda --- /dev/null +++ b/apps/call-blocker/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/apps/call-blocker/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..26260d8 Binary files /dev/null and b/apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..4e3e8be Binary files /dev/null and b/apps/call-blocker/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/call-blocker/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/call-blocker/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..06aeb53 Binary files /dev/null and b/apps/call-blocker/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/call-blocker/src/main/res/mipmap-mdpi/ic_launcher_round.png b/apps/call-blocker/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..350fdef Binary files /dev/null and b/apps/call-blocker/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/call-blocker/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/call-blocker/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..58a414b Binary files /dev/null and b/apps/call-blocker/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/call-blocker/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/apps/call-blocker/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..daefa2a Binary files /dev/null and b/apps/call-blocker/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/call-blocker/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/call-blocker/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..bbd7f15 Binary files /dev/null and b/apps/call-blocker/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/call-blocker/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/call-blocker/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b713377 Binary files /dev/null and b/apps/call-blocker/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/call-blocker/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/call-blocker/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..1b797c6 Binary files /dev/null and b/apps/call-blocker/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/call-blocker/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/call-blocker/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..f631722 Binary files /dev/null and b/apps/call-blocker/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/call-blocker/src/main/res/values/colors.xml b/apps/call-blocker/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/apps/call-blocker/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/apps/call-blocker/src/main/res/values/ic_launcher_background.xml b/apps/call-blocker/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/apps/call-blocker/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/call-blocker/src/main/res/values/strings.xml b/apps/call-blocker/src/main/res/values/strings.xml new file mode 100644 index 0000000..03dd6e0 --- /dev/null +++ b/apps/call-blocker/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + Блокировка звонков + Права не выданы + Выключено + Отменять любые звонки + Отменять звонки не из контактов + Режим работы + чтения контактов + распознавания находится ли номер в списке контактов или нет + Внимание! + Не выданы права для %1$s. Приложению эти права необходимы для %2$s. Пожалуйста, выдайте права, иначе приложение может работать некорректно" + просмотра номера звонящего + просмотра информации о звонке + получения уведомлений о звонках + чтение состояния телефона + сброса входящего звонка + ответа на звонок + Выдать права + Настройки + О приложении + Версия + \ No newline at end of file diff --git a/apps/call-blocker/src/main/res/values/themes.xml b/apps/call-blocker/src/main/res/values/themes.xml new file mode 100644 index 0000000..1de0704 --- /dev/null +++ b/apps/call-blocker/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/call-blocker/src/main/res/xml/backup_rules.xml b/apps/call-blocker/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/apps/call-blocker/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/apps/call-blocker/src/main/res/xml/data_extraction_rules.xml b/apps/call-blocker/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/apps/call-blocker/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/apps/experimentarium/androidApp/.gitignore b/apps/experimentarium/androidApp/.gitignore new file mode 100644 index 0000000..0265f6b --- /dev/null +++ b/apps/experimentarium/androidApp/.gitignore @@ -0,0 +1,4 @@ +keystore.properties +release.keystore +*.keystore +build \ No newline at end of file diff --git a/apps/experimentarium/androidApp/build.gradle.kts b/apps/experimentarium/androidApp/build.gradle.kts new file mode 100644 index 0000000..372f29e --- /dev/null +++ b/apps/experimentarium/androidApp/build.gradle.kts @@ -0,0 +1,41 @@ + +import com.android.build.api.dsl.ApplicationExtension + +plugins { + alias(libs.plugins.convention.android.app) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.koin) +} + +extensions.getByType().apply { + buildFeatures { buildConfig = true } + namespace = "ru.shadowsparky.experimentarium" + defaultConfig { + applicationId = namespace + minSdk = libs.versions.proj.min.get().toInt() + targetSdk = libs.versions.proj.target.get().toInt() + versionCode = 1000 + versionName = "1.0" + } +} + +koinCompiler { + compileSafety = false + unsafeDslChecks = false +} + +dependencies { + implementation(libs.activity.compose) + implementation(libs.material) + implementation(project(":apps:experimentarium:shared")) + implementation(project(":libs:video-player")) + implementation(project(":libs:ui")) + implementation(project(":libs:base")) + debugImplementation(libs.ui.tooling) + compileOnly(libs.ui.tooling.preview.android) + testImplementation(libs.koin.test) + testImplementation(libs.mockk) + testImplementation(kotlin("test-junit")) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.runner) +} diff --git a/apps/experimentarium/androidApp/src/main/AndroidManifest.xml b/apps/experimentarium/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2d9509d --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/apps/experimentarium/androidApp/src/main/ic_launcher-playstore.png b/apps/experimentarium/androidApp/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..5495ac5 Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/ic_launcher-playstore.png differ diff --git a/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/App.kt b/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/App.kt new file mode 100644 index 0000000..be32409 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/App.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.experimentarium + +import android.app.Application +import org.koin.android.ext.koin.androidContext +import org.koin.core.annotation.KoinApplication +import org.koin.plugin.module.dsl.startKoin + +@KoinApplication +object Experimentarium + +class App : Application() { + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@App) + } + } +} diff --git a/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/MainActivity.kt b/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/MainActivity.kt new file mode 100644 index 0000000..f34e550 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/kotlin/ru/shadowsparky/experimentarium/MainActivity.kt @@ -0,0 +1,15 @@ +package ru.shadowsparky.experimentarium + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import ru.shadowsparky.experimentarium.shared.TestPlayer + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TestPlayer() + } + } +} diff --git a/apps/experimentarium/androidApp/src/main/res/drawable/ic_launcher_foreground.xml b/apps/experimentarium/androidApp/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..336f278 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..4d409ae Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4c1c37a Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..d0b9b90 Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f3ed8e7 Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..27e1836 Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4a0e201 Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..d240462 Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b62cf81 Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..805c281 Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/apps/experimentarium/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/apps/experimentarium/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..189157d Binary files /dev/null and b/apps/experimentarium/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/apps/experimentarium/androidApp/src/main/res/values/ic_launcher_background.xml b/apps/experimentarium/androidApp/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/experimentarium/androidApp/src/main/res/values/strings.xml b/apps/experimentarium/androidApp/src/main/res/values/strings.xml new file mode 100644 index 0000000..13c85a8 --- /dev/null +++ b/apps/experimentarium/androidApp/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Experimentarium + \ No newline at end of file diff --git a/apps/experimentarium/shared/.gitignore b/apps/experimentarium/shared/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/apps/experimentarium/shared/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/apps/experimentarium/shared/build.gradle.kts b/apps/experimentarium/shared/build.gradle.kts new file mode 100644 index 0000000..bdb2e8b --- /dev/null +++ b/apps/experimentarium/shared/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) +} +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":libs:base")) + implementation(project(":libs:video-player")) + } + androidMain.dependencies { + } + } + android { + namespace = "ru.shadowsparky.experimentarium.shared" + } +} diff --git a/apps/experimentarium/shared/src/commonMain/kotlin/ru/shadowsparky/experimentarium/shared/Test.kt b/apps/experimentarium/shared/src/commonMain/kotlin/ru/shadowsparky/experimentarium/shared/Test.kt new file mode 100644 index 0000000..7edff23 --- /dev/null +++ b/apps/experimentarium/shared/src/commonMain/kotlin/ru/shadowsparky/experimentarium/shared/Test.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.experimentarium.shared + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import ru.shadowsparky.koin +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.ui.VideoPlayer + +private val controller = koin.get() + +@Composable +fun TestPlayer() { + VideoPlayer(controller, modifier = Modifier.fillMaxSize()) + LaunchedEffect(controller) { + controller.load("https://samplelib.com/mp4/sample-5s.mp4") + controller.play() + } +} diff --git a/apps/experimentarium/web/.gitignore b/apps/experimentarium/web/.gitignore new file mode 100644 index 0000000..51ebf63 --- /dev/null +++ b/apps/experimentarium/web/.gitignore @@ -0,0 +1,4 @@ +build +videobox.db +cache +bin diff --git a/apps/experimentarium/web/build.gradle.kts b/apps/experimentarium/web/build.gradle.kts new file mode 100644 index 0000000..0bcd67e --- /dev/null +++ b/apps/experimentarium/web/build.gradle.kts @@ -0,0 +1,32 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.koin) +} + +kotlin { + + sourceSets { + val wasmJsMain by getting { + dependencies { + implementation(project(":libs:base")) + implementation(project(":libs:ui")) + implementation(project(":libs:video-player")) + implementation(project(":apps:experimentarium:shared")) + } + } + } + + wasmJs { + browser { + commonWebpackConfig { + outputFileName = "composeApp.js" + } + } + binaries.executable() + } +} diff --git a/apps/experimentarium/web/src/wasmJsMain/kotlin/ru/shadowsparky/experimentarium/Main.kt b/apps/experimentarium/web/src/wasmJsMain/kotlin/ru/shadowsparky/experimentarium/Main.kt new file mode 100644 index 0000000..b0b7aa8 --- /dev/null +++ b/apps/experimentarium/web/src/wasmJsMain/kotlin/ru/shadowsparky/experimentarium/Main.kt @@ -0,0 +1,26 @@ +package ru.shadowsparky.experimentarium + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.doOnResume +import kotlinx.browser.document +import org.koin.core.context.startKoin +import org.koin.ksp.generated.module +import org.w3c.dom.HTMLElement +import ru.shadowsparky.CommonModule +import ru.shadowsparky.experimentarium.shared.TestPlayer +import ru.shadowsparky.ui.hideElementById +import ru.shadowsparky.ui.hookPageVisibility + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + val lifecycle = LifecycleRegistry() + startKoin { modules(CommonModule().module) } + val composeRoot = document.getElementById("compose-root") as HTMLElement + hookPageVisibility(lifecycle) + lifecycle.doOnResume { hideElementById("loader") } + ComposeViewport(viewportContainer = composeRoot) { + TestPlayer() + } +} diff --git a/apps/experimentarium/web/src/wasmJsMain/resources/index.html b/apps/experimentarium/web/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..450b55d --- /dev/null +++ b/apps/experimentarium/web/src/wasmJsMain/resources/index.html @@ -0,0 +1,51 @@ + + + + + Test Layers + + + + +
+
+
+
+ +
+
+ + diff --git a/apps/vbox/backend/.gitignore b/apps/vbox/backend/.gitignore new file mode 100644 index 0000000..dd86a94 --- /dev/null +++ b/apps/vbox/backend/.gitignore @@ -0,0 +1,4 @@ +/build +/videobox +/bin +back.env \ No newline at end of file diff --git a/apps/vbox/backend/Dockerfile b/apps/vbox/backend/Dockerfile new file mode 100644 index 0000000..d8d5e16 --- /dev/null +++ b/apps/vbox/backend/Dockerfile @@ -0,0 +1,6 @@ +FROM bellsoft/liberica-openjdk-alpine:21 + +WORKDIR /usr/local/app +COPY ./build/libs/server-fat-1.0.jar ./server-fat-1.0.jar +EXPOSE 8181 +CMD ["java", "-jar", "server-fat-1.0.jar"] diff --git a/apps/vbox/backend/build.gradle.kts b/apps/vbox/backend/build.gradle.kts new file mode 100644 index 0000000..6673620 --- /dev/null +++ b/apps/vbox/backend/build.gradle.kts @@ -0,0 +1,111 @@ +plugins { + alias(libs.plugins.convention.backend.app) + alias(libs.plugins.ktor) + alias(libs.plugins.sqldelight) + alias(libs.plugins.serialization) + alias(libs.plugins.convention.koin) +} + +sqldelight { + databases { + create("AppDatabase") { + packageName.set("ru.shadowsparky.vbox.backend") + dialect("app.cash.sqldelight:postgresql-dialect:2.0.2") + } + } +} + +dependencies { + implementation(project(":apps:vbox:common")) + implementation(project(":libs:backend-base")) + implementation(project(":libs:base")) + implementation(project(":libs:http-client")) + + implementation(libs.ktor.server.core.jvm) + implementation(libs.ktor.server.host.common.jvm) + implementation(libs.ktor.server.status.pages.jvm) + implementation(libs.ktor.server.content.negotiation.jvm) + implementation(libs.ktor.serialization.kotlinx.json.jvm) + implementation(libs.ktor.server.netty.jvm) + implementation(libs.ktor.server.auth) + implementation(libs.ktor.server.auth.jwt) + implementation(libs.ktor.server.websockets.jvm) + implementation(libs.ktor.server.cors) + + implementation(libs.androidx.datastore.preferences.core) + implementation(libs.kxml2) + implementation(libs.jsoup) + implementation(libs.jdbc.driver) + implementation(libs.postgresql) + implementation(libs.coroutines.extensions) + + testImplementation(libs.koin.test) + testImplementation(libs.mockk) + testImplementation(libs.junit) +} + +java { + sourceCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get()) +} + +group = "ru.shadowsparky.vbox" +version = "1.0" + +val mainClassName = "ru.shadowsparky.vbox.backend.MainKt" + +application { + mainClass.set(mainClassName) + + val isDevelopment: Boolean = project.ext.has("development") + applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") +} + +tasks { + register("fatJar", Jar::class) { + archiveBaseName = "server-fat" + manifest { + attributes["Implementation-Title"] = "Videobox Server" + attributes["Implementation-Version"] = version + attributes["Main-Class"] = mainClassName + } + from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) + duplicatesStrategy = DuplicatesStrategy.WARN + with(jar.get() as CopySpec) + } + + "build" { + dependsOn("fatJar") + } + + register("deployRemote") { + dependsOn("fatJar") + commandLine( + getCommandLine( + "docker context use remote", + "docker build . -t videobox-server", + "docker context use default" + ) + ) + notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache") + } + + register("deployLocal") { + dependsOn("fatJar") + commandLine( + getCommandLine( + "docker compose down", + "docker compose up -d --build --force-recreate" + ) + ) + notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache") + } +} +private fun getCommandLine(vararg commands: String): List { + val osName = System.getProperty("os.name").lowercase() + return if (osName.contains("win")) { + listOf("cmd", "/c") + commands.joinToString(" && ") + } else { + listOf("sh", "-c") + commands.joinToString(" && ") + } +} diff --git a/apps/vbox/backend/docker-compose.yml b/apps/vbox/backend/docker-compose.yml new file mode 100644 index 0000000..ff2f4b2 --- /dev/null +++ b/apps/vbox/backend/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3' + +services: + postgres: + image: postgres:16-alpine + restart: always + env_file: + - postgres.env + ports: + - "5432:5432" + networks: + - serv-network + volumes: + - pgdata:/var/lib/postgresql/data + - ./src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq:/docker-entrypoint-initdb.d/init.sql:ro + + videobox: + container_name: videobox + build: + context: . + dockerfile: Dockerfile + env_file: + - postgres.env + - back.env + ports: + - 8181:8181 + restart: always + volumes: + - ./videobox/cache/:/usr/local/app/cache:rw + networks: + - serv-network + depends_on: + - postgres + +networks: + serv-network: + driver: bridge +volumes: + pgdata: diff --git a/apps/vbox/backend/postgres.env b/apps/vbox/backend/postgres.env new file mode 100644 index 0000000..19a03ff --- /dev/null +++ b/apps/vbox/backend/postgres.env @@ -0,0 +1,8 @@ +POSTGRES_DB=videobox +POSTGRES_USER=login +POSTGRES_PASSWORD=password + +VIDEOBOX_SERVER_URI=postgres +VIDEOBOX_SECRET=144b5446caea8d7d1a33cc87b0447f3be608f0b12bef3d130ec37264375df912468411e37b432299d7f037db81f099b4b3572d4898fa6cc154e6931d7d12f78f + +ALLOW_REGISTRATION=1 diff --git a/apps/vbox/backend/readme.md b/apps/vbox/backend/readme.md new file mode 100644 index 0000000..170bc66 --- /dev/null +++ b/apps/vbox/backend/readme.md @@ -0,0 +1,9 @@ +# Videobox Server + +## Deploy + +### Remote + +Сначала нужно добавить remote context +`docker context create remote --docker "host=ssh://root@localhost"`. +Далее публикация происходит таской gradle: `gradle :apps:vbox:backend:deployRemote` diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt new file mode 100644 index 0000000..04ab935 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/BackendModule.kt @@ -0,0 +1,35 @@ +package ru.shadowsparky.vbox.backend + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.backend.domain.JwtInfo +import ru.shadowsparky.vbox.backend.di.DbModule +import ru.shadowsparky.vbox.backend.di.EventModule +import ru.shadowsparky.vbox.backend.di.HttpModule + +@Module +class VboxModule { + + @Single + fun provideJwtInfo(envFetcher: EnvFetcher): JwtInfo { + return JwtInfo( + envFetcher.get("VIDEOBOX_SECRET", "secret"), + "https://shadowsparky.ru/", + "https://box.shadowsparky.ru/", + "server side data storage" + ) + } +} + +@Module( + includes = [ + DbModule::class, + HttpModule::class, + EventModule::class, + VboxModule::class + ] +) +@ComponentScan +class BackendModule diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt new file mode 100644 index 0000000..2ba644c --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/Main.kt @@ -0,0 +1,27 @@ +package ru.shadowsparky.vbox.backend + +import io.ktor.server.application.Application +import org.koin.core.annotation.KoinApplication +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.plugin.module.dsl.startKoin +import ru.shadowsparky.vbox.backend.presentation.configureJwt +import ru.shadowsparky.vbox.backend.presentation.configureRouting +import ru.shadowsparky.vbox.backend.presentation.configureSerialization +import ru.shadowsparky.vbox.backend.presentation.configureWebSocket + +@KoinApplication +object VBoxBackend + +fun main(args: Array) { + startKoin() + io.ktor.server.netty.EngineMain.main(args) +} + +fun Application.module() { + val koin = object : KoinComponent {} + configureSerialization() + configureJwt(koin.get()) + configureWebSocket(koin.get()) + configureRouting(koin.get(), koin.get()) +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRecentlyWatchedRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRecentlyWatchedRepository.kt new file mode 100644 index 0000000..5a76984 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRecentlyWatchedRepository.kt @@ -0,0 +1,103 @@ +package ru.shadowsparky.vbox.backend.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo +import java.sql.SQLException + +class BackendRecentlyWatchedRepository( + private val db: AppDatabase, + private val userId: Long, + private val dispatcherProvider: DispatcherProvider, + private val remoteEventHandler: RemoteEventHandler +) : RecentlyWatchedRepository { + + override suspend fun remove(info: RecentlyWatchedInfo) = + withContext(dispatcherProvider.io) { + db.recentQueries.deleteRwByIds(info.movieId, info.episode, info.season, userId) + notifyChanged(info.movieId) + } + + override suspend fun write(info: RecentlyWatchedInfo) = + withContext(dispatcherProvider.io) { + try { + db.recentQueries.insertRecentlyWatched( + userId, + info.movieId, + info.episode, + info.season + ) + notifyChanged(info.movieId) + } catch (ignored: SQLException) { + } + } + + suspend fun toggleInfo(info: RecentlyWatchedInfo) = + withContext(dispatcherProvider.io) { + val hasInfo = + db.recentQueries.selectByIds(info.movieId, info.episode, info.season, userId) + .executeAsOneOrNull() != null + if (hasInfo) { + remove(info) + } else { + write(info) + } + } + + override fun queryRecentlyWatched( + movieId: Long, + seasonId: Long? + ): Flow> = flow { + val rsp = if (seasonId == null) { + db.recentQueries.selectByMovieId(movieId, userId) + .executeAsList() + .map { + RecentlyWatchedInfo( + it.movie_id, + it.episode, + it.season + ) + } + } else { + db.recentQueries.selectByMovieIdAndSeason(movieId, userId, seasonId) + .executeAsList() + .map { + RecentlyWatchedInfo( + it.movie_id, + it.episode, + seasonId + ) + } + } + emit(rsp) + } + + override suspend fun getAllRecentlyWatched(): List { + return withContext(dispatcherProvider.io) { + db.recentQueries.selectAll(userId).executeAsList() + .map { + RecentlyWatchedInfo( + it.movie_id, + it.episode, + it.season + ) + } + } + } + + private suspend fun notifyChanged(seasonId: Long) { + remoteEventHandler.notify( + RemoteEvent.OnRecent( + System.currentTimeMillis(), + userId, + seasonId + ) + ) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRemoteEventHandler.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRemoteEventHandler.kt new file mode 100644 index 0000000..453c86a --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendRemoteEventHandler.kt @@ -0,0 +1,43 @@ +package ru.shadowsparky.vbox.backend.data + +import kotlinx.serialization.json.Json +import org.koin.core.annotation.Factory +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler + +val eventLogger: Logger = LoggerFactory.getLogger("events") + +@Factory +class BackendRemoteEventHandler( + private val json: Json, + private val sessionCache: SessionCache +) : RemoteEventHandler { + + suspend fun put(userId: Long, socketSession: SessionCache.Writer) { + sessionCache.put(userId, socketSession) + } + + suspend fun remove(userId: Long, socketSession: SessionCache.Writer) { + sessionCache.remove(userId, socketSession) + } + + override suspend fun notify(eventInfo: RemoteEvent) { + val json = json.encodeToString(eventInfo) + val writers = sessionCache.get(eventInfo.userId) + if (writers.isNullOrEmpty()) { + eventLogger.info("unable to notify $eventInfo. sessions not found, cache $sessionCache") + } else { + writers.forEach { + eventLogger.info("notify[$eventInfo]. session $it", RuntimeException("called")) + try { + it.writeText(json) + } catch (_: Exception) { + eventLogger.error("unable to notify $it. delete session") + remove(eventInfo.userId, it) + } + } + } + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSavedMovieRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSavedMovieRepository.kt new file mode 100644 index 0000000..a772d28 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSavedMovieRepository.kt @@ -0,0 +1,79 @@ +package ru.shadowsparky.vbox.backend.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import ru.shadowsparky.backend.data.Logger +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.server.Saved_movie +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository +import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails +import java.sql.SQLException + +class BackendSavedMovieRepository( + private val db: AppDatabase, + private val userId: Long, + private val dispatcherProvider: DispatcherProvider, + private val logger: Logger, + private val eventHandler: RemoteEventHandler +) : SavedMovieRepository { + + override fun getAll(): Flow> { + val request = db.saved_movieQueries.selectByUserId(userId) + return flow { + emit(request.executeAsList().parse().reversed()) + } + } + + private suspend fun List.parse(): List = + withContext(dispatcherProvider.io) { + mapNotNull { + val movie = db.movieQueries.selectMovie(it.movie_id) + .executeAsOneOrNull() ?: return@mapNotNull null + VideoDetails( + movie.movie_id, + movie.poster_url, + movie.description, + movie.title, + (movie.flags and SERIAL_FLAG) != 0 + ) + } + } + + override suspend fun save(details: VideoDetails) { + logger.debug(TAG, "save(${details.id}) ${details.title}") + withContext(dispatcherProvider.io) { + try { + db.saved_movieQueries.addSavedMovie( + details.id, + userId + ) + eventHandler.notify(RemoteEvent.OnSaved(userId, details.id)) + } catch (_: SQLException) { + } + } + } + + override fun isSaved(id: Long): Flow { + return flow { + val request = db.saved_movieQueries.selectByUserIdAndMovieId(userId, id) + emit(request.executeAsOneOrNull() != null) + } + } + + override suspend fun remove(id: Long) { + logger.debug(TAG, "remove(${id})") + withContext(dispatcherProvider.io) { + db.saved_movieQueries.removeSavedMovie(userId, id) + eventHandler.notify(RemoteEvent.OnSaved(userId, id)) + } + } + + private companion object { + const val TAG = "BackendSavedMovieRepository" + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSearchRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSearchRepository.kt new file mode 100644 index 0000000..b1caec9 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/BackendSearchRepository.kt @@ -0,0 +1,64 @@ +package ru.shadowsparky.vbox.backend.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.SearchRepository +import java.sql.SQLException + +class BackendSearchRepository( + private val db: AppDatabase, + private val userId: Long, + private val dispatcherProvider: DispatcherProvider, + private val eventHandler: RemoteEventHandler +) : SearchRepository { + + override fun search(query: String): Flow> { + return flow { emit(searchSingle(query)) } + } + + private suspend fun searchSingle(query: String): List = withContext(Dispatchers.IO) { + if (query.trim().isEmpty()) { + db.searchQueries.selectAll(userId) + } else { + db.searchQueries.selectByQuery(query.addWilcard(), userId) + }.executeAsList().map { it.query } + } + + private fun String.addWilcard(): String { + return if (this.endsWith("%")) this else "$this%" + } + + override suspend fun addToSearch(query: String) { + withContext(dispatcherProvider.io) { + try { + db.searchQueries.insertSearchInfo(query, userId) + notifyChanged() + } catch (_: SQLException) { + } + } + } + + override suspend fun deleteFromSearch(query: String) { + withContext(dispatcherProvider.io) { + db.searchQueries.deleteSearchInfo(query, userId) + notifyChanged() + } + } + + override suspend fun clear() { + withContext(dispatcherProvider.io) { + db.searchQueries.deleteAll(userId) + notifyChanged() + } + } + + private suspend fun notifyChanged() { + eventHandler.notify(RemoteEvent.OnSearch(System.currentTimeMillis(), userId)) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/SessionCache.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/SessionCache.kt new file mode 100644 index 0000000..83aa08d --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/SessionCache.kt @@ -0,0 +1,42 @@ +package ru.shadowsparky.vbox.backend.data + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Factory +import java.util.Collections + +typealias SessionMap = MutableMap> + +@Factory +class SessionCache { + private val sessionMap: SessionMap = Collections.synchronizedMap(hashMapOf()) + + private val mutex = Mutex() + + suspend fun put(userId: Long, socketSession: Writer) { + eventLogger.info("put[$userId]=$socketSession") + mutex.withLock { + sessionMap[userId] = (sessionMap[userId] ?: mutableListOf()) + listOf(socketSession) + } + } + + suspend fun get(userId: Long): List? { + mutex.withLock { + return sessionMap[userId] + } + } + + suspend fun remove(userId: Long, session: Writer) { + eventLogger.info("remove[$userId]=$session") + mutex.withLock { + val sessionFromMap = sessionMap[userId]?.toMutableList() + val item = sessionFromMap?.firstOrNull { it == session } ?: return + sessionFromMap.remove(item) + sessionMap[userId] = sessionFromMap + } + } + + fun interface Writer { + suspend fun writeText(text: String) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendAuthTokenRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendAuthTokenRepository.kt new file mode 100644 index 0000000..02d004d --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendAuthTokenRepository.kt @@ -0,0 +1,169 @@ +package ru.shadowsparky.vbox.backend.data.auth + +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Factory +import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.backend.data.JwtPreparer +import ru.shadowsparky.backend.data.StringFetcher +import ru.shadowsparky.backend.data.StringResource +import ru.shadowsparky.backend.domain.LoginVerifier +import ru.shadowsparky.domain.Log +import ru.shadowsparky.http.domain.BadRequestException +import ru.shadowsparky.http.domain.TokenInfo +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.server.Refresh_tokens +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository +import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest +import ru.shadowsparky.vbox.shared.domain.TokenRequest +import ru.shadowsparky.vbox.shared.domain.model.LoginInfo +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.concurrent.TimeUnit +import kotlin.io.encoding.Base64 + +@Factory +class AuthTokenRepositoryFactory( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + private val jwtPreparer: JwtPreparer, + private val loginVerifier: LoginVerifier, + private val log: Log, + private val stringFetcher: StringFetcher, + private val envFetcher: EnvFetcher +) { + + fun create(userId: Long = -1): AuthTokenRepository { + return BackendAuthTokenRepository( + db, + dispatcherProvider, + jwtPreparer, + loginVerifier, + userId, + log, + stringFetcher, + envFetcher + ) + } +} + +class BackendAuthTokenRepository( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + private val jwtPreparer: JwtPreparer, + private val loginVerifier: LoginVerifier, + private val userId: Long, + private val log: Log, + private val stringFetcher: StringFetcher, + private val envFetcher: EnvFetcher +) : AuthTokenRepository { + private val random = SecureRandom() + + override suspend fun register(loginInfo: LoginInfo): TokenInfo { + return if (envFetcher.get("ALLOW_REGISTRATION", "0") == "1") { + registerInternal(loginInfo) + } else { + throw BadRequestException(stringFetcher.get(StringResource.REG_BLOCKED)) + } + } + + private suspend fun registerInternal(loginInfo: LoginInfo): TokenInfo { + return withContext(dispatcherProvider.io) { + val user = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull() + if (user != null) throw BadRequestException(stringFetcher.get(StringResource.ALREADY_REGISTERED)) + db.usersQueries.addUser( + loginInfo.login, loginInfo.passwordHash, System.currentTimeMillis(), null + ) + TokenInfo("") + } + } + + override suspend fun login(loginInfo: LoginInfo): TokenInfo = + withContext(dispatcherProvider.io) { + val userInfo = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull() + ?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER)) + if (loginInfo.passwordHash != userInfo.password_hash) { + throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER)) + } + create(loginInfo.login) + } + + override suspend fun update(refresh: TokenRequest): TokenInfo { + val info = db.refresh_tokensQueries.selectByHash(refresh.token.toHash()) + .executeAsOneOrNull() + ?: throw BadRequestException("Token not found") + val userInfo = db.usersQueries.selectUserByUserId(info.user_id).executeAsOneOrNull() + ?: throw BadRequestException("User not found") + val newTokens = create(userInfo.login) + revokeInternal(refresh) + return newTokens + } + + override suspend fun revoke(tokenRequest: TokenRequest) { + revokeInternal(tokenRequest) + } + + private suspend fun revokeInternal(request: TokenRequest) { + log.d("BackendAuthTokenRepository", "revoke ${request.token.toHash()}") + val info = getTokenAndCheck(request) + db.refresh_tokensQueries.revokeToken(info.token_id).await() + } + + private suspend fun getTokenAndCheck(tokenRequest: TokenRequest): Refresh_tokens = + withContext(dispatcherProvider.io) { + val info = db.refresh_tokensQueries.selectByHash(tokenRequest.token.toHash()) + .executeAsOneOrNull() + ?: throw BadRequestException("Token not found") + if (info.revoked) throw BadRequestException("Token already revoked") + if (info.expires_at < System.currentTimeMillis()) { + throw BadRequestException( + "Refresh token expired. " + + "Expires at ${info.expires_at} < ${System.currentTimeMillis()}" + ) + } + info + } + + private suspend fun create(login: String): TokenInfo = withContext(dispatcherProvider.io) { + loginVerifier.verify(login) + val userInfo = db.usersQueries.selectUserByLogin(login).executeAsOneOrNull() + ?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER)) + val rsp = jwtPreparer.prepare(login, userInfo.user_id) + val refresh = ByteArray(32) + random.nextBytes(refresh) + val refreshStr = refresh.toHexString() + db.refresh_tokensQueries.insertToken( + userInfo.user_id, + refreshStr.toHash(), + System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30) + ).executeAsOneOrNull() + log.d( + "BackendAuthTokenRepository", + "${refreshStr.toHash()} created for user ${userInfo.user_id}" + ) + TokenInfo(rsp, refreshStr) + } + + override suspend fun changePassword(request: ChangePasswordRequest): Unit = + withContext(dispatcherProvider.io) { + val user = db.usersQueries.selectUserByUserId(userId).executeAsOneOrNull() + ?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER)) + if (user.password_hash != request.oldPasswordHash) { + throw BadRequestException(stringFetcher.get(StringResource.INVALID_PASSWORD)) + } else if (user.password_hash == request.newPasswordHash) { + throw BadRequestException(stringFetcher.get(StringResource.NO_CHANGES_PASS)) + } + db.usersQueries.updatePassword(request.newPasswordHash, userId).await() + } + + private fun String.toHash(): String { + val md = MessageDigest.getInstance("SHA-256") + md.update(SALT.toByteArray()) + val result = md.digest(this.toByteArray()) + return Base64.encode(result) + } + + private companion object { + const val SALT = "G0yCPoy7gCj1a5OxPaeYLeJm69NvPQ50HbV0ZVYVqahUPdKun4MKgg86u9HbWq2e" + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendLoginVerifier.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendLoginVerifier.kt new file mode 100644 index 0000000..e640302 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/auth/BackendLoginVerifier.kt @@ -0,0 +1,26 @@ +package ru.shadowsparky.vbox.backend.data.auth + +import org.koin.core.annotation.Factory +import ru.shadowsparky.backend.data.StringFetcher +import ru.shadowsparky.backend.data.StringResource +import ru.shadowsparky.backend.domain.LoginVerifier +import ru.shadowsparky.backend.domain.VerifyTokenException +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.domain.FLAG_USER_BLOCKED + +@Factory +class BackendLoginVerifier( + private val db: AppDatabase, + private val stringFetcher: StringFetcher +) : LoginVerifier { + override suspend fun verify(login: String) { + val user = db.usersQueries.selectUserByLogin(login) + .executeAsOneOrNull() + ?: throw VerifyTokenException(stringFetcher.get(StringResource.UNKNOWN_USER)) + user.flags?.let { + if ((it and FLAG_USER_BLOCKED) != 0) { + throw VerifyTokenException(stringFetcher.get(StringResource.USER_BLOCKED)) + } + } + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendApi.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendApi.kt new file mode 100644 index 0000000..6c14d39 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendApi.kt @@ -0,0 +1,137 @@ +package ru.shadowsparky.vbox.backend.data.http + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.url +import io.ktor.client.statement.bodyAsText +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.koin.core.annotation.Factory +import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.backend.data.StringFetcher +import ru.shadowsparky.backend.data.StringResource +import ru.shadowsparky.http.domain.NotFoundException +import ru.shadowsparky.vbox.shared.domain.VideoApi +import ru.shadowsparky.vbox.shared.domain.model.Season +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails +import ru.shadowsparky.vbox.shared.domain.model.VideoItem +import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse +import ru.shadowsparky.vbox.shared.domain.model.VideosResponse + +private const val FALLBACK_SEASON_ID = -1L +private const val SEASON_FALLBACK_NAME = "Сезон 1" + +@Factory(binds = [ExternalBackendApi::class]) +class ExternalBackendApi( + private val client: HttpClient, + private val mapper: ExternalBackendMapper, + private val cache: ExternalCache, + private val envFetcher: EnvFetcher, + private val stringFetcher: StringFetcher +) : VideoApi { + private val notFoundException = + NotFoundException(stringFetcher.get(StringResource.VIDEO_NOT_FOUND)) + private val endpoint = envFetcher.get(BACKEND_HOST) + + override suspend fun fetchNewVideos(query: String?): VideosResponse { + return coroutineScope { + val movies = async { fetchFavoritesInternal("movie", query) } + val series = async { fetchFavoritesInternal("series", query) } + val anime = async { fetchFavoritesInternal("anime", query) } + val result = (series.await() + movies.await() + anime.await()) + result.map { + async { cache.put(it) } + }.awaitAll() + return@coroutineScope VideosResponse(false, result, 1) + } + } + + private suspend fun fetchFavoritesInternal(request: String, search: String?): List { + val rsp = client.get { + url("https", endpoint, path = "${envFetcher.get(FAVORITES_PREFIX)}$request") + if (search != null) { + parameter("search", search) + } + } + val rspText = rsp.bodyAsText() + return mapper.movieRspToItems(rspText, request) + } + + override suspend fun fetchDetails(id: Long): VideoDetails { + val info = cache.get(id) ?: throw notFoundException + return VideoDetails( + poster = mapper.mapPoster(info.poster), + id = info.id, + title = info.title, + desc = info.description ?: "", + isSerial = info.isSerial + ) + } + + override suspend fun fetchVideoLinks( + id: Long, + seasonId: Long? + ): VideoLinksResponse { + val isSerial = cache.get(id)?.isSerial ?: throw notFoundException + return if (isSerial) { + val seasons = mutableListOf() + if (seasonId == null) { + getSerialSeasonIds(id).forEach { seasons.add(Season(emptyList(), it.id, it.title)) } + if (seasons.isEmpty()) { + seasons.add(Season(emptyList(), FALLBACK_SEASON_ID, SEASON_FALLBACK_NAME)) + } + } else { + if (seasonId == FALLBACK_SEASON_ID) { + seasons.add(getSerialSeasonFallback(id)) + } else { + val entry = getSerialSeasonIds(id).firstOrNull { it.id == seasonId } + ?: throw notFoundException + seasons.add(getSeason(entry)) + } + } + if (seasons.isEmpty()) throw notFoundException + VideoLinksResponse(seasons = seasons) + } else { + val rsp = client.get { + url("https", endpoint, path = envFetcher.get(LINKS_PATH)) + parameter("id", id) + parameter("movie", "") + }.bodyAsText() + VideoLinksResponse(files = mapper.parseMovie(rsp) ?: throw notFoundException) + } + } + + private suspend fun getSerialSeasonIds(id: Long): List { + val rsp = client.get { + url("https", endpoint, path = envFetcher.get(SEASON_IDS_PATH)) + parameter("id", id) + } + val text = rsp.bodyAsText() + return mapper.categoryRspToSeasonInfo(text) + } + + private suspend fun getSerialSeasonFallback(id: Long): Season { + val rsp = client.get { + url("https", endpoint, path = envFetcher.get(LINKS_PATH)) + parameter("id", id) + } + val text = rsp.bodyAsText() + return mapper.parseSeason( + text, + ExternalBackendMapper.SeasonEntry( + SEASON_FALLBACK_NAME, + FALLBACK_SEASON_ID + ) + ) + } + + private suspend fun getSeason(entry: ExternalBackendMapper.SeasonEntry): Season { + val rsp2 = client.get { + url("https", endpoint, path = envFetcher.get(SEASON_PATH)) + parameter("id", entry.id) + }.bodyAsText() + return mapper.parseSeason(rsp2, entry) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendMapper.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendMapper.kt new file mode 100644 index 0000000..1f94cad --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalBackendMapper.kt @@ -0,0 +1,199 @@ +package ru.shadowsparky.vbox.backend.data.http + +import org.jsoup.Jsoup +import org.koin.core.annotation.Factory +import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.vbox.shared.domain.model.Episode +import ru.shadowsparky.vbox.shared.domain.model.File +import ru.shadowsparky.vbox.shared.domain.model.Season +import ru.shadowsparky.vbox.shared.domain.model.VideoItem +import java.net.URI + +@Factory +class ExternalBackendMapper( + private val envFetcher: EnvFetcher +) { + private val readerFactory: () -> XmlReader = { XmlReader() } + + fun movieRspToItems(rsp: String, rootTag: String): List { + val reader = readerFactory().apply { setInput(rsp) } + return readRoot(reader, rootTag).toItems() + } + + fun categoryRspToSeasonInfo(rsp: String): List { + val reader = readerFactory().apply { setInput(rsp) } + val movies = readRoot(reader, "category").unit + return movies.map { SeasonEntry(it.title!!, it.id.toLong()) } + } + + fun parseSeason(rsp: String, seasonEntry: SeasonEntry): Season { + val reader = readerFactory().apply { setInput(rsp) } + val videos = readRoot(reader, "video").unit + val episodes = mutableListOf() + videos.forEachIndexed { index, hlamerUnit -> + episodes.add(hlamerUnit.toEpisode(index + 1L)) + } + return Season(episodes, seasonEntry.id, seasonEntry.title.parseHtml()) + } + + fun parseMovie(rsp: String): List? { + val reader = readerFactory().apply { setInput(rsp) } + val files = readRoot(reader, "video").unit.firstOrNull() ?: return null + return listOf(File(false, -1, files.file?.loadMp4FromSparky() ?: return null)) + } + + private fun ExternalUnit.toEpisode(epNumber: Long): Episode { + val hasTitle = title?.trim()?.isNotEmpty() == true + val splitTitle = title?.split(""") + ?.lastOrNull { it.isNotEmpty() } + ?: title?.removeSurrounding(""") + val episodeText = "Серия" + return Episode( + epNumber, + listOf(File(false, -1, file!!.loadMp4FromSparky())), + if (hasTitle) "$epNumber. $splitTitle" else "$episodeText $epNumber" + ) + } + + private fun readRoot(reader: XmlReader, rootTag: String): Movie { + reader.require(XmlReader.Tag.START, rootTag) + val entries = mutableListOf() + while (reader.next != XmlReader.Tag.END) { + if (reader.eventType != XmlReader.Tag.START) { + continue + } + if (reader.name == "unit") { + val entry = readUnitEntry(reader) + entries.add(entry) + } + } + return Movie(entries) + } + + private fun readUnitEntry(reader: XmlReader): ExternalUnit { + reader.require(XmlReader.Tag.START, "unit") + val id = reader.getAttributeValue("id") + var title: String? = null + var description: String? = null + var thumb: String? = null + var section: String? = null + var file: String? = null + var image: String? = null + while (reader.next != XmlReader.Tag.END) { + if (reader.eventType != XmlReader.Tag.START) { + continue + } + when (reader.name) { + "title" -> title = readText(reader) + "description" -> description = readText(reader) + "thumb" -> thumb = readText(reader) + "section" -> section = readText(reader) + "file" -> file = readText(reader) + "image" -> image = readText(reader) + else -> readText(reader) // skip + } + } + return ExternalUnit(id, title, description, thumb, section, file, image) + } + + private fun readText(reader: XmlReader): String { + var result = "" + if (reader.next == XmlReader.Tag.TEXT) { + result = reader.text + reader.nextTag() + } + return result + } + + private fun Movie.toItems(): List { + return unit.mapNotNull { it.toVideoItem() } + } + + private fun ExternalUnit.toVideoItem(): VideoItem? { + val poster = thumb?.loadPosterFromSparky() ?: return null + if (poster.endsWith("/_180.jpg")) { + return null + } + val isMovie = section == "movie" + return VideoItem( + id = id.toLong(), + title = title?.parseHtml() ?: return null, + poster = poster, + description = description?.parseHtml(), + isSerial = !isMovie + ) + } + + private fun String.loadMp4FromSparky(): String { + return mapExternalToBox(this) ?: this + } + + private val hostRegex = Regex("""^m(\d+)\.${envFetcher.get(REGEX_HOST)}\.ru$""") + + private fun mapExternalToBox(sourceUrl: String): String? { + val uri = try { + URI(sourceUrl) + } catch (_: Exception) { + return null + } + val host = uri.host ?: return null + + val m = hostRegex.matchEntire(host) ?: return null + val idx = m.groupValues[1] + + val rawPath = uri.rawPath ?: return null + if (!rawPath.startsWith("/video")) return null + + val rest = rawPath.removePrefix("/video") + val newPath = "/video/m$idx$rest" + + return URI( + "https", + null, + "box.shadowsparky.ru", + uri.port, + newPath, + uri.rawQuery, + uri.rawFragment + ).toString() + } + + fun mapPoster(poster: String): String { + return poster.loadPosterFromSparky() + } + + private fun String.loadPosterFromSparky(): String { + val imageEndpoint = envFetcher.get(IMAGE_ENDPOINT) + if (startsWith(imageEndpoint)) { + return replace(imageEndpoint, SPARKY_IMAGE_ENDPOINT) + } + return this + } + + data class SeasonEntry( + val title: String, + val id: Long + ) + + private data class Movie( + val unit: List + ) + + private data class ExternalUnit( + val id: String, + val title: String?, + val description: String?, + val thumb: String?, + val section: String?, + val file: String?, + val image: String? + ) + + private fun String.parseHtml(): String { + return Jsoup.parse(this@parseHtml).wholeText() + } + + companion object { + private const val SPARKY_IMAGE_ENDPOINT = "https://box.shadowsparky.ru/images/" + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalCache.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalCache.kt new file mode 100644 index 0000000..2cf05cc --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalCache.kt @@ -0,0 +1,66 @@ +package ru.shadowsparky.vbox.backend.data.http + +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.model.MOVIE_FLAG +import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG +import ru.shadowsparky.vbox.shared.domain.model.VideoItem + +@Single +class ExternalCache( + private val dispatcherProvider: DispatcherProvider, + private val db: AppDatabase +) { + + suspend fun put(item: VideoItem) { + insertIfNeeded(item) + } + + suspend fun get(id: Long): VideoItem? = queryOrNull(id) + + private suspend fun queryOrNull(id: Long): VideoItem? { + return withContext(dispatcherProvider.io) { + val oldInfo = db.movieQueries.selectMovie(id) + .executeAsOneOrNull() + ?: return@withContext null + VideoItem( + oldInfo.movie_id, + oldInfo.poster_url, + oldInfo.title, + oldInfo.description, + oldInfo.flags == SERIAL_FLAG + ) + } + } + + private suspend fun insertIfNeeded(new: VideoItem) = withContext(dispatcherProvider.io) { + val old = queryOrNull(new.id) + if (old == null) { + db.movieQueries.insertMovie( + new.id, + new.poster, + new.description ?: "", + new.title, + new.serializeFlags() + ) + } else if (old != new) { + db.movieQueries.updateMovie( + new.poster, + new.description ?: "", + new.title, + new.serializeFlags(), + new.id + ) + } + } + + private fun VideoItem.serializeFlags(): Int { + return if (isSerial) { + SERIAL_FLAG + } else { + MOVIE_FLAG + } + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalConst.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalConst.kt new file mode 100644 index 0000000..bb2cfa4 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/ExternalConst.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.vbox.backend.data.http + +const val REGEX_HOST = "EXTERNAL_HOST" +const val IMAGE_ENDPOINT = "EXTERNAL_IMAGE_ENDPOINT" +const val CACHE_DIR = "EXTERNAL_CACHE_DIR" +const val BACKEND_HOST = "EXTERNAL_BACKEND_HOST" +const val FAVORITES_PREFIX = "EXTERNAL_FAVORITES_PREFIX" +const val LINKS_PATH = "EXTERNAL_LINKS_PATH" +const val SEASON_IDS_PATH = "EXTERNAL_SEASON_IDS_PATH" +const val SEASON_PATH = "EXTERNAL_SEASON_PATH" diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/XmlReader.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/XmlReader.kt new file mode 100644 index 0000000..9413588 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/http/XmlReader.kt @@ -0,0 +1,61 @@ +package ru.shadowsparky.vbox.backend.data.http + +import org.kxml2.io.KXmlParser +import org.xmlpull.v1.XmlPullParser +import java.io.StringReader + +class XmlReader { + private var parser: XmlPullParser? = null + + val name: String + get() = parser!!.name + val eventType: Tag + get() = parser!!.eventType.fromType() + val next: Tag + get() = parser!!.next().fromType() + + fun setInput(raw: String) { + parser = KXmlParser().apply { + setInput(StringReader(raw)) + nextTag() + } + } + + fun require( + tag: Tag, + name: String + ) { + parser?.require(tag.toType(), null, name) + } + + fun Tag.toType(): Int { + return when (this) { + Tag.START -> XmlPullParser.START_TAG + Tag.END -> XmlPullParser.END_TAG + Tag.TEXT -> XmlPullParser.TEXT + } + } + + fun Int.fromType(): Tag { + return when (this) { + XmlPullParser.START_TAG -> Tag.START + XmlPullParser.END_TAG -> Tag.END + else -> Tag.TEXT + } + } + + fun getAttributeValue(name: String): String { + return parser!!.getAttributeValue(null, name) + } + + fun nextTag() { + parser!!.nextTag() + } + + enum class Tag { + START, END, TEXT + } + + val text: String + get() = parser!!.text +} \ No newline at end of file diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendMovieTagRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendMovieTagRepository.kt new file mode 100644 index 0000000..b4028a1 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendMovieTagRepository.kt @@ -0,0 +1,64 @@ +package ru.shadowsparky.vbox.backend.data.tags + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.domain.DispatcherProvider +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository +import ru.shadowsparky.vbox.shared.domain.tag.TagInfo + +@Factory +class MovieTagRepositoryFactory( + private val appDatabase: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + @Named(EventType.MOVIE_TAG) private val movieEventHandler: RemoteEventHandler +) { + fun create(userId: Long): MovieTagRepository { + return BackendMovieTagRepository( + appDatabase, + dispatcherProvider, + userId, + movieEventHandler + ) + } +} + +class BackendMovieTagRepository( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + private val userId: Long, + private val movieEventHandler: RemoteEventHandler +) : MovieTagRepository { + private val dbQueries get() = db.tagsQueries + + override fun query(movieId: Long): Flow> = dbQueries + .getTagsForMovie(movieId, userId) + .asFlow() + .mapToList(dispatcherProvider.io) + .map { list -> + list.map { row -> TagInfo(id = row.id, tag = row.tag) } + } + + override fun queryMovies(tagId: Long): Flow> = dbQueries + .getMoviesByTagId(tagId, userId) + .asFlow() + .mapToList(dispatcherProvider.io) + .map { list -> list.toSet() } + + override suspend fun link(movieId: Long, tagId: Long) { + dbQueries.linkMovieTag(movieId, tagId, userId).await() + movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId)) + } + + override suspend fun unlink(movieId: Long, tagId: Long) { + dbQueries.unlinkMovieTag(movieId, tagId, userId).await() + movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId)) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendUserTagRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendUserTagRepository.kt new file mode 100644 index 0000000..84ddb59 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/tags/BackendUserTagRepository.kt @@ -0,0 +1,72 @@ +package ru.shadowsparky.vbox.backend.data.tags + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEvent +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.tag.TagInfo +import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository + +@Factory +class UserTagRepositoryFactory( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + @Named(EventType.USER_TAG) private val userTagEventHandler: RemoteEventHandler +) { + fun create(userId: Long): UserTagRepository { + return BackendUserTagRepository( + userId, + db, + dispatcherProvider, + userTagEventHandler + ) + } +} + +class BackendUserTagRepository( + private val userId: Long, + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + private val userTagEventHandler: RemoteEventHandler +) : UserTagRepository { + private val queries get() = db.tagsQueries + + override val userTags: Flow> by lazy { + queries.getUserTagsByUserId(user_id = userId) + .asFlow() + .mapToList(dispatcherProvider.io) + .map { list -> + list.map { row -> TagInfo(id = row.id, tag = row.tag) } + } + } + + override suspend fun add(tag: String) { + queries.insertUserTag(userId, tag).await() + userTagEventHandler.notify(RemoteEvent.OnUserTag(userId)) + } + + override suspend fun edit(id: Long, newTag: String) { + checkOwner(id) + queries.updateTagText(newTag, id).await() + userTagEventHandler.notify(RemoteEvent.OnUserTag(userId)) + } + + override suspend fun delete(id: Long) { + checkOwner(id) + queries.deleteTagById(id).await() + userTagEventHandler.notify(RemoteEvent.OnUserTag(userId)) + } + + private suspend fun checkOwner(id: Long) = withContext(dispatcherProvider.io) { + val ownerId = queries.getUserIdByTagId(id).executeAsOneOrNull() + check(ownerId == userId) { "tag owner mismatch. owner: $ownerId, current user: $userId" } + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/DbModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/DbModule.kt new file mode 100644 index 0000000..a37a3fd --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/DbModule.kt @@ -0,0 +1,33 @@ +package ru.shadowsparky.vbox.backend.di + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.asJdbcDriver +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.postgresql.ds.PGSimpleDataSource +import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.vbox.backend.AppDatabase + +@Module +class DbModule { + + @Factory + fun provideSqlDriver(envFetcher: EnvFetcher): SqlDriver { + return PGSimpleDataSource().apply { + val db = envFetcher.get("POSTGRES_DB", "videobox") + val uri = envFetcher.get("VIDEOBOX_SERVER_URI", "localhost") + val login = envFetcher.get("POSTGRES_USER", "login") + val pass = envFetcher.get("POSTGRES_PASSWORD", "password") + + setUrl("jdbc:postgresql://$uri:5432/$db") + user = login + password = pass + }.asJdbcDriver() + } + + @Single + fun provideDb(driver: SqlDriver): AppDatabase { + return AppDatabase.Companion.invoke(driver) + } +} \ No newline at end of file diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt new file mode 100644 index 0000000..2b0458a --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt @@ -0,0 +1,46 @@ +package ru.shadowsparky.vbox.backend.di + +import kotlinx.serialization.json.Json +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import ru.shadowsparky.backend.data.TokenVerifier +import ru.shadowsparky.backend.domain.JwtInfo +import ru.shadowsparky.backend.domain.LoginVerifier +import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler +import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory +import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory +import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory +import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory +import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory +import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.VideoApi + +@Single +class RoutingEntryPoint( + val videoApi: VideoApi, + val searchFactory: SearchRepositoryFactory, + val recentlyFactory: RecentlyWatchedRepositoryFactory, + val savedMovieFactory: SavedMovieRepositoryFactory, + val json: Json, + val movieTagFactory: MovieTagRepositoryFactory, + val userTagFactory: UserTagRepositoryFactory +) + +@Single +class WebSocketEntryPoint( + @Named(EventType.SEARCH) val searchEventHandler: BackendRemoteEventHandler, + @Named(EventType.RECENT) val recentlyEventHandler: BackendRemoteEventHandler, + @Named(EventType.SAVED) val savedEventHandler: BackendRemoteEventHandler, + @Named(EventType.USER_TAG) val userTagEventHandler: BackendRemoteEventHandler, + @Named(EventType.MOVIE_TAG) val movieTagEventHandler: BackendRemoteEventHandler, + val tokenVerifier: TokenVerifier +) + +@Single +class AuthEntryPoint( + val loginVerifier: LoginVerifier, + val authTokenRepositoryFactory: AuthTokenRepositoryFactory, + val jwtInfo: JwtInfo, + val tokenVerifier: TokenVerifier +) diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EventModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EventModule.kt new file mode 100644 index 0000000..5f5c361 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EventModule.kt @@ -0,0 +1,32 @@ +package ru.shadowsparky.vbox.backend.di + +import org.koin.core.annotation.Module +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler + +@Module +class EventModule { + + @Single(binds = [BackendRemoteEventHandler::class]) + @Named(EventType.SEARCH) + fun provideSearch(impl: BackendRemoteEventHandler): RemoteEventHandler = impl + + @Single(binds = [BackendRemoteEventHandler::class]) + @Named(EventType.RECENT) + fun provideRecent(impl: BackendRemoteEventHandler): RemoteEventHandler = impl + + @Single(binds = [BackendRemoteEventHandler::class]) + @Named(EventType.SAVED) + fun provideSaved(impl: BackendRemoteEventHandler): RemoteEventHandler = impl + + @Single(binds = [BackendRemoteEventHandler::class]) + @Named(EventType.USER_TAG) + fun provideUserTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl + + @Single(binds = [BackendRemoteEventHandler::class]) + @Named(EventType.MOVIE_TAG) + fun provideMovieTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt new file mode 100644 index 0000000..428b284 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/HttpModule.kt @@ -0,0 +1,13 @@ +package ru.shadowsparky.vbox.backend.di + +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Module +import ru.shadowsparky.vbox.backend.data.http.ExternalBackendApi +import ru.shadowsparky.vbox.shared.domain.VideoApi + +@Module +class HttpModule { + + @Factory + fun provideVideoApi(impl: ExternalBackendApi): VideoApi = impl +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt new file mode 100644 index 0000000..9fcbb7b --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/RecentlyWatchedRepositoryFactory.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.vbox.backend.di.factory + +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.backend.data.BackendRecentlyWatchedRepository +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler + +@Factory +class RecentlyWatchedRepositoryFactory( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + @Named(EventType.RECENT) private val remoteEventHandler: RemoteEventHandler +) { + fun create(userId: Long): BackendRecentlyWatchedRepository { + return BackendRecentlyWatchedRepository(db, userId, dispatcherProvider, remoteEventHandler) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt new file mode 100644 index 0000000..f60b076 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SavedMovieRepositoryFactory.kt @@ -0,0 +1,23 @@ +package ru.shadowsparky.vbox.backend.di.factory + +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.backend.data.Logger +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler +import ru.shadowsparky.vbox.backend.data.BackendSavedMovieRepository +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository + +@Factory +class SavedMovieRepositoryFactory( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + private val logger: Logger, + @Named(EventType.SAVED) private val eventHandler: BackendRemoteEventHandler +) { + fun create(userId: Long): SavedMovieRepository { + return BackendSavedMovieRepository(db, userId, dispatcherProvider, logger, eventHandler) + } +} \ No newline at end of file diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SearchRepositoryFactory.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SearchRepositoryFactory.kt new file mode 100644 index 0000000..97336e5 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/factory/SearchRepositoryFactory.kt @@ -0,0 +1,21 @@ +package ru.shadowsparky.vbox.backend.di.factory + +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.vbox.backend.AppDatabase +import ru.shadowsparky.vbox.backend.data.BackendSearchRepository +import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import ru.shadowsparky.vbox.shared.domain.SearchRepository + +@Factory +class SearchRepositoryFactory( + private val db: AppDatabase, + private val dispatcherProvider: DispatcherProvider, + @Named(EventType.SEARCH) private val eventHandler: RemoteEventHandler +) { + fun create(userId: Long): SearchRepository { + return BackendSearchRepository(db, userId, dispatcherProvider, eventHandler) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Jwt.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Jwt.kt new file mode 100644 index 0000000..e805ba5 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Jwt.kt @@ -0,0 +1,70 @@ +package ru.shadowsparky.vbox.backend.presentation + +import com.auth0.jwt.JWT +import com.auth0.jwt.interfaces.DecodedJWT +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.jwt.jwt +import io.ktor.server.response.respond +import ru.shadowsparky.backend.domain.INVALID_TOKEN_MSG +import ru.shadowsparky.backend.domain.LOGIN_NAME +import ru.shadowsparky.backend.domain.VerifyTokenException +import ru.shadowsparky.vbox.backend.di.AuthEntryPoint +import ru.shadowsparky.vbox.shared.domain.USER_ID_ARG +import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo + +const val AUTH_JWT_NAME = "auth-jwt" + +fun Application.configureJwt(authEntryPoint: AuthEntryPoint) { + install(Authentication) { + jwt(AUTH_JWT_NAME) { + val jwtInfo = authEntryPoint.jwtInfo + realm = jwtInfo.realm + verifier(authEntryPoint.tokenVerifier.verifier) + validate { credential -> + val tokenRepo = authEntryPoint.loginVerifier + val login = credential.payload.getClaim(LOGIN_NAME).asString() + try { + if (login != null) { + tokenRepo.verify(login) + if (credential.payload.expiresAt == null) { + throw VerifyTokenException("Static tokens not supported!") + } + JWTPrincipal(credential.payload) + } else { + null + } + } catch (e: VerifyTokenException) { + routingLogger.error("verify token failed ${e.message}") + null + } + } + challenge { defaultScheme, realm -> + call.respond( + HttpStatusCode.Unauthorized, + ServerExceptionInfo(INVALID_TOKEN_MSG) + ) + } + } + } +} + +private fun String?.obtainToken(): DecodedJWT { + val token = this?.removePrefix("Bearer ") + ?: throw VerifyTokenException(INVALID_TOKEN_MSG) + return JWT.decode(token) +} + +fun ApplicationCall.obtainToken(): DecodedJWT { + return request.headers["Authorization"].obtainToken() +} + +fun ApplicationCall.obtainUserId(): Long { + return obtainToken() + .getClaim(USER_ID_ARG) + .asLong() +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt new file mode 100644 index 0000000..336c515 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Routing.kt @@ -0,0 +1,44 @@ +package ru.shadowsparky.vbox.backend.presentation + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.response.respond +import io.ktor.server.routing.routing +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import ru.shadowsparky.http.domain.HttpException +import ru.shadowsparky.vbox.backend.di.AuthEntryPoint +import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint +import ru.shadowsparky.vbox.backend.presentation.routing.setupAuthMethods +import ru.shadowsparky.vbox.backend.presentation.routing.setupBaseMethods +import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo + +val routingLogger: Logger = LoggerFactory.getLogger("routing") + +fun Application.configureRouting( + routingEntryPoint: RoutingEntryPoint, + authEntryPoint: AuthEntryPoint +) { + install(StatusPages) { + exception { call, cause -> + routingLogger.error("http exception occurred. returns ${cause.httpCode}", cause) + call.respond( + status = HttpStatusCode.fromValue(cause.httpCode), + message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка") + ) + } + exception { call, cause -> + routingLogger.error("error occurred. returns 500...", cause) + call.respond( + status = HttpStatusCode.InternalServerError, + message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка") + ) + } + } + routing { + setupBaseMethods(routingEntryPoint.videoApi) + setupAuthMethods(routingEntryPoint, authEntryPoint) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Serialization.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Serialization.kt new file mode 100644 index 0000000..bcd316d --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/Serialization.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.vbox.backend.presentation + +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation + +fun Application.configureSerialization() { + install(ContentNegotiation) { + json() + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt new file mode 100644 index 0000000..078b42a --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/WebSocket.kt @@ -0,0 +1,67 @@ +package ru.shadowsparky.vbox.backend.presentation + +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.routing.Route +import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.pingPeriod +import io.ktor.server.websocket.timeout +import io.ktor.server.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import kotlinx.coroutines.CompletableDeferred +import ru.shadowsparky.backend.data.TokenVerifier +import ru.shadowsparky.backend.domain.USER_ID_ARG +import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler +import ru.shadowsparky.vbox.backend.data.SessionCache +import ru.shadowsparky.vbox.backend.di.WebSocketEntryPoint +import ru.shadowsparky.vbox.shared.domain.EventType +import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler +import kotlin.time.Duration.Companion.seconds + +fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with(socketEntryPoint) { + install(WebSockets) { + pingPeriod = (15).seconds + timeout = (15).seconds + maxFrameSize = Long.MAX_VALUE + masking = false + } + routing { + hashMapOf( + EventType.SEARCH to searchEventHandler, + EventType.RECENT to recentlyEventHandler, + EventType.SAVED to savedEventHandler, + EventType.USER_TAG to userTagEventHandler, + EventType.MOVIE_TAG to movieTagEventHandler + ).forEach { (key, handler) -> webSocket(key, handler, tokenVerifier) } + } +} + +private fun Route.webSocket( + eventType: String, + handler: BackendRemoteEventHandler, + tokenVerifier: TokenVerifier +) { + val path = when (eventType) { + EventType.SEARCH -> RemoteEventHandler.SEARCH_CHANGED + EventType.RECENT -> RemoteEventHandler.RECENTLY_CHANGED + EventType.SAVED -> RemoteEventHandler.SAVED_CHANGED + EventType.USER_TAG -> RemoteEventHandler.USER_TAG_CHANGED + EventType.MOVIE_TAG -> RemoteEventHandler.MOVIE_TAG_CHANGED + else -> error("Unsupported type $eventType") + } + webSocket(path) { + val frame = (incoming.receive() as Frame.Text).readText() + val userId = tokenVerifier.verify(frame).getClaim(USER_ID_ARG).asLong() + val session = SessionCache.Writer { text -> outgoing.trySend(Frame.Text(text)) } + handler.put(userId, session) + val deferred = CompletableDeferred() + try { + outgoing.invokeOnClose { deferred.complete(null) } + deferred.await() + } finally { + handler.remove(userId, session) + } + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt new file mode 100644 index 0000000..c79825b --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt @@ -0,0 +1,50 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.authenticate +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Routing +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import ru.shadowsparky.vbox.backend.di.AuthEntryPoint +import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint +import ru.shadowsparky.vbox.backend.presentation.AUTH_JWT_NAME +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository +import ru.shadowsparky.vbox.shared.domain.HeathCheck + +fun Routing.setupAuthMethods( + routingEntryPoint: RoutingEntryPoint, + authEntryPoint: AuthEntryPoint +) = with(routingEntryPoint) { + val default = authEntryPoint.authTokenRepositoryFactory.create() + post(AuthTokenRepository.REGISTER_PATH) { + call.respond(default.register(call.receive())) + } + post(AuthTokenRepository.LOGIN_PATH) { + call.respond(default.login(call.receive())) + } + post(AuthTokenRepository.UPDATE_PATH) { + call.respond(default.update(call.receive())) + } + post(AuthTokenRepository.REVOKE_PATH) { + default.revoke(call.receive()) + call.respond(HttpStatusCode.OK) + } + authenticate(AUTH_JWT_NAME) { + + get("/chech") { call.respond(HttpStatusCode.OK) } + get(HeathCheck.PATH) { call.respond(HttpStatusCode.OK) } + + post(AuthTokenRepository.CHANGE_PASS_PATH) { + authEntryPoint.authTokenRepositoryFactory.create(call.obtainUserId()) + .changePassword(call.receive()) + call.respond(HttpStatusCode.OK) + } + setupSearch(searchFactory) + setupRecentlyWatched(recentlyFactory) + setupSavedMovie(savedMovieFactory) + setupTagsRouting(userTagFactory, movieTagFactory) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt new file mode 100644 index 0000000..833bb44 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/BaseRouting.kt @@ -0,0 +1,32 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respond +import io.ktor.server.routing.Routing +import io.ktor.server.routing.get +import io.ktor.server.util.getOrFail +import ru.shadowsparky.vbox.shared.domain.VideoApi + +fun Routing.setupBaseMethods(api: VideoApi) { + get(VideoApi.VIDEOS_PATH) { + val p = call.parameters + val obj = api.fetchNewVideos(p[VideoApi.SEARCH_ARG]) + call.respond(obj) + } + get(VideoApi.DETAILS_PATH) { + val p = call.parameters + call.respond(api.fetchDetails(p.getOrFail(VideoApi.ID_ARG).toLong())) + } + get(VideoApi.LINKS_PATH) { + val p = call.parameters + call.respond( + api.fetchVideoLinks( + p.getOrFail(VideoApi.ID_ARG).toLong(), + p[VideoApi.SEASON_ID_ARG]?.toLong() + ) + ) + } + get("/") { + call.respond(HttpStatusCode.NotFound) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/RecentlyWatchedRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/RecentlyWatchedRouting.kt new file mode 100644 index 0000000..166f7d1 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/RecentlyWatchedRouting.kt @@ -0,0 +1,48 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import kotlinx.coroutines.flow.first +import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository +import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRequest +import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedResponse + +fun Route.setupRecentlyWatched(recentlyFactory: RecentlyWatchedRepositoryFactory) { + get(RecentlyWatchedRepository.GET_INFO) { + val p = call.parameters + val recentlyWatchedId = p[RecentlyWatchedRepository.MOVIE_ID_ARG]?.toLong() + val seasonId = p[RecentlyWatchedRepository.SEASON_ID_ARG]?.toLong() + val repo = recentlyFactory.create(call.obtainUserId()) + val list = if (recentlyWatchedId != null) { + repo.queryRecentlyWatched(recentlyWatchedId, seasonId).first() + } else { + repo.getAllRecentlyWatched() + } + call.respond(RecentlyWatchedResponse(list)) + } + post(RecentlyWatchedRepository.WRITE) { + val info = call.receive() + recentlyFactory.create(call.obtainUserId()).writeAll(info.info) + call.respond(HttpStatusCode.OK) + } + post(RecentlyWatchedRepository.REMOVE) { + val repo = recentlyFactory.create(call.obtainUserId()) + call.receive().info.forEach { + repo.remove(it) + } + call.respond(HttpStatusCode.OK) + } + post(RecentlyWatchedRepository.TOGGLE) { + val repo = recentlyFactory.create(call.obtainUserId()) + call.receive().info.forEach { + repo.toggleInfo(it) + } + call.respond(HttpStatusCode.OK) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SavedMovieRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SavedMovieRouting.kt new file mode 100644 index 0000000..226a90c --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SavedMovieRouting.kt @@ -0,0 +1,43 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.util.getOrFail +import kotlinx.coroutines.flow.first +import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.IsSavedResponse +import ru.shadowsparky.vbox.shared.domain.RemoveRequest +import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository +import ru.shadowsparky.vbox.shared.domain.SavedMoviesResponse +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails + +fun Route.setupSavedMovie(savedMovieFactory: SavedMovieRepositoryFactory) { + post(SavedMovieRepository.REMOVE) { + val saveRequest = call.receive() + savedMovieFactory.create(call.obtainUserId()).remove(saveRequest.movieId) + call.respond(HttpStatusCode.OK) + } + post(SavedMovieRepository.WRITE) { + val saveRequest = call.receive() + savedMovieFactory.create(call.obtainUserId()).save(saveRequest) + call.respond(HttpStatusCode.OK) + } + get(SavedMovieRepository.QUERY_SAVED) { + val p = call.parameters + val saved = savedMovieFactory.create(call.obtainUserId()) + .isSaved(p.getOrFail(SavedMovieRepository.MOVIE_ID_ARG).toLong()) + .first() + call.respond(IsSavedResponse(saved)) + } + get(SavedMovieRepository.QUERY_ALL) { + val saved = savedMovieFactory.create(call.obtainUserId()) + .getAll() + .first() + call.respond(SavedMoviesResponse(saved)) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SearchRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SearchRouting.kt new file mode 100644 index 0000000..d5ca0ad --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/SearchRouting.kt @@ -0,0 +1,42 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import kotlinx.coroutines.flow.first +import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.SearchDeleteRequest +import ru.shadowsparky.vbox.shared.domain.SearchQueryRequest +import ru.shadowsparky.vbox.shared.domain.SearchQueryResponse +import ru.shadowsparky.vbox.shared.domain.SearchRepository + +fun Route.setupSearch(searchFactory: SearchRepositoryFactory) { + get(SearchRepository.SEARCH) { + val p = call.parameters + val userId = call.obtainUserId() + val search = searchFactory.create(userId) + call.respond(SearchQueryResponse(search.search(p[SearchRepository.QUERY_ARG] ?: "").first())) + } + post(SearchRepository.ADD_TO_SEARCH) { + val userId = call.obtainUserId() + val request = call.receive() + searchFactory.create(userId).addToSearch(request.query) + call.respond(HttpStatusCode.OK) + } + delete(SearchRepository.CLEAR) { + val userId = call.obtainUserId() + val request = call.receive() + val repo = searchFactory.create(userId) + if (request.query == null) { + repo.clear() + } else { + repo.deleteFromSearch(request.query!!) + } + call.respond(HttpStatusCode.OK) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/TagsRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/TagsRouting.kt new file mode 100644 index 0000000..e7725c2 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/TagsRouting.kt @@ -0,0 +1,79 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import kotlinx.coroutines.flow.first +import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory +import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.tag.AddRequest +import ru.shadowsparky.vbox.shared.domain.tag.DeleteRequest +import ru.shadowsparky.vbox.shared.domain.tag.EditRequest +import ru.shadowsparky.vbox.shared.domain.tag.LinkRequest +import ru.shadowsparky.vbox.shared.domain.tag.LinkedMovies +import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository +import ru.shadowsparky.vbox.shared.domain.tag.TagResponse +import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository + +fun Route.setupTagsRouting( + userTagFactory: UserTagRepositoryFactory, + movieTagFactory: MovieTagRepositoryFactory +) { + setupUserTag(userTagFactory) + setupMovieTag(movieTagFactory) +} + +private fun Route.setupUserTag(userTagFactory: UserTagRepositoryFactory) { + get(UserTagRepository.LIST) { + val userTag = userTagFactory.create(call.obtainUserId()) + call.respond(TagResponse(userTag.userTags.first())) + } + post(UserTagRepository.ADD) { + val userTag = userTagFactory.create(call.obtainUserId()) + userTag.add(call.receive().tag) + call.respond(HttpStatusCode.OK) + } + post(UserTagRepository.EDIT) { + val userTag = userTagFactory.create(call.obtainUserId()) + val edit = call.receive() + userTag.edit(edit.id, edit.newTag) + call.respond(HttpStatusCode.OK) + } + delete(UserTagRepository.DELETE) { + val userTag = userTagFactory.create(call.obtainUserId()) + userTag.delete(call.receive().id) + call.respond(HttpStatusCode.OK) + } +} + +private fun Route.setupMovieTag(movieTagFactory: MovieTagRepositoryFactory) { + get(MovieTagRepository.QUERY) { + val movieTag = movieTagFactory.create(call.obtainUserId()) + val movieId = call.parameters[MovieTagRepository.MOVIE_ID]?.toLong() + val tags = movieTag.query(checkNotNull(movieId) { "movieId required" }).first() + call.respond(TagResponse(tags)) + } + get(MovieTagRepository.QUERY_MOVIES) { + val movieTag = movieTagFactory.create(call.obtainUserId()) + val tagId = call.parameters[MovieTagRepository.TAG_ID]?.toLong() + val ids = movieTag.queryMovies(checkNotNull(tagId) { "tagId required" }).first() + call.respond(LinkedMovies(ids)) + } + post(MovieTagRepository.LINK) { + val movieTag = movieTagFactory.create(call.obtainUserId()) + val link = call.receive() + movieTag.link(link.movieId, link.tagId) + call.respond(HttpStatusCode.OK) + } + delete(MovieTagRepository.UNLINK) { + val movieTag = movieTagFactory.create(call.obtainUserId()) + val link = call.receive() + movieTag.unlink(link.movieId, link.tagId) + call.respond(HttpStatusCode.OK) + } +} diff --git a/apps/vbox/backend/src/main/resources/application.conf b/apps/vbox/backend/src/main/resources/application.conf new file mode 100644 index 0000000..3058540 --- /dev/null +++ b/apps/vbox/backend/src/main/resources/application.conf @@ -0,0 +1,8 @@ +ktor { + deployment { + port = 8181 + } + application { + modules = [ ru.shadowsparky.vbox.backend.MainKt.module ] + } +} diff --git a/apps/vbox/backend/src/main/resources/logback.xml b/apps/vbox/backend/src/main/resources/logback.xml new file mode 100644 index 0000000..678bbd4 --- /dev/null +++ b/apps/vbox/backend/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq new file mode 100644 index 0000000..bdca057 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq @@ -0,0 +1,80 @@ +-- Создание таблицы movie + +CREATE TABLE movie ( + movie_id BIGSERIAL PRIMARY KEY, + poster_url VARCHAR(256) NOT NULL, + description TEXT NOT NULL, + title TEXT NOT NULL, + flags INT NOT NULL +); + +-- Создание таблицы user + +CREATE TABLE "user" ( + user_id BIGSERIAL PRIMARY KEY, + login VARCHAR(256) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at BIGINT NOT NULL, + flags INT DEFAULT NULL +); + +-- Создание таблицы recently_watched + +CREATE TABLE recently_watched ( + recently_watched_id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + movie_id BIGINT NOT NULL, + episode BIGINT DEFAULT NULL, + season BIGINT DEFAULT NULL, + UNIQUE (user_id, movie_id), + FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (movie_id) REFERENCES movie (movie_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Создание таблицы saved_movie + +CREATE TABLE saved_movie ( + saved_movie_id BIGSERIAL PRIMARY KEY, + movie_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + UNIQUE (movie_id, user_id), + FOREIGN KEY (movie_id) REFERENCES movie (movie_id) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Создание таблицы search_info + +CREATE TABLE search_info ( + search_id BIGSERIAL PRIMARY KEY, + query VARCHAR(256) DEFAULT NULL, + user_id BIGINT DEFAULT NULL, + UNIQUE (query, user_id), + FOREIGN KEY (user_id) REFERENCES "user" (user_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE refresh_tokens ( + token_id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at BIGINT NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE user_tag ( + id bigserial NOT NULL, + user_id bigint NOT NULL, + tag varchar NOT NULL, + CONSTRAINT user_tag_pk PRIMARY KEY (id), + CONSTRAINT user_tag_user_fk FOREIGN KEY (user_id) REFERENCES "user" (user_id) +); + +CREATE UNIQUE INDEX user_tag_tag_idx ON user_tag (tag,user_id); + +CREATE TABLE movie_tag ( + id bigserial NOT NULL, + movie_id bigint NOT NULL, + tag_id bigint NOT NULL, + CONSTRAINT movie_tag_pk PRIMARY KEY (id), + CONSTRAINT movie_tag_movie_fk FOREIGN KEY (movie_id) REFERENCES movie(movie_id), + CONSTRAINT movie_tag_user_tag_fk FOREIGN KEY (tag_id) REFERENCES user_tag(id) +); diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/movie.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/movie.sq new file mode 100644 index 0000000..1e8500e --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/movie.sq @@ -0,0 +1,10 @@ +insertMovie: + INSERT INTO "movie" (movie_id, poster_url, description, title, flags) VALUES (?, ?, ?, ?, ?); + +selectMovie: + SELECT * FROM "movie" WHERE movie_id = ?; + +updateMovie: + UPDATE "movie" + SET poster_url = ?, description = ?, title = ?, flags = ? + WHERE movie_id = ?; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/recent.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/recent.sq new file mode 100644 index 0000000..a0660fd --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/recent.sq @@ -0,0 +1,23 @@ +selectAll: + SELECT * FROM "recently_watched" WHERE user_id = ?; + +selectByMovieId: + SELECT * FROM "recently_watched" WHERE movie_id = ? AND user_id = ? AND season IS NOT NULL AND episode IS NOT NULL; + +selectByMovieIdAndSeason: + SELECT * FROM "recently_watched" WHERE movie_id = ? AND user_id = ? AND season = ? AND episode IS NOT NULL; + +selectByIds: + SELECT * FROM "recently_watched" WHERE movie_id = ? AND episode = ? AND season = ? AND user_id = ?; + +insertRecentlyWatched: + INSERT INTO "recently_watched" (user_id, movie_id, episode, season) VALUES (?, ?, ?, ?); + +deleteRwByIds: + DELETE FROM "recently_watched" WHERE (movie_id = ? AND episode = ? AND season = ? AND user_id = ?); + +deleteRecentlyWatchedById: + DELETE FROM "recently_watched" WHERE recently_watched_id = ? AND user_id = ?; + +deleteRecentlyWatchedByMovieId: + DELETE FROM "recently_watched" WHERE movie_id = ? AND user_id = ?; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/refresh_tokens.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/refresh_tokens.sq new file mode 100644 index 0000000..083d422 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/refresh_tokens.sq @@ -0,0 +1,19 @@ +selectByHash: + SELECT * + FROM refresh_tokens + WHERE token_hash = ?; + +insertToken: + INSERT INTO refresh_tokens(user_id, token_hash, expires_at, revoked) + VALUES (?, ?, ?, FALSE) + RETURNING token_id; + +revokeToken: + UPDATE refresh_tokens + SET revoked = TRUE + WHERE token_id = ?; + +revokeAllForUser: + UPDATE refresh_tokens + SET revoked = TRUE + WHERE user_id = ?; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/saved_movie.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/saved_movie.sq new file mode 100644 index 0000000..3d37c77 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/saved_movie.sq @@ -0,0 +1,13 @@ +-- videobox."saved_movie" definition + +selectByUserId: + SELECT * FROM "saved_movie" WHERE user_id = ? ORDER BY saved_movie_id; + +selectByUserIdAndMovieId: + SELECT * FROM "saved_movie" WHERE user_id = ? AND movie_id = ?; + +addSavedMovie: + INSERT INTO "saved_movie" (movie_id, user_id) VALUES (?, ?); + +removeSavedMovie: + DELETE FROM "saved_movie" WHERE user_id = ? AND movie_id = ?; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/search.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/search.sq new file mode 100644 index 0000000..026f834 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/search.sq @@ -0,0 +1,14 @@ +selectAll: + SELECT * FROM "search_info" WHERE user_id = ? ORDER BY search_id; + +selectByQuery: + SELECT * FROM "search_info" WHERE query = ? AND user_id = ? ORDER BY search_id; + +insertSearchInfo: + INSERT INTO "search_info" (query, user_id) VALUES (?, ?); + +deleteSearchInfo: + DELETE FROM "search_info" WHERE query = ? AND user_id = ?; + +deleteAll: + DELETE FROM "search_info" WHERE user_id = ?; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/tags.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/tags.sq new file mode 100644 index 0000000..ced3b19 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/tags.sq @@ -0,0 +1,47 @@ +getUserTagsByUserId: +SELECT id, user_id, tag +FROM user_tag +WHERE user_id = ?; + +getUserIdByTagId: +SELECT user_id +FROM user_tag +WHERE id = ?; + +insertUserTag: +INSERT INTO user_tag (user_id, tag) +VALUES (?, ?); + +updateTagText: +UPDATE user_tag +SET tag = ? +WHERE id = ?; + +deleteTagById: +DELETE FROM user_tag +WHERE id = ?; + +getTagsForMovie: +SELECT ut.id, ut.tag +FROM movie_tag mt +JOIN user_tag ut ON mt.tag_id = ut.id +WHERE mt.movie_id = ? AND ut.user_id = ?; + +getMoviesByTagId: +SELECT mt.movie_id +FROM movie_tag mt +JOIN user_tag ut ON mt.tag_id = ut.id +WHERE mt.tag_id = ? AND ut.user_id = ?; + +linkMovieTag: +INSERT INTO movie_tag (movie_id, tag_id) +SELECT ?, ut.id +FROM user_tag ut +WHERE ut.id = ? AND ut.user_id = ? +ON CONFLICT DO NOTHING; + +unlinkMovieTag: +DELETE FROM movie_tag +WHERE movie_id = ? + AND tag_id = ? + AND tag_id IN (SELECT id FROM user_tag WHERE user_id = ?); diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/users.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/users.sq new file mode 100644 index 0000000..4f0d6fc --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/users.sq @@ -0,0 +1,16 @@ +selectUserByLogin: + SELECT * FROM "user" WHERE login = ? LIMIT 1; + +selectUserByUserId: + SELECT * FROM "user" WHERE user_id = ? LIMIT 1; + +addUser: + INSERT INTO "user" (login, password_hash, created_at, flags) VALUES (?, ?, ?, ?); + +removeUser: + DELETE FROM "user" WHERE login = ?; + +updatePassword: + UPDATE "user" + SET password_hash = ? + WHERE user_id = ?; diff --git a/apps/vbox/client/androidApp/.gitignore b/apps/vbox/client/androidApp/.gitignore new file mode 100644 index 0000000..0265f6b --- /dev/null +++ b/apps/vbox/client/androidApp/.gitignore @@ -0,0 +1,4 @@ +keystore.properties +release.keystore +*.keystore +build \ No newline at end of file diff --git a/apps/vbox/client/androidApp/build.gradle.kts b/apps/vbox/client/androidApp/build.gradle.kts new file mode 100644 index 0000000..c5a3a4b --- /dev/null +++ b/apps/vbox/client/androidApp/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.convention.android.app) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.koin) + alias(libs.plugins.convention.ktor.client) +} + +android { + buildFeatures { buildConfig = true } + namespace = "ru.shadowsparky.videobox.multiplatform" + defaultConfig { + applicationId = namespace + minSdk = libs.versions.proj.min.get().toInt() + targetSdk = libs.versions.proj.target.get().toInt() + versionCode = 35 + versionName = "1.18" + } +} + +dependencies { + implementation(libs.activity.compose) + implementation(libs.material) + implementation(project(":apps:vbox:client:shared")) + implementation(project(":apps:vbox:common")) + + implementation(project(":libs:base")) + implementation(project(":libs:ui")) + implementation(project(":libs:http-client")) + implementation(project(":libs:video-player")) + debugImplementation(libs.ui.tooling) + compileOnly(libs.ui.tooling.preview.android) + testImplementation(libs.koin.test) + testImplementation(libs.mockk) + testImplementation(kotlin("test-junit")) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.runner) +} diff --git a/apps/vbox/client/androidApp/proguard-rules.pro b/apps/vbox/client/androidApp/proguard-rules.pro new file mode 100644 index 0000000..bf6dd8c --- /dev/null +++ b/apps/vbox/client/androidApp/proguard-rules.pro @@ -0,0 +1,41 @@ +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE +-dontwarn org.slf4j.impl.StaticLoggerBinder +-dontwarn java.lang.management.ManagementFactory +-dontwarn java.lang.management.RuntimeMXBean +-dontwarn kotlinx.atomicfu.** +-dontwarn io.netty.** +-dontwarn com.typesafe.** +-dontwarn org.slf4j.** +-dontwarn com.sun.nio.file.SensitivityWatchEventModifier +-dontwarn org.xmlpull.v1.** +-dontwarn javax.xml.namespace.** +-dontwarn androidx.test.platform.app.InstrumentationRegistry +-dontwarn androidx.window.extensions.WindowExtensions +-dontwarn androidx.window.extensions.WindowExtensionsProvider +-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation +-dontwarn androidx.window.extensions.core.util.function.Consumer +-dontwarn androidx.window.extensions.core.util.function.Function +-dontwarn androidx.window.extensions.core.util.function.Predicate +-dontwarn androidx.window.extensions.layout.DisplayFeature +-dontwarn androidx.window.extensions.layout.FoldingFeature +-dontwarn androidx.window.extensions.layout.WindowLayoutComponent +-dontwarn androidx.window.extensions.layout.WindowLayoutInfo +-dontwarn androidx.window.sidecar.SidecarDeviceState +-dontwarn androidx.window.sidecar.SidecarDisplayFeature +-dontwarn androidx.window.sidecar.SidecarInterface$SidecarCallback +-dontwarn androidx.window.sidecar.SidecarInterface +-dontwarn androidx.window.sidecar.SidecarProvider +-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo + +-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent { *; } +-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnSaved { *; } +-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnRecent { *; } +-keep class ru.shadowsparky.vbox.shared.domain.RemoteEvent$OnSearch { *; } diff --git a/apps/vbox/client/androidApp/src/main/AndroidManifest.xml b/apps/vbox/client/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c7c272e --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/vbox/client/androidApp/src/main/ic_launcher-playstore.png b/apps/vbox/client/androidApp/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..5c19190 Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/ic_launcher-playstore.png differ diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt new file mode 100644 index 0000000..479748c --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt @@ -0,0 +1,34 @@ +package ru.shadowsparky.videobox.multiplatform + +import android.app.Application +import android.os.StrictMode +import com.google.android.material.color.DynamicColors +import org.koin.android.ext.koin.androidContext +import org.koin.core.annotation.KoinApplication +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.koin.plugin.module.dsl.startKoin +import ru.shadowsparky.domain.AppConfig + +@KoinApplication +object VBox + +class App : Application() { + override fun onCreate() { + super.onCreate() + DynamicColors.applyToActivitiesIfAvailable(this) + startKoin { androidContext(this@App) } + if (BuildConfig.DEBUG) StrictMode.enableDefaults() + } +} + +@Module +class AppModule { + + @Single + fun provideAppConfig(): AppConfig { + return object : AppConfig { + override fun isDebug(): Boolean = BuildConfig.DEBUG + } + } +} diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/MainActivity.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/MainActivity.kt new file mode 100644 index 0000000..14d2b8f --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/MainActivity.kt @@ -0,0 +1,101 @@ +package ru.shadowsparky.videobox.multiplatform + +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.lifecycle.lifecycleScope +import com.arkivanov.decompose.retainedComponent +import kotlinx.coroutines.launch +import ru.shadowsparky.koin +import ru.shadowsparky.vbox.shared.presentation.Nav +import ru.shadowsparky.vbox.shared.presentation.RootComponent +import ru.shadowsparky.vbox.shared.presentation.RootFactory +import ru.shadowsparky.vbox.shared.presentation.UrlRoutes +import ru.shadowsparky.vbox.shared.presentation.nav.Route + +class MainActivity : ComponentActivity() { + private lateinit var root: RootComponent + + override fun onCreate(savedInstanceState: Bundle?) { + edgeToEdge(false) + super.onCreate(savedInstanceState) + root = retainedComponent { componentContext -> + val routeFromIntent = obtainRouteOrNull(intent?.data) + val route = if (routeFromIntent == null) { + listOf(Route.default) + } else { + listOf(Route.default, routeFromIntent) + } + koin.get().create(componentContext, route) + } + if (openSavedIfNeeded(intent) || openUrlIfNeeded(intent.data)) { + intent = Intent(intent) + .setData(null) + .setAction(null) + } + setContent { + Nav(rootComponent = root) + edgeToEdge(isSystemInDarkTheme()) + } + lifecycleScope.launch { + root.onFinish.collect { + finishAndRemoveTask() + } + } + } + + private fun edgeToEdge(isDarkTheme: Boolean) { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + Color.TRANSPARENT, + Color.TRANSPARENT, + { isDarkTheme } + ), + navigationBarStyle = SystemBarStyle.light( + Color.TRANSPARENT, + Color.TRANSPARENT + ) + ) + } + + private fun openSavedIfNeeded(intent: Intent?): Boolean { + if (intent?.action == "ru.shadowsparky.vbox.action.OPEN_SAVED_MOVIES") { + root.nav(Route.Bookmark(), true) + return true + } + return false + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (!openSavedIfNeeded(intent)) { + val uri = intent.data ?: return + val route = obtainRouteOrNull(uri) + if (route != null) { + root.nav(route, true) + } else { + openUrlIfNeeded(uri) + } + } + } + + private fun openUrlIfNeeded(uri: Uri?): Boolean { + uri ?: return false + if (uri.path?.startsWith("/video/") == true) { + root.nav(Route.Overview.Player(url = uri.toString())) + return true + } + return false + } + + private fun obtainRouteOrNull(uri: Uri?): Route? { + uri ?: return null + return UrlRoutes.parseUrl(uri.toString(), basePath = "/") + } +} diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt new file mode 100644 index 0000000..a88ee98 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/PreferencePreview.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.videobox.multiplatform.preview + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import ru.shadowsparky.ui.components.TextPreference + +@Preview +@Composable +fun SwitchPreferencePreview() { + TextPreference(Modifier, "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", "summary") +} diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt new file mode 100644 index 0000000..c1d319d --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SearchScreenPreview.kt @@ -0,0 +1,64 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package ru.shadowsparky.videobox.multiplatform.preview + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import ru.shadowsparky.vbox.shared.presentation.search.AppBarWithSearch +import ru.shadowsparky.vbox.shared.presentation.search.OldSearchInfo +import ru.shadowsparky.vbox.shared.presentation.search.SearchAppBar + +@Preview +@Composable +fun AppBarWithSearchPreview() { + AppBarWithSearch( + title = "TitleWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", + isDefault = false, + onBack = {} + ) +} + +@Preview +@Composable +fun AppBarWithSearchWithBackPreview() { + AppBarWithSearch( + title = "TitleWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", + onBack = {} + ) +} + +@SuppressLint("RememberInComposition") +@Preview +@Composable +fun InternalAppBarPreview() { + SearchAppBar( + TextFieldValue(""), + FocusRequester(), + {}, + {} + ) +} + +@Preview(showBackground = true) +@Composable +fun SearchInfoPreview() { + OldSearchInfo( + modifier = Modifier, + info = listOf( + "Почему дора дура?", + "клиинка", + "симпсоны", + "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW" + ), + PaddingValues(0.dp), + onClick = {}, + onDeleteRequested = {} + ) +} diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SeasonOverviewScreenPreview.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SeasonOverviewScreenPreview.kt new file mode 100644 index 0000000..e57c7a2 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/SeasonOverviewScreenPreview.kt @@ -0,0 +1,19 @@ +package ru.shadowsparky.videobox.multiplatform.preview + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import ru.shadowsparky.vbox.shared.domain.model.Season +import ru.shadowsparky.vbox.shared.presentation.overview.season.SeasonsInfo + +@Preview +@Composable +fun SeasonsInfoPreview() { + SeasonsInfo( + paddingValues = PaddingValues(0.dp), + seasons = listOf(Season(listOf(), 23, "Lalalal")) + ) { + + } +} diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/VideoScreenPreview.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/VideoScreenPreview.kt new file mode 100644 index 0000000..6c00d37 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/preview/VideoScreenPreview.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.videobox.multiplatform.preview + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import ru.shadowsparky.vbox.shared.presentation.list.ErrorAndRetry +import ru.shadowsparky.vbox.shared.presentation.list.Loading + +@Preview +@Composable +fun ErrorPreview() { + ErrorAndRetry( + errorMsg = "Неизвестная ошибка" + ) {} +} + +@Preview +@Composable +fun LoadingPreview() { + Loading() +} diff --git a/apps/vbox/client/androidApp/src/main/res/drawable/baseline_saved_search_24.xml b/apps/vbox/client/androidApp/src/main/res/drawable/baseline_saved_search_24.xml new file mode 100644 index 0000000..80d9d23 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/drawable/baseline_saved_search_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/apps/vbox/client/androidApp/src/main/res/drawable/baseline_video_library_24.xml b/apps/vbox/client/androidApp/src/main/res/drawable/baseline_video_library_24.xml new file mode 100644 index 0000000..69b8b7a --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/drawable/baseline_video_library_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_foreground.xml b/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..fdd9b90 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml b/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..fdd9b90 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/vbox/client/androidApp/src/main/res/drawable/ic_outline_remove_red_eye_24.xml b/apps/vbox/client/androidApp/src/main/res/drawable/ic_outline_remove_red_eye_24.xml new file mode 100644 index 0000000..efe7c38 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/drawable/ic_outline_remove_red_eye_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/vbox/client/androidApp/src/main/res/layout/activity_main.xml b/apps/vbox/client/androidApp/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..18c33cc --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/layout/activity_main.xml @@ -0,0 +1,10 @@ + + + + diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..f30783b --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..260a46b Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..c5d564e Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/vbox/client/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..ebcdf46 Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png b/apps/vbox/client/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..420d0fd Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_banner.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_banner.png new file mode 100644 index 0000000..c8f8a6c Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_banner.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_banner_foreground.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_banner_foreground.png new file mode 100644 index 0000000..5599942 Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_banner_foreground.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..98c452c Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..fa51166 Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..3a03d56 Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..9e1bfb1 Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..053c6b4 Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8a52cb3 Binary files /dev/null and b/apps/vbox/client/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/vbox/client/androidApp/src/main/res/values/ic_banner_background.xml b/apps/vbox/client/androidApp/src/main/res/values/ic_banner_background.xml new file mode 100644 index 0000000..15db34b --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/values/ic_banner_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/vbox/client/androidApp/src/main/res/values/ic_launcher_background.xml b/apps/vbox/client/androidApp/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/vbox/client/androidApp/src/main/res/values/strings.xml b/apps/vbox/client/androidApp/src/main/res/values/strings.xml new file mode 100644 index 0000000..1083d06 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Videobox + Сохранённые фильмы + Просмотр списка ранее сохранённых фильмов + diff --git a/apps/vbox/client/androidApp/src/main/res/values/themes.xml b/apps/vbox/client/androidApp/src/main/res/values/themes.xml new file mode 100644 index 0000000..f184558 --- /dev/null +++ b/apps/vbox/client/androidApp/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + diff --git a/apps/vbox/client/web/src/wasmJsMain/resources/manifest.json b/apps/vbox/client/web/src/wasmJsMain/resources/manifest.json new file mode 100644 index 0000000..590615d --- /dev/null +++ b/apps/vbox/client/web/src/wasmJsMain/resources/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Videobox", + "short_name": "VBox", + "start_url": "/", + "display": "standalone", + "icons": [ + { + "src": "icon.png", + "sizes": "192x192", + "type": "image/png" + } + ] +} diff --git a/apps/vbox/client/web/src/wasmJsMain/resources/sqljs.worker.js b/apps/vbox/client/web/src/wasmJsMain/resources/sqljs.worker.js new file mode 100644 index 0000000..d7687ac --- /dev/null +++ b/apps/vbox/client/web/src/wasmJsMain/resources/sqljs.worker.js @@ -0,0 +1,58 @@ +import initSqlJs from "sql.js"; + +let db = null; + +async function createDatabase() { + let SQL = await initSqlJs({locateFile: file => 'sql-wasm.wasm'}); + db = new SQL.Database(); +} + +function onModuleReady() { + const data = this.data; + + switch (data && data.action) { + case "exec": + if (!data["sql"]) { + throw new Error("exec: Missing query string"); + } + + return postMessage({ + id: data.id, + results: db.exec(data.sql, data.params)[0] ?? {values: []} + }); + case "begin_transaction": + return postMessage({ + id: data.id, + results: db.exec("BEGIN TRANSACTION;") + }) + case "end_transaction": + return postMessage({ + id: data.id, + results: db.exec("END TRANSACTION;") + }) + case "rollback_transaction": + return postMessage({ + id: data.id, + results: db.exec("ROLLBACK TRANSACTION;") + }) + default: + throw new Error(`Unsupported action: ${data && data.action}`); + } +} + +function onError(err) { + return postMessage({ + id: this.data.id, + error: err + }); +} + +if (typeof importScripts === "function") { + db = null; + const sqlModuleReady = createDatabase() + self.onmessage = (event) => { + return sqlModuleReady + .then(onModuleReady.bind(event)) + .catch(onError.bind(event)); + } +} diff --git a/apps/vbox/client/web/webpack.config.d/config.js b/apps/vbox/client/web/webpack.config.d/config.js new file mode 100644 index 0000000..ecb0970 --- /dev/null +++ b/apps/vbox/client/web/webpack.config.d/config.js @@ -0,0 +1,15 @@ +const TerserPlugin = require("terser-webpack-plugin"); + +config.optimization = config.optimization || {}; +config.optimization.minimize = true; +config.optimization.minimizer = [ + new TerserPlugin({ + terserOptions: { + mangle: true, // Note: By default, mangle is set to true. + compress: false, // Disable the transformations that reduce the code size. + output: { + beautify: false, + }, + }, + }), +]; \ No newline at end of file diff --git a/apps/vbox/client/web/webpack.config.d/devServerConfig.js b/apps/vbox/client/web/webpack.config.d/devServerConfig.js new file mode 100644 index 0000000..64358dc --- /dev/null +++ b/apps/vbox/client/web/webpack.config.d/devServerConfig.js @@ -0,0 +1,4 @@ +config.devServer = { + ...config.devServer, + historyApiFallback: true +}; diff --git a/apps/vbox/client/web/webpack.config.d/sqljs-config.js b/apps/vbox/client/web/webpack.config.d/sqljs-config.js new file mode 100644 index 0000000..3b0b6cd --- /dev/null +++ b/apps/vbox/client/web/webpack.config.d/sqljs-config.js @@ -0,0 +1,17 @@ +// {project}/webpack.config.d/sqljs.js +config.resolve = { + fallback: { + fs: false, + path: false, + crypto: false, + } +}; + +const CopyWebpackPlugin = require('copy-webpack-plugin'); +config.plugins.push( + new CopyWebpackPlugin({ + patterns: [ + '../../node_modules/sql.js/dist/sql-wasm.wasm' + ] + }) +); diff --git a/apps/vbox/common/.gitignore b/apps/vbox/common/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/apps/vbox/common/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/apps/vbox/common/build.gradle.kts b/apps/vbox/common/build.gradle.kts new file mode 100644 index 0000000..a4b4b6a --- /dev/null +++ b/apps/vbox/common/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.ktor.client) + alias(libs.plugins.convention.compose) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":libs:base")) + implementation(project(":libs:http-client")) + } + } + val androidMain by getting + } + android { + namespace = "ru.shadowsparky.vbox.common" + } +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/VBoxCommon.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/VBoxCommon.kt new file mode 100644 index 0000000..adf6ed3 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/VBoxCommon.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.vbox.shared + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@Configuration +@ComponentScan +class VBoxCommon diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DispatcherProvider.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DispatcherProvider.kt new file mode 100644 index 0000000..12ef45b --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DispatcherProvider.kt @@ -0,0 +1,3 @@ +package ru.shadowsparky.vbox.shared.di.factory + +typealias DispatcherProvider = ru.shadowsparky.domain.DispatcherProvider diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/AuthTokenRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/AuthTokenRepository.kt new file mode 100644 index 0000000..8d38bdf --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/AuthTokenRepository.kt @@ -0,0 +1,35 @@ +package ru.shadowsparky.vbox.shared.domain + +import kotlinx.serialization.Serializable +import ru.shadowsparky.http.domain.TokenInfo +import ru.shadowsparky.vbox.shared.domain.model.LoginInfo + +interface AuthTokenRepository { + suspend fun register(loginInfo: LoginInfo): TokenInfo + suspend fun login(loginInfo: LoginInfo): TokenInfo + + suspend fun update(refresh: TokenRequest): TokenInfo + + suspend fun changePassword(request: ChangePasswordRequest) + + suspend fun revoke(tokenRequest: TokenRequest) + + companion object { + const val LOGIN_PATH = "$DYNAMIC_PREFIX/login" + const val REGISTER_PATH = "$DYNAMIC_PREFIX/register" + const val UPDATE_PATH = "$DYNAMIC_PREFIX/updateToken" + const val CHANGE_PASS_PATH = "$DYNAMIC_PREFIX/changePassword" + const val REVOKE_PATH = "$DYNAMIC_PREFIX/revokeToken" + } +} + +@Serializable +data class TokenRequest(val token: String) + +@Serializable +data class ChangePasswordRequest( + val oldPasswordHash: String, + val newPasswordHash: String +) + +const val FLAG_USER_BLOCKED = 1 diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HeathCheck.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HeathCheck.kt new file mode 100644 index 0000000..c6d5f8e --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/HeathCheck.kt @@ -0,0 +1,9 @@ +package ru.shadowsparky.vbox.shared.domain + +interface HeathCheck { + suspend fun check() + + companion object { + const val PATH = "healthCheck" + } +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RecentlyWatchedRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RecentlyWatchedRepository.kt new file mode 100644 index 0000000..c304ba2 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RecentlyWatchedRepository.kt @@ -0,0 +1,47 @@ +package ru.shadowsparky.vbox.shared.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo + +interface RecentlyWatchedRepository { + suspend fun remove(info: RecentlyWatchedInfo) + suspend fun write(info: RecentlyWatchedInfo) + + suspend fun writeAll(info: List) { + info.forEach { write(it) } + } + + fun queryRecentlyWatched( + movieId: Long, + seasonId: Long? = null + ): Flow> + + suspend fun getAllRecentlyWatched(): List + + suspend fun clear() { + getAllRecentlyWatched().forEach { remove(it) } + } + + companion object { + const val PREFIX = "$DYNAMIC_PREFIX/recent" + + const val REMOVE = "$PREFIX/delete" + const val WRITE = "$PREFIX/add" + const val TOGGLE = "$PREFIX/toggle" + const val GET_INFO = "$PREFIX/query" + + const val MOVIE_ID_ARG = "movieId" + const val SEASON_ID_ARG = "seasonId" + } +} + +@Serializable +data class RecentlyWatchedResponse( + val info: List +) + +@Serializable +data class RecentlyWatchedRequest( + val info: List +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt new file mode 100644 index 0000000..d42833b --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/RemoteEventHandler.kt @@ -0,0 +1,51 @@ +package ru.shadowsparky.vbox.shared.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository +import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository + +interface RemoteEventHandler { + suspend fun notify(eventInfo: RemoteEvent) + + companion object { + const val SEARCH_CHANGED = "${SearchRepository.PREFIX}/onChange" + const val RECENTLY_CHANGED = "${RecentlyWatchedRepository.PREFIX}/onChange" + const val SAVED_CHANGED = "${SavedMovieRepository.PREFIX}/onChange" + const val USER_TAG_CHANGED = "${UserTagRepository.PREFIX}/onChange" + const val MOVIE_TAG_CHANGED = "${MovieTagRepository.PREFIX}/onChange" + } +} + +interface RemoteEventListener { + val event: Flow +} + +@Serializable +sealed interface RemoteEvent { + val userId: Long + + @Serializable + data class OnSearch(val millis: Long, override val userId: Long) : RemoteEvent + + @Serializable + data class OnRecent(val millis: Long, override val userId: Long, val movieId: Long) : + RemoteEvent + + @Serializable + data class OnSaved(override val userId: Long, val movieId: Long) : RemoteEvent + + @Serializable + data class OnUserTag(override val userId: Long) : RemoteEvent + + @Serializable + data class OnMovieTag(override val userId: Long, val movieId: Long) : RemoteEvent +} + +object EventType { + const val SEARCH = "search" + const val RECENT = "recent" + const val SAVED = "saved" + const val USER_TAG = "user_tag" + const val MOVIE_TAG = "movie_tag" +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SavedMovieRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SavedMovieRepository.kt new file mode 100644 index 0000000..27c22c6 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SavedMovieRepository.kt @@ -0,0 +1,40 @@ +package ru.shadowsparky.vbox.shared.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails + +interface SavedMovieRepository { + suspend fun save(details: VideoDetails) + fun getAll(): Flow> + fun isSaved(id: Long): Flow + suspend fun remove(id: Long) + suspend fun saveAll(details: List) { + details.forEach { save(it) } + } + + suspend fun clear() { + getAll().first().forEach { remove(it.id) } + } + + companion object { + const val MOVIE_ID_ARG = RecentlyWatchedRepository.MOVIE_ID_ARG + + const val PREFIX = "$DYNAMIC_PREFIX/savedMovie" + const val REMOVE = "${PREFIX}/delete" + const val WRITE = "${PREFIX}/add" + const val QUERY = "${PREFIX}/query" + const val QUERY_ALL = "${QUERY}_all" + const val QUERY_SAVED = "${QUERY}_saved" + } +} + +@Serializable +data class RemoveRequest(val movieId: Long) + +@Serializable +data class IsSavedResponse(val isSaved: Boolean) + +@Serializable +data class SavedMoviesResponse(val list: List) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SearchRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SearchRepository.kt new file mode 100644 index 0000000..e2bbef1 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/SearchRepository.kt @@ -0,0 +1,41 @@ +package ru.shadowsparky.vbox.shared.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable + +interface SearchRepository { + fun search(query: String): Flow> + + suspend fun addToSearch(query: String) + suspend fun addToSearch(query: List) { + query.forEach { addToSearch(it) } + } + + suspend fun deleteFromSearch(query: String) + suspend fun clear() + + companion object { + const val PREFIX = "$DYNAMIC_PREFIX/search" + + const val SEARCH = "$PREFIX/query" + const val ADD_TO_SEARCH = "$PREFIX/add" + const val CLEAR = "$PREFIX/delete" + + const val QUERY_ARG = "query" + } +} + +@Serializable +class SearchQueryResponse( + val search: List +) + +@Serializable +class SearchQueryRequest( + val query: List +) + +@Serializable +class SearchDeleteRequest( + val query: String? = null +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/ServerConfiguration.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/ServerConfiguration.kt new file mode 100644 index 0000000..82dc311 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/ServerConfiguration.kt @@ -0,0 +1,22 @@ +package ru.shadowsparky.vbox.shared.domain + +data class ServerConfiguration( + val uriString: String = "https://box.shadowsparky.ru/api" +) { + fun asHttpStr(): String { + val builder = StringBuilder(uriString) + if (uriString.endsWith("/")) { + builder.removeSuffix("/") + } + return builder.toString() + } + + fun asWebSocketStr(): String { + val useTls = asHttpStr().startsWith("https") + val withoutScheme = asHttpStr() + .removePrefix("https") + .removePrefix("http") + val newPrefix = if (useTls) "wss" else "ws" + return "$newPrefix$withoutScheme" + } +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/User.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/User.kt new file mode 100644 index 0000000..0720926 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/User.kt @@ -0,0 +1,3 @@ +package ru.shadowsparky.vbox.shared.domain + +const val USER_ID_ARG = "user_id" diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/VideoApi.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/VideoApi.kt new file mode 100644 index 0000000..09bfa9b --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/VideoApi.kt @@ -0,0 +1,23 @@ +package ru.shadowsparky.vbox.shared.domain + +import ru.shadowsparky.vbox.shared.domain.model.VideoDetails +import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse +import ru.shadowsparky.vbox.shared.domain.model.VideosResponse + +const val DYNAMIC_PREFIX = "dynamic" + +interface VideoApi { + suspend fun fetchNewVideos(query: String? = null): VideosResponse + suspend fun fetchDetails(id: Long): VideoDetails + suspend fun fetchVideoLinks(id: Long, seasonId: Long?): VideoLinksResponse + + companion object { + const val SEARCH_ARG = "search" + const val ID_ARG = "id" + const val SEASON_ID_ARG = "season_id" + + const val VIDEOS_PATH = "videos" + const val DETAILS_PATH = "details" + const val LINKS_PATH = "links" + } +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Episode.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Episode.kt new file mode 100644 index 0000000..a442576 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Episode.kt @@ -0,0 +1,13 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Episode( + val episode: Long, + val files: List, + val title: String? = null, + val isWatched: Boolean = false +) { + val url: String = files.first().url +} diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/File.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/File.kt new file mode 100644 index 0000000..eb2bd4c --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/File.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class File( + val proPlus: Boolean?, + val quality: Int?, + val url: String +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/LoginInfo.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/LoginInfo.kt new file mode 100644 index 0000000..a7b6e00 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/LoginInfo.kt @@ -0,0 +1,16 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginInfo( + val login: String, + val passwordHash: String +) + +open class LoginException(override val message: String?) : IllegalStateException() + +class EmptyLoginException : LoginException("Login must be not empty!") +class EmptyPasswordException : LoginException("Password must be not empty!") +class ServerLoginException(message: String) : LoginException(message) +class UnknownLoginException(override val cause: Throwable?) : LoginException(cause?.message) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/RecentlyWatchedInfo.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/RecentlyWatchedInfo.kt new file mode 100644 index 0000000..0975227 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/RecentlyWatchedInfo.kt @@ -0,0 +1,11 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class RecentlyWatchedInfo( + val movieId: Long, + val episode: Long?, + val season: Long?, + val id: Long = -1 +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Season.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Season.kt new file mode 100644 index 0000000..ed77808 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/Season.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Season( + val episodes: List, + val id: Long, + val title: String +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/ServerExceptionInfo.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/ServerExceptionInfo.kt new file mode 100644 index 0000000..f2021ff --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/ServerExceptionInfo.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ServerExceptionInfo(val msg: String) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoDetails.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoDetails.kt new file mode 100644 index 0000000..510fcb7 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoDetails.kt @@ -0,0 +1,34 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class VideoDetails( + val id: Long, + val poster: String, + val desc: String, + val title: String, + val isSerial: Boolean +) { + val isMovie = !isSerial + + val flags: Int + get() { + return if (isSerial) SERIAL_FLAG else MOVIE_FLAG + } + + companion object { + fun getStub(title: String, isSerial: Boolean = true): VideoDetails { + return VideoDetails( + id = 1234, + poster = "https://poster.ru/img.png", + title = title, + desc = "Short Story Description!", + isSerial = isSerial + ) + } + } +} + +const val SERIAL_FLAG = 1 +const val MOVIE_FLAG = 1 shl 1 diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoItem.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoItem.kt new file mode 100644 index 0000000..506f190 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoItem.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class VideoItem( + val id: Long, + val poster: String, + val title: String, + val description: String? = null, + val isSerial: Boolean = false +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoLinksResponse.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoLinksResponse.kt new file mode 100644 index 0000000..d281ac8 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideoLinksResponse.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class VideoLinksResponse( + val name: String? = null, + val seasons: List? = null, + val files: List? = null, +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideosResponse.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideosResponse.kt new file mode 100644 index 0000000..17aafbc --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/model/VideosResponse.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.vbox.shared.domain.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class VideosResponse( + @SerialName("has_next_page") + val hasNextPage: Boolean, + val items: List, + val page: Int +) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/MovieTagRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/MovieTagRepository.kt new file mode 100644 index 0000000..a73a043 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/MovieTagRepository.kt @@ -0,0 +1,31 @@ +package ru.shadowsparky.vbox.shared.domain.tag + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX + +interface MovieTagRepository { + fun query(movieId: Long): Flow> + fun queryMovies(tagId: Long): Flow> + suspend fun link(movieId: Long, tagId: Long) + suspend fun unlink(movieId: Long, tagId: Long) + + suspend fun clear() {} + + companion object { + const val TAG_ID = "tagId" + const val MOVIE_ID = "movieId" + + const val PREFIX = "$DYNAMIC_PREFIX/movieTag" + const val QUERY = "$PREFIX/query" + const val QUERY_MOVIES = "$PREFIX/queryMovies" + const val LINK = "$PREFIX/link" + const val UNLINK = "$PREFIX/unlink" + } +} + +@Serializable +data class LinkRequest(val movieId: Long, val tagId: Long) + +@Serializable +data class LinkedMovies(val ids: Set) diff --git a/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/UserTagRepository.kt b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/UserTagRepository.kt new file mode 100644 index 0000000..a8a7446 --- /dev/null +++ b/apps/vbox/common/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/domain/tag/UserTagRepository.kt @@ -0,0 +1,42 @@ +package ru.shadowsparky.vbox.shared.domain.tag + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX + +interface UserTagRepository { + val userTags: Flow> + + suspend fun add(tag: String) + suspend fun edit(id: Long, newTag: String) + suspend fun delete(id: Long) + + suspend fun clear() {} + + companion object { + const val PREFIX = "$DYNAMIC_PREFIX/userTag" + + const val LIST = "$PREFIX/list" + const val ADD = "$PREFIX/add" + const val EDIT = "$PREFIX/edit" + const val DELETE = "$PREFIX/delete" + } +} + +@Serializable +data class TagResponse(val tags: List) + +@Serializable +data class AddRequest(val tag: String) + +@Serializable +data class EditRequest(val id: Long, val newTag: String) + +@Serializable +data class DeleteRequest(val id: Long) + +@Serializable +data class TagInfo( + val id: Long? = null, + val tag: String +) diff --git a/apps/vbox/common/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DesktopFileProvider.kt b/apps/vbox/common/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DesktopFileProvider.kt new file mode 100644 index 0000000..33200f1 --- /dev/null +++ b/apps/vbox/common/src/desktopMain/kotlin/ru/shadowsparky/vbox/shared/di/factory/DesktopFileProvider.kt @@ -0,0 +1,47 @@ +package ru.shadowsparky.vbox.shared.di.factory + +import org.koin.core.annotation.Single +import java.io.File + +@Single +class DesktopFileProvider { + private val homeUser by lazy { System.getProperty("user.home") } + private val appPackage = "ru.shadowsparky.vbox" + private var cacheDir: File? = null + + fun getCacheFileDir(): String? { + cacheDir?.let { return it.absolutePath } + return getCacheDir().apply { + mkdirs() + cacheDir = this + }.absolutePath + } + + private fun getCacheDir(): File { + getMacOsCacheDir()?.let { return it } + getLinuxCacheDir()?.let { return it } + getWindowsCacheDir()?.let { return it } + return File("cache") // fallback + } + + private fun getMacOsCacheDir(): File? { + return File(homeUser, "Library").provideCacheFile() + } + + private fun getLinuxCacheDir(): File? { + return File(homeUser, ".cache").provideCacheFile() + } + + private fun getWindowsCacheDir(): File? { + val appData = System.getenv("APPDATA") + return File(appData).provideCacheFile() + } + + private fun File.provideCacheFile(): File? { + return if (exists() && canRead() && canWrite()) { + File(this, appPackage) + } else { + null + } + } +} diff --git a/build-logic/.gitignore b/build-logic/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/build-logic/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/build-logic/convention/.gitignore b/build-logic/convention/.gitignore new file mode 100644 index 0000000..4295729 --- /dev/null +++ b/build-logic/convention/.gitignore @@ -0,0 +1,2 @@ +build +bin \ No newline at end of file diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts new file mode 100644 index 0000000..cc4ac3a --- /dev/null +++ b/build-logic/convention/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + `kotlin-dsl` +} + +dependencies { + compileOnly(libs.plugins.android.library.toDep()) + compileOnly(libs.plugins.android.application.toDep()) + compileOnly(libs.plugins.compose.compiler.toDep()) + compileOnly(libs.plugins.jetbrains.compose.toDep()) + compileOnly(libs.plugins.serialization.toDep()) + compileOnly(libs.plugins.kotlin.multiplatform.toDep()) + compileOnly(libs.plugins.devtools.ksp.toDep()) + compileOnly(libs.plugins.koin.compiler.toDep()) +} + +fun Provider.toDep() = map { + "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version}" +} + +val prefix = "ru.shadowsparky.plugin." + +gradlePlugin { + plugins { + create("conventionKmp") { + id = "convention.kmp" + implementationClass = "${prefix}Kmp" + } + create("conventionKtorClient") { + id = "convention.ktor-client" + implementationClass = "${prefix}KtorClient" + } + create("conventionCompose") { + id = "convention.compose" + implementationClass = "${prefix}Compose" + } + create("conventionSerialization") { + id = "convention.serialization" + implementationClass = "${prefix}Serialization" + } + create("conventionAndroidLib") { + id = "convention.android-lib" + implementationClass = "${prefix}AndroidLibrary" + } + create("conventionSqlDelight") { + id = "convention.sqldelight" + implementationClass = "${prefix}SqlDelight" + } + create("conventionJvm") { + id = "convention.jvm" + implementationClass = "${prefix}Jvm" + } + create("conventionKoin") { + id = "convention.koin" + implementationClass = "${prefix}Koin" + } + create("conventionKrpcClient") { + id = "convention.krpc-client" + implementationClass = "${prefix}KrpcClient" + } + create("conventionKrpcServer") { + id = "convention.krpc-server" + implementationClass = "${prefix}KrpcServer" + } + + create("conventionWebApp") { + id = "convention.web-app" + implementationClass = "${prefix}WebApplication" + } + create("conventionBackendApp") { + id = "convention.backend-app" + implementationClass = "${prefix}BackendApplication" + } + create("conventionAndroidApp") { + id = "convention.android-app" + implementationClass = "${prefix}AndroidApplication" + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/AndroidExt.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/AndroidExt.kt new file mode 100644 index 0000000..248ae78 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/AndroidExt.kt @@ -0,0 +1,39 @@ +package ru.shadowsparky + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Project + +fun LibraryExtension.setupAndroid(project: Project) { + val libs = project.findLibs() + compileSdk { + version = release(libs.findVersion("proj.compile").get().toString().toInt()) + } + defaultConfig { + minSdk = libs.findVersion("proj.min").get().toString().toInt() + } + compileOptions { + val javaVersion = JavaVersion.toVersion(libs.findVersion("proj.java").get()) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + } +} + +fun ApplicationExtension.setupAndroid(project: Project) { + val libs = project.findLibs() + compileSdk { + version = release(libs.findVersion("proj.compile").get().toString().toInt()) + } + defaultConfig { + minSdk = libs.findVersion("proj.min").get().toString().toInt() + targetSdk { + version = release(libs.findVersion("proj.target").get().toString().toInt()) + } + } + compileOptions { + val javaVersion = JavaVersion.toVersion(libs.findVersion("proj.java").get()) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/BuildFlags.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/BuildFlags.kt new file mode 100644 index 0000000..278ceba --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/BuildFlags.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky + +object BuildFlags { + const val DESKTOP = true +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/Ext.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/Ext.kt new file mode 100644 index 0000000..1451011 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/Ext.kt @@ -0,0 +1,79 @@ +package ru.shadowsparky + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Project +import org.gradle.api.artifacts.UnknownConfigurationException +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.DependencyHandlerScope +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.findByType + +internal fun Project.findLibs(): VersionCatalog { + return extensions.findByType(VersionCatalogsExtension::class.java)!!.named("libs") +} + +val Project.isAndroidApp: Boolean + get() { + return extensions.findByType() != null + } + +val Project.isAndroidLib: Boolean + get() { + return extensions.findByType() != null + } + +val Project.isWebApp: Boolean + get() { + return hasPlugin("convention-web-app") + } + +val Project.isBackendApp: Boolean + get() { + return hasPlugin("convention-backend-app") + } + +internal fun Project.hasAndroidSupport(): Boolean { + return isAndroidApp || isAndroidLib +} + +internal fun Project.hasMultiplatformSupport(): Boolean = hasPlugin("kotlin-multiplatform") + +private fun Project.hasPlugin(alias: String): Boolean { + val plugin = findLibs().findPlugin(alias).get().get().pluginId + return pluginManager.hasPlugin(plugin) +} + +internal fun DependencyHandlerScope.implementation(deps: Any) { + try { + add("commonMainImplementation", deps) + } catch (_: UnknownConfigurationException) { + add("implementation", deps) + } +} + +internal fun DependencyHandlerScope.androidImplementation(deps: Any) { + add("androidMainImplementation", deps) +} + +internal fun DependencyHandlerScope.desktopImplementation(deps: Any) { + if (BuildFlags.DESKTOP) add("desktopMainImplementation", deps) +} + +internal fun Project.applyCoroutines( + libs: VersionCatalog, + hasAndroid: Boolean = hasAndroidSupport() +) { + dependencies { + implementation(libs.findLibrary("kotlinx-coroutines-core").get()) + if (hasAndroid) { + val lib = libs.findLibrary("kotlinx.coroutines.android").get() + if (hasMultiplatformSupport()) { + androidImplementation(lib) + } else { + implementation(lib) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/KspExtensions.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/KspExtensions.kt new file mode 100644 index 0000000..4112166 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/KspExtensions.kt @@ -0,0 +1,48 @@ +package ru.shadowsparky + +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +private const val COMMON_MAIN = "kspCommonMainMetadata" + +private const val KSP = "ksp" +private const val KSP_ANDROID = "kspAndroid" +private const val KSP_ANDROID_JVM = "kspAndroidJvm" +private const val KSP_WASM_JS = "kspWasmJs" +private const val KSP_JS = "kspJs" +private const val KSP_DESKTOP = "kspDesktop" +private const val KSP_JVM = "kspJvm" + +private fun Project.hasConfig(name: String) = configurations.findByName(name) != null + +internal fun Project.kspAllPlatforms(deps: Any) { + + dependencies { + + // common + if (hasConfig(COMMON_MAIN)) { + add(COMMON_MAIN, deps) + } else { + add(KSP, deps) + return@dependencies + } + + // android + when { + hasConfig(KSP_ANDROID) -> add(KSP_ANDROID, deps) + hasConfig(KSP_ANDROID_JVM) -> add(KSP_ANDROID_JVM, deps) + } + + // web + when { + hasConfig(KSP_WASM_JS) -> add(KSP_WASM_JS, deps) + hasConfig(KSP_JS) -> add(KSP_JS, deps) + } + + // desktop + when { + hasConfig(KSP_DESKTOP) -> add(KSP_DESKTOP, deps) + hasConfig(KSP_JVM) -> add(KSP_JVM, deps) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidApplication.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidApplication.kt new file mode 100644 index 0000000..377775f --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidApplication.kt @@ -0,0 +1,53 @@ +package ru.shadowsparky.plugin + +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import ru.shadowsparky.applyCoroutines +import ru.shadowsparky.findLibs +import ru.shadowsparky.setupAndroid +import java.io.File +import java.io.FileInputStream +import java.util.Properties + +class AndroidApplication : Plugin { + override fun apply(target: Project) = with(target) { + val libs = findLibs() + pluginManager.apply(libs.findPlugin("android.application").get().get().pluginId) + extensions.configure { + val keystorePropertiesFile = File(rootDir, "keystore.properties") + signingConfigs { + if (keystorePropertiesFile.exists()) { + val keystoreProperties = Properties() + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + create("release") { + storeFile = File(rootDir, "release.keystore") + check(storeFile?.exists() == true) { "Unable to load keystore file!" } + storePassword = keystoreProperties["RELEASE_STORE_PASSWORD"].toString() + keyAlias = keystoreProperties["RELEASE_KEY_ALIAS"].toString() + keyPassword = keystoreProperties["RELEASE_KEY_PASSWORD"].toString() + } + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = true + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } + proguardFiles(file("proguard-rules.pro")) + } + getByName("debug") { + isMinifyEnabled = false + applicationIdSuffix = ".debug" + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } + } + } + setupAndroid(target) + } + applyCoroutines(libs) + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidLibrary.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidLibrary.kt new file mode 100644 index 0000000..24d7630 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/AndroidLibrary.kt @@ -0,0 +1,42 @@ +package ru.shadowsparky.plugin + +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Action +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import ru.shadowsparky.applyCoroutines +import ru.shadowsparky.findLibs +import ru.shadowsparky.hasMultiplatformSupport +import ru.shadowsparky.setupAndroid + +class AndroidLibrary : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + val isMultiplatform = hasMultiplatformSupport() + val pluginName = if (isMultiplatform) { + "multiplatform.android.library" + } else { + "android.library" + } + pluginManager.apply(libs.findPlugin(pluginName).get().get().pluginId) + if (isMultiplatform) { + extensions.configure { + android { + compileSdk = libs.findVersion("proj.compile").get().toString().toInt() + minSdk = libs.findVersion("proj.min").get().toString().toInt() + androidResources.enable = true + } + } + } else { + extensions.configure { setupAndroid(target) } + } + applyCoroutines(libs) + } +} + +private fun org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension.android(configure: Action): Unit = + (this as org.gradle.api.plugins.ExtensionAware).extensions.configure("android", configure) + diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/BackendApplication.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/BackendApplication.kt new file mode 100644 index 0000000..62babfe --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/BackendApplication.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import ru.shadowsparky.findLibs + +class BackendApplication : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("convention-jvm").get().get().pluginId) + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Compose.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Compose.kt new file mode 100644 index 0000000..78d53ce --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Compose.kt @@ -0,0 +1,28 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import org.jetbrains.compose.ComposePlugin +import ru.shadowsparky.findLibs +import ru.shadowsparky.implementation + +class Compose : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("jetbrains.compose").get().get().pluginId) + plugins.apply(libs.findPlugin("compose.compiler").get().get().pluginId) + dependencies { + val compose = ComposePlugin.Dependencies(target) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.components.resources) + + implementation(libs.findLibrary("compose-material3").get()) + implementation(libs.findLibrary("decompose").get()) + implementation(libs.findLibrary("decompose-extensions").get()) + implementation(libs.findLibrary("decompose-lifecycle-coroutines").get()) + implementation(libs.findLibrary("image.loader").get()) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Jvm.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Jvm.kt new file mode 100644 index 0000000..9ef9ca3 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Jvm.kt @@ -0,0 +1,22 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.kotlin.dsl.withType +import ru.shadowsparky.applyCoroutines +import ru.shadowsparky.findLibs + +class Jvm : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("java-library").get().get().pluginId) + plugins.apply(libs.findPlugin("jetbrains-kotlin-jvm").get().get().pluginId) + + tasks.withType { + sourceCompatibility = libs.findVersion("proj-server-java").get().toString() + targetCompatibility = libs.findVersion("proj-server-java").get().toString() + } + applyCoroutines(libs) + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Kmp.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Kmp.kt new file mode 100644 index 0000000..ae5994a --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Kmp.kt @@ -0,0 +1,32 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.kotlinExtension +import ru.shadowsparky.BuildFlags +import ru.shadowsparky.applyCoroutines +import ru.shadowsparky.findLibs + +class Kmp : Plugin { + + override fun apply(target: Project) = with(target) { + val libs = findLibs() + pluginManager.apply(libs.findPlugin("kotlin-multiplatform").get().get().pluginId) + extensions.configure { applyConfig() } + applyCoroutines(libs, false) + kotlinExtension.sourceSets.named("commonMain").configure { + kotlin.srcDirs( + "build/generated/ksp/metadata/commonMain/kotlin" + ) + } + } +} + +@OptIn(ExperimentalWasmDsl::class) +fun KotlinMultiplatformExtension.applyConfig() { + if (BuildFlags.DESKTOP) jvm("desktop") + wasmJs { browser() } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Koin.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Koin.kt new file mode 100644 index 0000000..7e4c751 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Koin.kt @@ -0,0 +1,44 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Action +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.ExtensionAware +import org.gradle.kotlin.dsl.dependencies +import org.koin.compiler.plugin.KoinGradleExtension +import ru.shadowsparky.androidImplementation +import ru.shadowsparky.findLibs +import ru.shadowsparky.hasAndroidSupport +import ru.shadowsparky.hasMultiplatformSupport +import ru.shadowsparky.implementation +import ru.shadowsparky.isAndroidApp +import ru.shadowsparky.isWebApp + +class Koin : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("koin-compiler").get().get().pluginId) + koinCompiler { + val checkSafety = isAndroidApp || isWebApp // || isBackendApp + if (checkSafety) { + println("koin check safety enabled for module ${target.name}") + } + compileSafety.set(checkSafety) + } + dependencies { + implementation(libs.findLibrary("koin-core").get()) + implementation(libs.findLibrary("koin-annotations").get()) + if (hasAndroidSupport()) { + val androidKoin = libs.findLibrary("koin-android").get() + if (hasMultiplatformSupport()) { + androidImplementation(androidKoin) + } else { + implementation(androidKoin) + } + } + } + } +} + +private fun Project.koinCompiler(configure: Action): Unit = + (this as ExtensionAware).extensions.configure("koinCompiler", configure) diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcClient.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcClient.kt new file mode 100644 index 0000000..7b77d8b --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcClient.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import ru.shadowsparky.findLibs +import ru.shadowsparky.implementation + +class KrpcClient : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("krpc").get().get().pluginId) + + dependencies { + implementation(libs.findLibrary("kotlinx-rpc-krpc-ktor-client").get()) + implementation(libs.findLibrary("kotlinx-rpc-krpc-serialization-json").get()) + implementation(libs.findLibrary("kotlinx-rpc-krpc-client").get()) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcServer.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcServer.kt new file mode 100644 index 0000000..fb1b1e4 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KrpcServer.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import ru.shadowsparky.findLibs +import ru.shadowsparky.implementation + +class KrpcServer : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("krpc").get().get().pluginId) + + dependencies { + implementation(libs.findLibrary("kotlinx-rpc-krpc-ktor-server").get()) + implementation(libs.findLibrary("kotlinx-rpc-krpc-serialization-json").get()) + implementation(libs.findLibrary("kotlinx-rpc-krpc-server").get()) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KtorClient.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KtorClient.kt new file mode 100644 index 0000000..669fcd1 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/KtorClient.kt @@ -0,0 +1,30 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import ru.shadowsparky.androidImplementation +import ru.shadowsparky.desktopImplementation +import ru.shadowsparky.findLibs +import ru.shadowsparky.implementation + +class KtorClient : Plugin { + override fun apply(target: Project) { + val libs = target.findLibs() + target.dependencies { + implementation(libs.findLibrary("ktor.client.websockets").get()) + implementation(libs.findLibrary("ktor-serialization-kotlinx-json").get()) + implementation(libs.findLibrary("ktor-client-content-negotiation").get()) + implementation(libs.findLibrary("ktor-client-logging").get()) + implementation(libs.findLibrary("ktor-client-auth").get()) + implementation(libs.findLibrary("ktor-client-core").get()) + val okHttp = libs.findLibrary("ktor.client.okhttp").get() + try { + androidImplementation(okHttp) + desktopImplementation(okHttp) + } catch (_: Exception) { + implementation(okHttp) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Serialization.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Serialization.kt new file mode 100644 index 0000000..701e04c --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/Serialization.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import ru.shadowsparky.findLibs +import ru.shadowsparky.implementation + +class Serialization : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("serialization").get().get().pluginId) + dependencies { + implementation(libs.findLibrary("kotlinx.serialization.json").get()) + implementation(libs.findLibrary("kotlinx.serialization.core").get()) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/SqlDelight.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/SqlDelight.kt new file mode 100644 index 0000000..6f7f8fb --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/SqlDelight.kt @@ -0,0 +1,28 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies +import ru.shadowsparky.androidImplementation +import ru.shadowsparky.desktopImplementation +import ru.shadowsparky.findLibs +import ru.shadowsparky.hasAndroidSupport +import ru.shadowsparky.implementation + +class SqlDelight : Plugin { + override fun apply(target: Project): Unit = with(target) { + val libs = findLibs() + plugins.apply(libs.findPlugin("sqldelight").get().get().pluginId) + dependencies { + implementation(libs.findLibrary("coroutines.extensions").get()) + implementation(libs.findLibrary("sql.delight.runtime").get()) + + if (hasAndroidSupport()) { + androidImplementation(libs.findLibrary("androidx.sqlite").get()) + androidImplementation(libs.findLibrary("androidx.sqlite.framework").get()) + } + + desktopImplementation(libs.findLibrary("sqlite.driver").get()) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/WebApplication.kt b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/WebApplication.kt new file mode 100644 index 0000000..886e40b --- /dev/null +++ b/build-logic/convention/src/main/kotlin/ru/shadowsparky/plugin/WebApplication.kt @@ -0,0 +1,9 @@ +package ru.shadowsparky.plugin + +import org.gradle.api.Plugin +import org.gradle.api.Project + +class WebApplication : Plugin { + override fun apply(target: Project) { + } +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..875164f --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,15 @@ +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e87b6f4 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,13 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.jetbrains.compose) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.koin.compiler) apply false + alias(libs.plugins.sqldelight) apply false + alias(libs.plugins.ktor) apply false + alias(libs.plugins.devtools.ksp) apply false + alias(libs.plugins.serialization) apply false + alias(libs.plugins.krpc) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..c157c29 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,13 @@ +kotlin.code.style=official +xcodeproj=./iosApp +android.useAndroidX=true +org.gradle.jvmargs=-Xms512m -Xmx4096m -XX:+HeapDumpOnOutOfMemoryError +org.jetbrains.compose.experimental.jscanvas.enabled=true +org.jetbrains.compose.experimental.uikit.enabled=true +android.nonTransitiveRClass=false +org.gradle.caching=true +org.gradle.parallel=true +org.gradle.configuration-cache=true +android.uniquePackageNames=false +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=false diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..500e909 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/248ffb1098f61659502d0c09aa348294/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ac151d55def6b6a9a159dc4cb4642851/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..8a3fe99 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,173 @@ +[versions] +activity-compose = "1.13.0" + +appcompat = "1.7.1" +coil-compose = "3.4.0" +compose-gradle-plugin = "1.11.0-rc01" +compose-version = "1.11.0-alpha03" +desugar_jdk_libs = "2.1.5" +haze = "1.7.2" +java-jwt = "4.5.2" +kotlin-version = "2.3.21" +agp = "9.0.1" + +coroutines-extensions = "1.5.5" +decompose-version = "3.5.0" +image-loader = "1.10.0" +junit = "4.13.2" +lifecycleCoroutines = "2.5.0" +material = "1.11.1" +materialVersion = "1.13.0" +media3 = "1.10.0" +mockk = "1.14.9" +jsoup = "1.22.2" +gradle-plugin = "1.5.5" +kotlinx-coroutines-core = "1.11.0" +kotlinx-serialization-json = "1.11.0" +ktor-plugin = "3.4.3" +kxml2 = "2.4.1" +logback-classic = "1.5.32" +multiplatform-settings = "1.3.0" +postgresql = "42.7.11" +runner = "1.7.0" +sqlite = "2.6.2" +sqlite-driver = "2.3.2" +ui-tooling = "1.11.1" +essenty = "1.3.0" +stately-common = "2.0.6" +proj-target = "36" +proj-compile = "36" +proj-min = "29" +proj-java = "21" +proj-server-java = "21" +datastore-preferences-core = "1.2.1" +ksp-version = "2.3.7" +koin-core = "4.2.1" +koin-compiler = "1.0.0-RC2" +kotlin-dsl-version = "5.1.2" +coreKtx = "1.18.0" +workRuntimeKtx = "2.11.2" +krpc = "0.10.2" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore-preferences-core" } + +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore-preferences-core" } +androidx-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-material = { module = "androidx.compose.material:material", version.ref = "material" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil-compose" } +com-android-application-gradle-plugin = { module = "com.android.application:com.android.application.gradle.plugin", version.ref = "agp" } +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } +haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } +java-jwt = { module = "com.auth0:java-jwt", version.ref = "java-jwt" } +jdbc-driver = { module = "app.cash.sqldelight:jdbc-driver", version.ref = "sqlite-driver" } +koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin-core" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor-plugin" } +ktor-server-cors = { module = "io.ktor:ktor-server-cors" } +ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt" } +ktor-server-auth = { module = "io.ktor:ktor-server-auth" } +ktor-server-netty-jvm = { module = "io.ktor:ktor-server-netty-jvm" } +ktor-serialization-kotlinx-json-jvm = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm" } +ktor-server-content-negotiation-jvm = { module = "io.ktor:ktor-server-content-negotiation-jvm" } +ktor-server-status-pages-jvm = { module = "io.ktor:ktor-server-status-pages-jvm" } +ktor-server-host-common-jvm = { module = "io.ktor:ktor-server-host-common-jvm" } +ktor-server-core-jvm = { module = "io.ktor:ktor-server-core-jvm" } +ktor-server-websockets-jvm = { module = "io.ktor:ktor-server-websockets-jvm" } +material = { module = "com.google.android.material:material", version.ref = "materialVersion" } +multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" } +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +compose-gradle-plugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-gradle-plugin" } +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-version" } + +decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose-version" } +decompose-extensions = { module = "com.arkivanov.decompose:extensions-compose", version.ref = "decompose-version" } +decompose-lifecycle-coroutines = { module = "com.arkivanov.essenty:lifecycle-coroutines", version.ref = "lifecycleCoroutines" } + +gradle = { module = "com.android.tools.build:gradle", version.ref = "agp" } +image-loader = { module = "io.github.qdsfdhvh:image-loader", version.ref = "image-loader" } +junit = { module = "junit:junit", version.ref = "junit" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin-core" } +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin-core" } +koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin-core" } +koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin-core" } + +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-core" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-core" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization-json" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor-plugin" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-plugin" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor-plugin" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor-plugin" } +kxml2 = { module = "com.github.kobjects:kxml2", version.ref = "kxml2" } +logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback-classic" } +ktor-plugin = { module = "io.ktor.plugin:plugin", version.ref = "ktor-plugin" } +postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" } +runner = { module = "androidx.test:runner", version.ref = "runner" } +android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlite-driver" } +coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqlite-driver" } +runtime-wasm-js = { module = "app.cash.sqldelight:runtime-wasm-js", version.ref = "sqlite-driver" } +sql-delight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqlite-driver" } +sql-delight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlite-driver" } +sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqlite-driver" } +sqldelight-web-driver = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqlite-driver" } + +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-plugin" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-plugin" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor-plugin" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor-plugin" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor-plugin" } +ui-compose = { module = "androidx.compose.ui:ui" } +ui-compose-util = { module = "androidx.compose.ui:ui-util" } + +ui-tooling-preview-android = { module = "androidx.compose.ui:ui-tooling-preview-android", version.ref = "ui-tooling" } +ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "ui-tooling" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-version" } +web-worker-driver-wasm-js = { module = "app.cash.sqldelight:web-worker-driver-wasm-js", version.ref = "sqlite-driver" } + +kotlinx-rpc-krpc-ktor-client = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-ktor-client", version.ref = "krpc" } +kotlinx-rpc-krpc-ktor-server = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-ktor-server", version.ref = "krpc" } +kotlinx-rpc-krpc-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-serialization-json", version.ref = "krpc" } +kotlinx-rpc-krpc-server = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-server", version.ref = "krpc" } +kotlinx-rpc-krpc-client = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-client", version.ref = "krpc" } + +[plugins] +jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-gradle-plugin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin-version" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-version" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqlite-driver" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-version" } +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +multiplatform-android-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +ktor = { id = "io.ktor.plugin", version.ref = "ktor-plugin" } +java-library = { id = "java-library", version = "unspecified" } +devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp-version" } +jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-version" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin-version" } +krpc = { id = "org.jetbrains.kotlinx.rpc.plugin", version.ref = "krpc" } +koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-compiler" } + +convention-jvm = { id = "convention.jvm", version = "unspecified" } +convention-kmp = { id = "convention.kmp", version = "unspecified" } +convention-ktor-client = { id = "convention.ktor-client", version = "unspecified" } +convention-compose = { id = "convention.compose", version = "unspecified" } +convention-serialization = { id = "convention.serialization", version = "unspecified" } +convention-android-lib = { id = "convention.android-lib", version = "unspecified" } +convention-sqldelight = { id = "convention.sqldelight", version = "unspecified" } +convention-koin = { id = "convention.koin", version = "unspecified" } +convention-krpc-client = { id = "convention.krpc-client", version = "unspecified" } +convention-krpc-server = { id = "convention.krpc-server", version = "unspecified" } +convention-android-app = { id = "convention.android-app", version = "unspecified" } +convention-web-app = { id = "convention.web-app", version = "unspecified" } +convention-backend-app = { id = "convention.backend-app", version = "unspecified" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..22d945d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat Apr 10 18:10:04 MSK 2021 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libs/android-stub/.gitignore b/libs/android-stub/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/android-stub/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/android-stub/build.gradle.kts b/libs/android-stub/build.gradle.kts new file mode 100644 index 0000000..268a4c0 --- /dev/null +++ b/libs/android-stub/build.gradle.kts @@ -0,0 +1,10 @@ + +import com.android.build.api.dsl.LibraryExtension + +plugins { + alias(libs.plugins.convention.android.lib) +} + +extensions.configure { + namespace = "ru.shadowsparky.androidstub" +} diff --git a/libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java b/libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java new file mode 100644 index 0000000..966aa14 --- /dev/null +++ b/libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java @@ -0,0 +1,5 @@ +package com.android.internal.telephony; + +public interface ITelephony { + boolean endCall(); +} diff --git a/libs/backend-base/.gitignore b/libs/backend-base/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/backend-base/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/backend-base/build.gradle.kts b/libs/backend-base/build.gradle.kts new file mode 100644 index 0000000..7400a92 --- /dev/null +++ b/libs/backend-base/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.convention.jvm) + alias(libs.plugins.convention.koin) +} +dependencies { + implementation(libs.java.jwt) + implementation(libs.logback.classic) +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/BackendCommon.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/BackendCommon.kt new file mode 100644 index 0000000..97b4e68 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/BackendCommon.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.backend + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@ComponentScan +@Configuration +class BackendCommon diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/EnvFetcher.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/EnvFetcher.kt new file mode 100644 index 0000000..a3e6e65 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/EnvFetcher.kt @@ -0,0 +1,11 @@ +package ru.shadowsparky.backend.data + +import org.koin.core.annotation.Single + +@Single +class EnvFetcher { + fun get(key: String, default: String = ""): String { + val value = System.getenv(key) + return value?.ifEmpty { default } ?: default + } +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/JwtPreparer.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/JwtPreparer.kt new file mode 100644 index 0000000..4dfe122 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/JwtPreparer.kt @@ -0,0 +1,33 @@ +package ru.shadowsparky.backend.data + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import org.koin.core.annotation.Single +import ru.shadowsparky.backend.domain.JwtInfo +import ru.shadowsparky.backend.domain.LOGIN_NAME +import ru.shadowsparky.backend.domain.USER_ID_ARG +import java.util.Date +import java.util.concurrent.TimeUnit + +@Single +class JwtPreparer( + private val jwtInfo: JwtInfo, + private val envFetcher: EnvFetcher +) { + private val tokenTimeInMinutes = envFetcher.get("JWT_DURATION", "30").toLong() + fun prepare( + login: String, + userId: Long + ): String { + val exp = Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(tokenTimeInMinutes)) + val token = JWT.create() + .withAudience(jwtInfo.audience) + .withIssuer(jwtInfo.issuer) + .withClaim(LOGIN_NAME, login) + .withClaim(USER_ID_ARG, userId) + .withSubject(userId.toString()) + .withExpiresAt(exp) + .sign(Algorithm.HMAC512(jwtInfo.secret)) + return token + } +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/Logger.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/Logger.kt new file mode 100644 index 0000000..a8f483b --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/Logger.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.backend.data + +import org.koin.core.annotation.Single +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +@Single +class Logger { + private val loggers = hashMapOf() + + fun info(tag: String, message: String) = obtainLogger(tag).info(message) + fun debug(tag: String, message: String) = obtainLogger(tag).debug(message) + fun error(tag: String, e: Exception) = obtainLogger(tag).error("error occurred", e) + + private fun obtainLogger(tag: String): Logger { + loggers[tag]?.let { return it } + loggers[tag] = LoggerFactory.getLogger(tag) + return loggers[tag]!! + } +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/StringFetcher.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/StringFetcher.kt new file mode 100644 index 0000000..e6ae0b5 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/StringFetcher.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.backend.data + +import org.koin.core.annotation.Single + +@Single +class StringFetcher { + fun get(resource: StringResource): String { + return resource.message + } +} + +enum class StringResource(val message: String) { + UNKNOWN_USER("Пользователь не найден или неверный пароль"), + INVALID_PASSWORD("Вы ввели неверный пароль"), + NO_CHANGES_PASS("Старый и новый пароль должны отличаться"), + USER_BLOCKED("Пользователь заблокирован"), + REG_BLOCKED("Регистрация недоступна. Доступ по приглашениям."), + ALREADY_REGISTERED("Пользователь уже существует"), + VIDEO_NOT_FOUND("Видео не найдено.") +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/TokenVerifier.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/TokenVerifier.kt new file mode 100644 index 0000000..006cbb3 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/data/TokenVerifier.kt @@ -0,0 +1,27 @@ +package ru.shadowsparky.backend.data + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.interfaces.DecodedJWT +import org.koin.core.annotation.Factory +import ru.shadowsparky.backend.domain.JwtInfo +import ru.shadowsparky.backend.domain.LOGIN_NAME +import ru.shadowsparky.backend.domain.LoginVerifier + +@Factory +class TokenVerifier( + private val jwtInfo: JwtInfo, + private val loginVerifier: LoginVerifier +) { + + val verifier = JWT.require(Algorithm.HMAC512(jwtInfo.secret)) + .withAudience(jwtInfo.audience) + .withIssuer(jwtInfo.issuer) + .build() + + suspend fun verify(token: String): DecodedJWT { + val jwt = verifier.verify(token) + loginVerifier.verify(jwt.getClaim(LOGIN_NAME).asString()) + return jwt + } +} diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/JwtInfo.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/JwtInfo.kt new file mode 100644 index 0000000..3a69cd4 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/JwtInfo.kt @@ -0,0 +1,8 @@ +package ru.shadowsparky.backend.domain + +data class JwtInfo( + val secret: String, + val issuer: String, + val audience: String, + val realm: String +) diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/LoginVerifier.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/LoginVerifier.kt new file mode 100644 index 0000000..741eb44 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/LoginVerifier.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.backend.domain + +interface LoginVerifier { + suspend fun verify(login: String) +} + +const val LOGIN_NAME = "login" +const val USER_ID_ARG = "user_id" + +const val INVALID_TOKEN_MSG = "Был передан неправильный токен" diff --git a/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/VerifyTokenException.kt b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/VerifyTokenException.kt new file mode 100644 index 0000000..1c4ee12 --- /dev/null +++ b/libs/backend-base/src/main/kotlin/ru/shadowsparky/backend/domain/VerifyTokenException.kt @@ -0,0 +1,3 @@ +package ru.shadowsparky.backend.domain + +class VerifyTokenException(override val message: String?) : IllegalStateException() diff --git a/libs/base/.gitignore b/libs/base/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/base/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/base/build.gradle.kts b/libs/base/build.gradle.kts new file mode 100644 index 0000000..f032688 --- /dev/null +++ b/libs/base/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.multiplatform.settings) + } + } + } + android { namespace = "ru.shadowsparky" } +} diff --git a/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/AndroidClipboardManager.kt b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/AndroidClipboardManager.kt new file mode 100644 index 0000000..cdf3725 --- /dev/null +++ b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/AndroidClipboardManager.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.data + +import android.content.ClipData +import android.content.Context +import org.koin.core.annotation.Factory +import ru.shadowsparky.domain.Clip +import ru.shadowsparky.domain.ClipboardManager + +@Factory +class AndroidClipboardManager(private val context: Context) : ClipboardManager { + private val service by lazy { + context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + } + + override suspend fun setText(clip: Clip) { + service.setPrimaryClip(ClipData.newPlainText(clip.title, clip.text)) + } +} diff --git a/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/Log.android.kt b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/Log.android.kt new file mode 100644 index 0000000..aa3bd0a --- /dev/null +++ b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/Log.android.kt @@ -0,0 +1,17 @@ +package ru.shadowsparky.data + +import org.koin.core.annotation.Single +import ru.shadowsparky.domain.Log + +private typealias Impl = android.util.Log + +@Single +class AndroidLog : Log { + override fun d(tag: String, msg: String?, e: Throwable?) { + Impl.d(tag, msg, e) + } + + override fun e(tag: String, e: Throwable?, msg: String?) { + Impl.e(tag, msg, e) + } +} diff --git a/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/SettingsFactory.android.kt b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/SettingsFactory.android.kt new file mode 100644 index 0000000..2407050 --- /dev/null +++ b/libs/base/src/androidMain/kotlin/ru/shadowsparky/data/SettingsFactory.android.kt @@ -0,0 +1,15 @@ +package ru.shadowsparky.data + +import android.app.Application +import android.content.Context +import com.russhwolf.settings.Settings +import com.russhwolf.settings.SharedPreferencesSettings +import org.koin.core.annotation.Single + +@Single +class AndroidSettingsFactory(private val app: Application) : SettingsFactory { + override fun create(name: String): Settings { + val prefs = app.getSharedPreferences(name, Context.MODE_PRIVATE) + return SharedPreferencesSettings(prefs) + } +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/CommonModule.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/CommonModule.kt new file mode 100644 index 0000000..af7c767 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/CommonModule.kt @@ -0,0 +1,37 @@ +package ru.shadowsparky + +import kotlinx.serialization.json.Json +import org.koin.core.Koin +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.koin.core.component.KoinComponent +import ru.shadowsparky.data.SettingsFactory +import ru.shadowsparky.data.SettingsKeyValueStorage +import ru.shadowsparky.domain.DispatcherProvider +import ru.shadowsparky.domain.KeyValueStorage + +val koin: Koin get() = object : KoinComponent {}.getKoin() + +@Module +@ComponentScan +@Configuration +class CommonModule { + + @Single + fun provideJson(): Json { + return Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + } + + @Single + fun provideSettings( + dispatcherProvider: DispatcherProvider, + settingsFactory: SettingsFactory + ): KeyValueStorage { + return SettingsKeyValueStorage(dispatcherProvider, "app_settings", settingsFactory) + } +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/DispatcherProviderImpl.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/DispatcherProviderImpl.kt new file mode 100644 index 0000000..99fe586 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/DispatcherProviderImpl.kt @@ -0,0 +1,14 @@ +package ru.shadowsparky.data + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import org.koin.core.annotation.Singleton +import ru.shadowsparky.domain.DispatcherProvider + +@Singleton +class DispatcherProviderImpl : DispatcherProvider { + override val io: CoroutineDispatcher = Dispatchers.Default + override val main: CoroutineDispatcher = Dispatchers.Main + override val unconfined: CoroutineDispatcher = Dispatchers.Unconfined + override val default: CoroutineDispatcher = Dispatchers.Default +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/KeyValueStorageImpl.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/KeyValueStorageImpl.kt new file mode 100644 index 0000000..c6383a5 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/KeyValueStorageImpl.kt @@ -0,0 +1,110 @@ +package ru.shadowsparky.data + +import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.Settings +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.shadowsparky.domain.DispatcherProvider +import ru.shadowsparky.domain.KeyValueStorage + +private typealias NotifyCallback = ((String?) -> Unit)? + +class SettingsKeyValueStorage( + private val dispatcherProvider: DispatcherProvider, + private val name: String, + private val settingsFactory: SettingsFactory +) : KeyValueStorage { + private val impl: BaseImpl by lazy { + val settings = settingsFactory.create(name) + if (settings is ObservableSettings) { + DefaultImpl(settings) + } else { + FallbackImpl(settings) + } + } + + override fun get(key: String): Flow { + return impl.get(key) + } + + override suspend fun remove(key: String) = withContext(dispatcherProvider.io) { + impl.remove(key) + } + + override suspend fun put(key: String, value: String) = withContext(dispatcherProvider.io) { + impl.put(key, value) + } + + private abstract class BaseImpl(private val settings: Settings) { + + open fun remove(key: String) { + settings.remove(key) + } + + open fun put(key: String, value: String) { + settings.putString(key, value) + } + + abstract fun get(key: String): Flow + } + + private class DefaultImpl(private val settings: ObservableSettings) : BaseImpl(settings) { + override fun get(key: String): Flow { + return callbackFlow { + val listener: (String?) -> Unit = { + launch { send(it) } + } + send(settings.getStringOrNull(key)) + val disposable = settings.addStringOrNullListener(key, listener) + awaitClose { disposable.deactivate() } + } + } + } + + private class FallbackImpl(private val settings: Settings) : BaseImpl(settings) { + private val listenerMap = hashMapOf>() + + override fun get(key: String): Flow = callbackFlow { + val listener: (String?) -> Unit = { + launch { send(it) } + } + send(settings.getStringOrNull(key)) + put(key, listener) + awaitClose { + remove(key, listener) + } + } + + private fun put(key: String, listener: NotifyCallback) { + val list = listenerMap[key] ?: mutableListOf() + list.add(listener) + listenerMap[key] = list + } + + private fun remove(key: String, listener: NotifyCallback) { + val list = listenerMap[key] ?: return + val index = list.indexOf(listener) + if (index >= 0) { + list.removeAt(index) + } + listenerMap[key] = list + } + + override fun remove(key: String) { + super.remove(key) + listenerMap[key]?.forEach { + it?.invoke(null) + } + } + + override fun put(key: String, value: String) { + super.put(key, value) + listenerMap[key]?.forEach { + it?.invoke(value) + } + } + } +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/SettingsFactory.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/SettingsFactory.kt new file mode 100644 index 0000000..2b98ea4 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/data/SettingsFactory.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.data + +import com.russhwolf.settings.Settings + +interface SettingsFactory { + fun create(name: String): Settings +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt new file mode 100644 index 0000000..254202f --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.domain + +interface AppConfig { + fun isDebug(): Boolean + fun isBackend(): Boolean = false +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/ClipboardManager.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/ClipboardManager.kt new file mode 100644 index 0000000..595bed1 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/ClipboardManager.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.domain + +interface ClipboardManager { + suspend fun setText(clip: Clip) +} + +data class Clip(val title: String, val text: String) diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/DispatcherProvider.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/DispatcherProvider.kt new file mode 100644 index 0000000..416838d --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/DispatcherProvider.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.domain + +import kotlinx.coroutines.CoroutineDispatcher + +interface DispatcherProvider { + val io: CoroutineDispatcher + val main: CoroutineDispatcher + val unconfined: CoroutineDispatcher + val default: CoroutineDispatcher +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/KeyValueStorage.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/KeyValueStorage.kt new file mode 100644 index 0000000..1e727d7 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/KeyValueStorage.kt @@ -0,0 +1,15 @@ +package ru.shadowsparky.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface KeyValueStorage { + fun get(key: String): Flow + fun getBoolean(key: String): Flow = get(key).map { it?.toBoolean() } + fun getInt(key: String): Flow = get(key).map { it?.toInt() } + + suspend fun remove(key: String) + suspend fun put(key: String, value: String) + suspend fun putBoolean(key: String, value: Boolean) = put(key, value.toString()) + suspend fun putInt(key: String, value: Int) = put(key, value.toString()) +} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/Log.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/Log.kt new file mode 100644 index 0000000..a0e2c89 --- /dev/null +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/Log.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.domain + +interface Log { + fun d(tag: String, msg: String? = null, e: Throwable? = null) + fun e(tag: String, e: Throwable? = null, msg: String? = null) +} diff --git a/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/LogImpl.desktop.kt b/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/LogImpl.desktop.kt new file mode 100644 index 0000000..8e1fb90 --- /dev/null +++ b/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/LogImpl.desktop.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.data + +import org.koin.core.annotation.Single +import ru.shadowsparky.domain.Log + +@Single +class DesktopLog : Log { + + override fun d(tag: String, msg: String?, e: Throwable?) { + println("$tag $msg") + e?.printStackTrace() + } + + override fun e(tag: String, e: Throwable?, msg: String?) { + println("$tag $msg") + e?.printStackTrace() + } +} diff --git a/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/SettingsFactory.desktop.kt b/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/SettingsFactory.desktop.kt new file mode 100644 index 0000000..1cd7601 --- /dev/null +++ b/libs/base/src/desktopMain/kotlin/ru/shadowsparky/data/SettingsFactory.desktop.kt @@ -0,0 +1,13 @@ +package ru.shadowsparky.data + +import com.russhwolf.settings.PreferencesSettings +import com.russhwolf.settings.Settings +import org.koin.core.annotation.Single +import java.util.prefs.Preferences + +@Single +class DesktopSettingsFactory : SettingsFactory { + override fun create(name: String): Settings { + return PreferencesSettings(Preferences.userRoot().node(name)) + } +} diff --git a/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/LogImpl.wasmJs.kt b/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/LogImpl.wasmJs.kt new file mode 100644 index 0000000..7313901 --- /dev/null +++ b/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/LogImpl.wasmJs.kt @@ -0,0 +1,17 @@ +package ru.shadowsparky.data + +import org.koin.core.annotation.Single +import ru.shadowsparky.domain.Log + +@Single +class WasmLog : Log { + override fun d(tag: String, msg: String?, e: Throwable?) { + println("$tag $msg") + e?.printStackTrace() + } + + override fun e(tag: String, e: Throwable?, msg: String?) { + println("$tag $msg") + e?.printStackTrace() + } +} diff --git a/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/SettingsFactory.wasmJs.kt b/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/SettingsFactory.wasmJs.kt new file mode 100644 index 0000000..d8f4020 --- /dev/null +++ b/libs/base/src/wasmJsMain/kotlin/ru/shadowsparky/data/SettingsFactory.wasmJs.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.data + +import com.russhwolf.settings.Settings +import com.russhwolf.settings.StorageSettings +import org.koin.core.annotation.Single + +@Single +class WasmSettingsFactory : SettingsFactory { + override fun create(name: String): Settings { + return StorageSettings() + } +} diff --git a/libs/http-client/.gitignore b/libs/http-client/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/http-client/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/http-client/build.gradle.kts b/libs/http-client/build.gradle.kts new file mode 100644 index 0000000..f4c134a --- /dev/null +++ b/libs/http-client/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) + alias(libs.plugins.convention.ktor.client) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":libs:base")) + implementation(libs.ktor.client.cio) + implementation(libs.kotlinx.rpc.krpc.client) + // Server API + implementation(libs.kotlinx.rpc.krpc.server) + // Serialization module. Also, protobuf and cbor are provided + implementation(libs.kotlinx.rpc.krpc.serialization.json) + + // Transport implementation for Ktor + implementation(libs.kotlinx.rpc.krpc.ktor.client) + implementation(libs.kotlinx.rpc.krpc.ktor.server) + } + } + } + android { namespace = "ru.shadowsparky.http" } +} diff --git a/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt b/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt new file mode 100644 index 0000000..8b68748 --- /dev/null +++ b/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt @@ -0,0 +1,26 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) { + + actual fun create(): HttpClient { + return HttpClient(OkHttp) { + settings.setup(this) + engine { + config { + retryOnConnectionFailure(true) + connectTimeout(15, TimeUnit.SECONDS) + readTimeout(15, TimeUnit.SECONDS) + writeTimeout(15, TimeUnit.SECONDS) + } + preconfigured = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .build() + } + } + } +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt new file mode 100644 index 0000000..1df5567 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt @@ -0,0 +1,69 @@ +package ru.shadowsparky.http + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.RefreshTokensParams +import kotlinx.coroutines.flow.firstOrNull +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import ru.shadowsparky.http.data.HttpClientFactory +import ru.shadowsparky.http.data.HttpClientSettings +import ru.shadowsparky.http.data.RefreshTokenRunner +import ru.shadowsparky.http.domain.HttpException +import ru.shadowsparky.http.domain.LogoutAction +import ru.shadowsparky.http.domain.RefreshTokenAction +import ru.shadowsparky.http.domain.TokenInfo +import ru.shadowsparky.http.domain.TokenStorage +import ru.shadowsparky.koin + +@Module +@Configuration +@ComponentScan +class HttpModule { + + @Single + fun createHttpClient(settings: HttpClientSettings): HttpClient { + return HttpClientFactory(settings).create() + } + + @Single + fun createBearerAuthProvider( + refreshTokenRunner: RefreshTokenRunner + ): BearerAuthProvider { + val tokenStorage = koin.getOrNull() + return BearerAuthProvider( + refreshTokens = { + refreshTokenRunner.runOrJoin { + refreshTokenInternal(tokenStorage) + } + }, + loadTokens = { + tokenStorage?.token?.firstOrNull()?.toKtor() + }, + realm = tokenStorage?.realm + ) + } + + private suspend fun RefreshTokensParams.refreshTokenInternal(tokenStorage: TokenStorage?): BearerTokens? { + val token = tokenStorage?.token?.firstOrNull()?.refresh ?: return null + return try { + val newToken = koin.getOrNull() + ?.refresh(this.client, token) + ?: return null + tokenStorage.updateToken(newToken) + newToken.toKtor() + } catch (e: Throwable) { + if (e is HttpException) { + koin.getOrNull()?.logout() + } + null + } + } +} + +private fun TokenInfo.toKtor(): BearerTokens { + return BearerTokens(token, refresh) +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt new file mode 100644 index 0000000..b7464ad --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient + +expect class HttpClientFactory(settings: HttpClientSettings) { + fun create(): HttpClient +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt new file mode 100644 index 0000000..2bf41a6 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt @@ -0,0 +1,111 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClientConfig +import io.ktor.client.call.body +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.HttpTimeoutConfig +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.request.header +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.koin.core.annotation.Factory +import ru.shadowsparky.domain.AppConfig +import ru.shadowsparky.domain.Log +import ru.shadowsparky.http.domain.ClientKtorExtension +import ru.shadowsparky.http.domain.HttpException +import ru.shadowsparky.http.domain.UserAgentProvider +import ru.shadowsparky.koin + +const val USE_HTTP_CUSTOM_CERTS = "use_http_custom_certs" + +private const val MESSAGE_LONG = "message" +private const val MESSAGE = "msg" + +@Factory +class HttpClientSettings( + private val json: Json, + private val userAgentProvider: UserAgentProvider?, + private val bearerAuthProvider: BearerAuthProvider, + private val appenderList: List, + private val log: Log +) { + + fun setup( + clientConfig: HttpClientConfig<*> + ) = with(clientConfig) { + install(Auth) { + providers.add(bearerAuthProvider) + } + expectSuccess = false + + HttpResponseValidator { + validateResponse { + val code = it.status.value + if (code in 400..599) { + val body = it.body() + if (body is JsonObject) { + val msg = when { + body.containsKey(MESSAGE_LONG) -> body[MESSAGE_LONG]?.jsonPrimitive?.content + body.containsKey(MESSAGE) -> body[MESSAGE]?.jsonPrimitive?.content + else -> body.toString() + } + throw HttpException(code, msg) + } else { + throw HttpException(code, body.toString()) + } + } + } + } + + install(Logging) { + val isDebug = koin.getOrNull()?.isDebug() ?: true + if (isDebug) { + this.level = LogLevel.ALL + this.logger = object : Logger { + override fun log(message: String) { + log.d("Http", message) + } + } + } + } + + install(ContentNegotiation) { + json(json) + } + + install(WebSockets) { + // как часто слать ping кадры + pingIntervalMillis = 30_000 // или: pingInterval = 20.seconds (в новых API) + } + + defaultRequest { + header(HttpHeaders.Accept, ContentType.Application.Json) + header(HttpHeaders.ContentType, ContentType.Application.Json) + userAgentProvider?.provide()?.let { + header(HttpHeaders.UserAgent, it) + } + } + + // Таймауты на уровне Ktor + install(HttpTimeout) { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + connectTimeoutMillis = 20_000 + } + + appenderList.forEach { it.append(this) } + } +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt new file mode 100644 index 0000000..e441036 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt @@ -0,0 +1,39 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.plugins.auth.providers.BearerTokens +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Factory +import ru.shadowsparky.domain.DispatcherProvider + + +@Factory +class RefreshTokenRunner(private val dispatcherProvider: DispatcherProvider) { + private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io) + private val lock = Mutex() + private var inFlight: Deferred? = null + + suspend fun runOrJoin(block: suspend () -> BearerTokens?): BearerTokens? { + val deferred = lock.withLock { + inFlight?.takeIf { it.isActive } ?: startNew(block).also { inFlight = it } + } + return deferred.await() + } + + private fun startNew(block: suspend () -> BearerTokens?): Deferred { + val d = scope.async(dispatcherProvider.io) { block() } + d.invokeOnCompletion { + scope.launch { + lock.withLock { + if (inFlight === d) inFlight = null + } + } + } + return d + } +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt new file mode 100644 index 0000000..f5f935b --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.http.domain + +import io.ktor.client.HttpClientConfig + +interface ClientKtorExtension { + fun append(clientConfig: HttpClientConfig<*>) +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt new file mode 100644 index 0000000..bf1295c --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpException.kt @@ -0,0 +1,8 @@ +package ru.shadowsparky.http.domain + +import io.ktor.http.HttpStatusCode + +open class HttpException(val httpCode: Int, override val message: String?) : RuntimeException() + +class BadRequestException(msg: String) : HttpException(HttpStatusCode.BadRequest.value, msg) +open class NotFoundException(msg: String) : HttpException(HttpStatusCode.NotFound.value, msg) diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/LogoutAction.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/LogoutAction.kt new file mode 100644 index 0000000..5533254 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/LogoutAction.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.http.domain + +interface LogoutAction { + suspend fun logout() +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/RefreshTokenAction.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/RefreshTokenAction.kt new file mode 100644 index 0000000..6521d6b --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/RefreshTokenAction.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.http.domain + +import io.ktor.client.HttpClient + +interface RefreshTokenAction { + suspend fun refresh(client: HttpClient, refreshToken: String): TokenInfo +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenInfo.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenInfo.kt new file mode 100644 index 0000000..1962eb2 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenInfo.kt @@ -0,0 +1,9 @@ +package ru.shadowsparky.http.domain + +import kotlinx.serialization.Serializable + +@Serializable +data class TokenInfo( + val token: String, + val refresh: String? = null +) diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenStorage.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenStorage.kt new file mode 100644 index 0000000..6cfa6b9 --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/TokenStorage.kt @@ -0,0 +1,16 @@ +package ru.shadowsparky.http.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull + +interface TokenStorage { + val realm: String + val token: Flow + + suspend fun updateToken(newToken: TokenInfo) + suspend fun clear() +} + +suspend fun TokenStorage.hasAccount(): Boolean { + return token.firstOrNull()?.token != null +} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/UserAgentProvider.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/UserAgentProvider.kt new file mode 100644 index 0000000..355303f --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/UserAgentProvider.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.http.domain + +interface UserAgentProvider { + fun provide(): String? +} diff --git a/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt b/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt new file mode 100644 index 0000000..56f88d7 --- /dev/null +++ b/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt @@ -0,0 +1,23 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) { + actual fun create(): HttpClient { + return HttpClient(OkHttp) { + settings.setup(this) + engine { + config { + retryOnConnectionFailure(true) + connectTimeout(15, TimeUnit.SECONDS) + readTimeout(15, TimeUnit.SECONDS) + writeTimeout(15, TimeUnit.SECONDS) + } + preconfigured = OkHttpClient.Builder().pingInterval(30, TimeUnit.SECONDS).build() + } + } + } +} diff --git a/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt b/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt new file mode 100644 index 0000000..99ac603 --- /dev/null +++ b/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt @@ -0,0 +1,12 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient +import io.ktor.client.engine.js.Js + +actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) { + actual fun create(): HttpClient { + return HttpClient(Js) { + settings.setup(this) + } + } +} diff --git a/libs/ui/.gitignore b/libs/ui/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/ui/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/ui/build.gradle.kts b/libs/ui/build.gradle.kts new file mode 100644 index 0000000..27a2c93 --- /dev/null +++ b/libs/ui/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) + alias(libs.plugins.convention.compose) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.haze) + implementation(libs.haze.materials) + } + } + } + android { namespace = "ru.shadowsparky.ui" } +} diff --git a/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.android.kt b/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.android.kt new file mode 100644 index 0000000..b37399e --- /dev/null +++ b/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.android.kt @@ -0,0 +1,14 @@ +package ru.shadowsparky.ui.components + +import android.app.UiModeManager +import android.content.Context +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun isTv(): Boolean { + val context = LocalContext.current + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION +} diff --git a/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/theme/Theme.android.kt b/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/theme/Theme.android.kt new file mode 100644 index 0000000..3557b57 --- /dev/null +++ b/libs/ui/src/androidMain/kotlin/ru/shadowsparky/ui/theme/Theme.android.kt @@ -0,0 +1,26 @@ +package ru.shadowsparky.ui.theme + +import android.os.Build +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun lightDynamicScheme(): ColorScheme? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicLightColorScheme(LocalContext.current) + } else { + null + } +} + +@Composable +actual fun darkDynamicScheme(): ColorScheme? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicDarkColorScheme(LocalContext.current) + } else { + null + } +} diff --git a/libs/ui/src/commonMain/composeResources/drawable/back.xml b/libs/ui/src/commonMain/composeResources/drawable/back.xml new file mode 100644 index 0000000..0e5dd04 --- /dev/null +++ b/libs/ui/src/commonMain/composeResources/drawable/back.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/libs/ui/src/commonMain/composeResources/drawable/check.xml b/libs/ui/src/commonMain/composeResources/drawable/check.xml new file mode 100644 index 0000000..1228f47 --- /dev/null +++ b/libs/ui/src/commonMain/composeResources/drawable/check.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/libs/ui/src/commonMain/composeResources/drawable/visibility.xml b/libs/ui/src/commonMain/composeResources/drawable/visibility.xml new file mode 100644 index 0000000..cf02125 --- /dev/null +++ b/libs/ui/src/commonMain/composeResources/drawable/visibility.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/ui/src/commonMain/composeResources/drawable/visibility_off.xml b/libs/ui/src/commonMain/composeResources/drawable/visibility_off.xml new file mode 100644 index 0000000..a1b2f6e --- /dev/null +++ b/libs/ui/src/commonMain/composeResources/drawable/visibility_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/BaseScreen.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/BaseScreen.kt new file mode 100644 index 0000000..04cd278 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/BaseScreen.kt @@ -0,0 +1,166 @@ +package ru.shadowsparky.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import ru.shadowsparky.ui.nav.Route +import ru.shadowsparky.ui.theme.ComponentDefaults +import ru.shadowsparky.ui.theme.getColorScheme + +const val USE_EXPERIMENTAL_RAIL = false + +@Immutable +data class NavigationEntry( + val route: T, + val title: String, + val painter: Painter +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseScreen( + modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior? = TopAppBarDefaults.pinnedScrollBehavior(), + snackbarState: SnackbarHostState? = null, + colorScheme: ColorScheme = getColorScheme(), + appBar: @Composable (scroll: TopAppBarScrollBehavior?) -> Unit, + content: @Composable (padding: PaddingValues) -> Unit, +) { + BaseScreen( + modifier, + scrollBehavior, + snackbarState, + colorScheme, + appBar = appBar, + content = content + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun BaseScreen( + modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior? = TopAppBarDefaults.pinnedScrollBehavior(), + snackbarState: SnackbarHostState? = null, + colorScheme: ColorScheme = getColorScheme(), + currentRoute: T? = null, + navigationEntries: List>? = null, + onSelectNavigation: ((T) -> Unit)? = null, + appBar: @Composable (scroll: TopAppBarScrollBehavior?) -> Unit, + content: @Composable (padding: PaddingValues) -> Unit, +) { + val windowType = currentWindowType() + MaterialExpressiveTheme(colorScheme = colorScheme) { + Scaffold( + bottomBar = { + if (windowType.isCompact || !USE_EXPERIMENTAL_RAIL) { + BottomBar(navigationEntries, currentRoute, onSelectNavigation) + } + }, + topBar = { + appBar(scrollBehavior) + }, content = { + if (windowType.isExpanded && navigationEntries?.isNotEmpty() == true && USE_EXPERIMENTAL_RAIL) { + ExpandedContent( + it, + navigationEntries, + currentRoute, + onSelectNavigation, + content + ) + } else { + content(it) + } + }, modifier = if (scrollBehavior != null) { + modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } else { + modifier + }, snackbarHost = { + snackbarState?.let { SnackbarHost(it) } + }, contentWindowInsets = WindowInsets.safeDrawing + ) + } +} + +@Composable +private fun BottomBar( + navigationEntries: List>?, + currentRoute: T?, + onSelectNavigation: ((T) -> Unit)? +) { + val blurState = LocalBlurState.current + navigationEntries?.let { + NavigationBar( + containerColor = ComponentDefaults.navBarContentColor(), + modifier = Modifier.applyBlurEffect(blurState) + ) { + it.forEach { entry -> + NavigationBarItem( + currentRoute == entry.route, + onClick = { onSelectNavigation?.invoke(entry.route) }, + icon = { + Icon(entry.painter, contentDescription = entry.title) + }, + label = { Text(entry.title) } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ExpandedContent( + values: PaddingValues, + navigationEntries: List>, + currentRoute: T?, + onSelectNavigation: ((T) -> Unit)?, + content: @Composable ((PaddingValues) -> Unit) +) { + Row(Modifier.fillMaxSize().padding(values)) { + Spacer(Modifier.width(4.dp)) + NavigationRail { + navigationEntries.forEach { entry -> + NavigationRailItem( + selected = currentRoute == entry.route, + onClick = { onSelectNavigation?.invoke(entry.route) }, + icon = { + Icon(entry.painter, contentDescription = entry.title) + }, + label = { Text(entry.title) } + ) + } + } + Spacer(Modifier.width(4.dp)) + Box(Modifier.weight(1f)) { + content(PaddingValues(0.dp)) + } + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/Blur.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/Blur.kt new file mode 100644 index 0000000..aa3e952 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/Blur.kt @@ -0,0 +1,45 @@ +package ru.shadowsparky.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState +import ru.shadowsparky.ui.components.isTv + +private const val BLUR_ENABLED = true + +val LocalBlurState = compositionLocalOf { null } + +@Composable +internal fun isBlurEnabled(): Boolean { + return BLUR_ENABLED && !isTv() +} + +@Composable +fun rememberBlurState(): BlurState? { + if (!isBlurEnabled()) return null + val hazeState = rememberHazeState() + return remember(hazeState) { BlurState(hazeState) } +} + +fun Modifier.applyBlurSource(state: BlurState?): Modifier { + state ?: return this + return hazeSource(state.hazeState) +} + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +fun Modifier.applyBlurEffect(state: BlurState?): Modifier { + state ?: return this + return hazeEffect(state.hazeState, HazeMaterials.thick()) +} + +@Stable +class BlurState(internal val hazeState: HazeState) diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/WindowExtensions.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/WindowExtensions.kt new file mode 100644 index 0000000..0c09887 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/WindowExtensions.kt @@ -0,0 +1,30 @@ +package ru.shadowsparky.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo + +@Composable +private fun currentWindowWidthDp(): Int { + val info = LocalWindowInfo.current + val density = LocalDensity.current + return with(density) { info.containerSize.width.toDp().value.toInt() } +} + +@Composable +fun currentWindowType(): WindowType { + return widthToSizeClass(currentWindowWidthDp()) +} + +sealed interface WindowType { + object Compact : WindowType + object Expanded : WindowType +} + +val WindowType.isExpanded: Boolean get() = this is WindowType.Expanded +val WindowType.isCompact: Boolean get() = this is WindowType.Compact + +private fun widthToSizeClass(widthDp: Int): WindowType = when { + widthDp >= 840 -> WindowType.Expanded + else -> WindowType.Compact +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/BackButton.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/BackButton.kt new file mode 100644 index 0000000..f10060d --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/BackButton.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.ui.components + +import androiddev.libs.ui.generated.resources.Res +import androiddev.libs.ui.generated.resources.back +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.painterResource + +@Composable +fun BackButton(doBack: () -> Unit) { + IconButton( + onClick = { doBack() }, + shape = CircleShape, + colors = IconButtonDefaults.iconButtonVibrantColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(0.1f)) + ) { Icon(painterResource(Res.drawable.back), contentDescription = null) } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt new file mode 100644 index 0000000..3328812 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Expressive.kt @@ -0,0 +1,131 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private const val ROUNDED_MAX = 24 +private const val ROUNDED_MIN = 6 + +@DslMarker private annotation class ExpressiveListDsl + +internal data class ExpressiveNode( + val key: Any?, + val contentType: Any?, + val content: @Composable () -> Unit +) + +@ExpressiveListDsl +class ExpressiveSectionScope { + internal val nodes = mutableListOf() + + fun item(key: Any? = null, contentType: Any? = null, content: @Composable () -> Unit) { + nodes += ExpressiveNode(key, contentType, content) + } + + fun items( + items: List, + key: ((T) -> Any)? = null, + contentType: ((T) -> Any)? = null, + itemContent: @Composable (T) -> Unit + ) { + items.forEach { t -> + nodes += ExpressiveNode( + key = key?.invoke(t), + contentType = contentType?.invoke(t), + content = { itemContent(t) } + ) + } + } +} + +@ExpressiveListDsl +class ExpressiveLazyListScope { + internal val sections = mutableListOf() + + fun section(content: ExpressiveSectionScope.() -> Unit) { + val s = ExpressiveSectionScope().apply(content) + if (s.nodes.isNotEmpty()) sections += s + } +} + +@Composable +fun ExpressiveLazyColumn( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + horizontalPadding: Dp = 8.dp, + verticalSpacing: Dp = 1.dp, + sectionSpacing: Dp = 8.dp, + roundedMax: Dp = ROUNDED_MAX.dp, + roundedMin: Dp = ROUNDED_MIN.dp, + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + content: ExpressiveLazyListScope.() -> Unit +) { + val scope = ExpressiveLazyListScope().apply(content) + + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalArrangement = verticalArrangement + ) { + scope.sections.forEachIndexed { sectionIndex, section -> + + if (sectionIndex != 0 && sectionSpacing > 0.dp) { + item(key = "sectionSpacer_$sectionIndex") { + Spacer(Modifier.height(sectionSpacing)) + } + } + + section.nodes.forEachIndexed { index, node -> + val topRadius = if (index == 0) roundedMax else roundedMin + val bottomRadius = if (index == section.nodes.lastIndex) roundedMax else roundedMin + + item(key = node.key, contentType = node.contentType) { + ExpressiveCard( + topRadius, + bottomRadius, + horizontalPadding, + verticalSpacing, + content = node.content + ) + } + } + } + } +} + +@Composable +fun ExpressiveCard( + topRadius: Dp = ROUNDED_MAX.dp, + bottomRadius: Dp = ROUNDED_MAX.dp, + horizontalPadding: Dp = 8.dp, + verticalSpacing: Dp = 1.dp, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ), + shape = RoundedCornerShape( + topStart = topRadius, + topEnd = topRadius, + bottomStart = bottomRadius, + bottomEnd = bottomRadius + ), + modifier = modifier.padding(horizontal = horizontalPadding, vertical = verticalSpacing) + ) { content() } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyColumn.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyColumn.kt new file mode 100644 index 0000000..971248c --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyColumn.kt @@ -0,0 +1,137 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.OverscrollEffect +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberOverscrollEffect +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +expect fun isTv(): Boolean + +/** + * LazyColumn — обходной путь для бага LazyColumn + contentPadding на Android TV. + * + * Ниже — визуальные схемы, которые показывают, + * КАК ломается фокус и КАК работает обход. + * + * ------------------------------------------------------------ + * 1) Как работает обычный LazyColumn с contentPadding + * (и почему это ломает DPAD-фокус на Android TV) + * ------------------------------------------------------------ + * + * ┌───────────────────────────────────────────────┐ + * │ LazyColumn(contentPadding = paddingValues) │ + * │ │ + * │ ↑ ВЕРХНИЙ PADDING (невидимая область) │ + * │ │ (создаётся внутри списка) │ + * │ │ │ + * │ ┌─▼─────────────────────────────────────────┐ │ + * │ │ Item 1 │ │ + * │ └───────────────────────────────────────────┘ │ + * │ ┌────────────────────────────────────────────┐│ + * │ │ Item 2 (кнопка, карточка, что угодно) ││ + * │ └────────────────────────────────────────────┘│ + * │ │ + * │ ↓ НИЖНИЙ PADDING (ещё одна невидимая зона) │ + * │ │ + * └───────────────────────────────────────────────┘ + * + * ПРОБЛЕМА: + * Для DPAD-навигации эти зоны — не просто отступы. + * Это ПУСТЫЕ ОБЛАСТИ, которые участвуют в алгоритме Focus Search. + * + * Алгоритм думает так: + * + * "Нажали вниз → ниже есть пространство? Да. Есть фокусируемый элемент? Нет." + * + * В итоге фокус: + * - не двигается вниз, + * - может даже перестать попадать в список, + * - прокрутка вниз не инициируется. + * + * + * ------------------------------------------------------------ + * 2) Что делает InsetLazyColumn на Android TV + * ------------------------------------------------------------ + * + * Вместо внутреннего contentPadding мы делаем: + * + * modifier.padding(contentPadding) + * + * А внутрь LazyColumn передаём: + * + * contentPadding = PaddingValues(0.dp) + * + * + * ┌───────────────────────────────────────────────┐ + * │ Modifier.padding(...) ← PADDING СНАРУЖИ │ + * │ │ + * │ ┌───────────────────────────────────────────┐ │ + * │ │ LazyColumn(contentPadding = 0.dp) │ │ + * │ │ │ │ + * │ │ Item 1 │ │ + * │ │ Item 2 │ │ + * │ │ Item 3 │ │ + * │ │ │ │ + * │ └───────────────────────────────────────────┘ │ + * │ │ + * └───────────────────────────────────────────────┘ + * + * РЕЗУЛЬТАТ: + * + * ✔ Визуально всё выглядит так же (insets соблюдены). + * ✔ Внутри LazyColumn нет искусственных «дыр». + * ✔ DPAD-навигация работает корректно. + * ✔ Список правильно скроллится вниз при фокусе. + * ✔ Алгоритм Focus Search видит только настоящие элементы. + * + * + * ------------------------------------------------------------ + * 3) Итоговая мысль + * ------------------------------------------------------------ + * + * Весь баг сводится к тому, что LazyColumn на TV неправильно + * интерпретирует внутренний contentPadding как "дополнительные" + * focus-space зоны, и DPAD начинает вести себя некорректно. + * + * Перенос padding наружу через Modifier полностью устраняет проблему, + * сохраняя визуальное поведение. + */ +@Composable +fun LazyColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), + content: LazyListScope.() -> Unit, +) { + val isTv = isTv() + androidx.compose.foundation.lazy.LazyColumn( + if (isTv) modifier.padding(contentPadding) else modifier, + state, + if (isTv) PaddingValues(0.dp) else contentPadding, + reverseLayout, + verticalArrangement, + horizontalAlignment, + flingBehavior, + userScrollEnabled, + overscrollEffect, + content + ) +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyVerticalGrid.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyVerticalGrid.kt new file mode 100644 index 0000000..b0fa6ab --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/LazyVerticalGrid.kt @@ -0,0 +1,47 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.OverscrollEffect +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.rememberOverscrollEffect +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun LazyVerticalGrid( + columns: GridCells, + modifier: Modifier = Modifier, + state: LazyGridState = rememberLazyGridState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(), + content: LazyGridScope.() -> Unit, +) { + val isTv = isTv() + androidx.compose.foundation.lazy.grid.LazyVerticalGrid( + columns, + if (isTv) modifier.padding(contentPadding) else modifier, + state, + if (isTv) PaddingValues(0.dp) else contentPadding, + reverseLayout, + verticalArrangement, + horizontalArrangement, + flingBehavior, + userScrollEnabled, + overscrollEffect, + content + ) +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/ListPreference.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/ListPreference.kt new file mode 100644 index 0000000..242cdbf --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/ListPreference.kt @@ -0,0 +1,66 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListPreference( + title: String, + items: Map, + selectedItem: T, + onItemSelected: (T) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + val entries = remember(items) { items.entries.toList() } + val selectedLabel = remember(items, selectedItem) { items[selectedItem].orEmpty() } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { if (enabled) expanded = !expanded }, + modifier = modifier + ) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, // readOnly + readOnly = true, + label = { Text(title) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled) + .fillMaxWidth(), + enabled = enabled, + singleLine = true + ) + + ExposedDropdownMenu( + expanded = expanded && enabled, + onDismissRequest = { expanded = false } + ) { + entries.forEach { (value, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + expanded = false + if (value != selectedItem) onItemSelected(value) + } + ) + } + } + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Loading.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Loading.kt new file mode 100644 index 0000000..cf313a2 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/Loading.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun Loading(modifier: Modifier) { + Box(modifier, contentAlignment = Alignment.Center) { + LoadingIndicator(Modifier.size(96.dp)) + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PasswordTextField.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PasswordTextField.kt new file mode 100644 index 0000000..d2984d9 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PasswordTextField.kt @@ -0,0 +1,63 @@ +package ru.shadowsparky.ui.components + +import androiddev.libs.ui.generated.resources.Res +import androiddev.libs.ui.generated.resources.visibility +import androiddev.libs.ui.generated.resources.visibility_off +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource + +@Composable +fun PasswordTextField( + value: String, + onValueChange: (String) -> Unit, + labelRes: StringResource, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onDone: (() -> Unit)? = null +) { + var passwordVisible by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = if (onDone == null) ImeAction.Next else ImeAction.Done, + keyboardType = KeyboardType.Password + ), + keyboardActions = KeyboardActions(onDone = { onDone?.invoke() }), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + label = { Text(stringResource(labelRes)) }, + modifier = modifier.fillMaxWidth(), + enabled = enabled, + trailingIcon = { + val image = if (passwordVisible) { + Res.drawable.visibility + } else { + Res.drawable.visibility_off + } + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = vectorResource(image), contentDescription = null) + } + } + ) +} + diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PreferenceCategory.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PreferenceCategory.kt new file mode 100644 index 0000000..b5569dd --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/PreferenceCategory.kt @@ -0,0 +1,27 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun PreferenceCategory( + title: String, + modifier: Modifier = Modifier, +) { + Text( + text = title, + modifier = modifier + .padding( + start = 16.dp, + top = 24.dp, + end = 16.dp, + bottom = 8.dp, + ), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/SwitchPreference.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/SwitchPreference.kt new file mode 100644 index 0000000..251188c --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/SwitchPreference.kt @@ -0,0 +1,58 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow + +@Composable +fun SwitchPreference( + modifier: Modifier = Modifier, + text: String, + summary: String? = null, + state: Boolean, + onStateChanged: (Boolean) -> Unit +) { + ListItem( + colors = ListItemDefaults.colors(Color.Transparent), + modifier = modifier + .fillMaxWidth() + .toggleable( + value = state, + role = Role.Switch, + onValueChange = onStateChanged + ), + headlineContent = { + Text( + text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + }, + supportingContent = summary?.let { + { + Text( + it, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + ) + } + }, + trailingContent = { + Switch( + checked = state, + onCheckedChange = null + ) + } + ) +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt new file mode 100644 index 0000000..02886cf --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TextPreference.kt @@ -0,0 +1,41 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow + +@Composable +fun TextPreference( + modifier: Modifier = Modifier, + text: String, + summary: String? = null +) { + ListItem( + colors = ListItemDefaults.colors(Color.Transparent), + modifier = modifier.fillMaxWidth(), + headlineContent = { + Text( + text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + }, + supportingContent = summary?.let { + { + Text( + it, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + ) + } + } + ) +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/BaseRootComponent.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/BaseRootComponent.kt new file mode 100644 index 0000000..2754552 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/BaseRootComponent.kt @@ -0,0 +1,76 @@ +package ru.shadowsparky.ui.nav + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.bringToFront +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceAll +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.backhandler.BackHandlerOwner +import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import kotlinx.serialization.KSerializer +import org.koin.core.Koin +import org.koin.core.qualifier.named + +abstract class BaseRootComponent( + private val componentContext: ComponentContext, +) : ComponentContext by componentContext, BackHandlerOwner { + protected abstract val initStack: List + protected abstract val serializer: KSerializer + protected abstract val koin: Koin + + private val scope = coroutineScope() + + protected val nav = StackNavigation() + + protected val childStack by lazy { + childStack( + source = nav, + serializer = serializer, + initialStack = { initStack }, + childFactory = ::newChild + ) + } + + private val _onFinish = MutableStateFlow(null) + val onFinish = _onFinish.filterNotNull() + + fun navReplace(vararg route: R) { + nav.replaceAll(*route) + } + + fun nav(route: R, bringToFront: Boolean = false) { + if (bringToFront) { + nav.bringToFront(route) + } else { + nav.pushNew(route) + } + } + + fun onBack() { + nav.pop { popped -> + if (!popped) finish() + } + } + + fun finish() { + scope.launch { _onFinish.emit(Unit) } + } + + val valueChildStack: Value> by lazy { childStack } + + protected open fun newChild(route: Route, ctx: ComponentContext): Child { + val factory = route.provideFactory(koin) + return factory.create(route, ctx, this as BaseRootComponent) + } + + private fun Route.provideFactory(koin: Koin): ComponentFactory { + return koin.get>(named(routeId)) + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Child.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Child.kt new file mode 100644 index 0000000..b249ace --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Child.kt @@ -0,0 +1,3 @@ +package ru.shadowsparky.ui.nav + +interface Child diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/ComponentFactory.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/ComponentFactory.kt new file mode 100644 index 0000000..6e1c7bc --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/ComponentFactory.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.ui.nav + +import com.arkivanov.decompose.ComponentContext + +interface ComponentFactory { + fun create(route: R, child: ComponentContext, root: BaseRootComponent): T +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt new file mode 100644 index 0000000..3977ae8 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt @@ -0,0 +1,28 @@ +package ru.shadowsparky.ui.nav + +import androidx.compose.runtime.Composable +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.androidPredictiveBackAnimatableV2 +import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation + +@OptIn(ExperimentalDecomposeApi::class) +@Composable +fun Nav( + rootComponent: BaseRootComponent<*>, + process: @Composable (child: Child, route: Route) -> Unit +) { + Children( + stack = rootComponent.valueChildStack, + animation = predictiveBackAnimation( + backHandler = rootComponent.backHandler, + fallbackAnimation = stackAnimation(fade()), + onBack = rootComponent::onBack, + selector = { backEvent, _, _ -> androidPredictiveBackAnimatableV2(backEvent) }, + ), + ) { + process(it.instance, it.configuration as Route) + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Route.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Route.kt new file mode 100644 index 0000000..015bf13 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Route.kt @@ -0,0 +1,8 @@ +package ru.shadowsparky.ui.nav + +import kotlinx.serialization.Serializable + +@Serializable +abstract class Route { + abstract val routeId: String +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Color.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Color.kt new file mode 100644 index 0000000..54e065f --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Color.kt @@ -0,0 +1,219 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF465D91) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFD9E2FF) +val onPrimaryContainerLight = Color(0xFF2D4678) +val secondaryLight = Color(0xFF575E71) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFDBE2F9) +val onSecondaryContainerLight = Color(0xFF3F4759) +val tertiaryLight = Color(0xFF725573) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFFCD7FB) +val onTertiaryContainerLight = Color(0xFF593E5A) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF93000A) +val backgroundLight = Color(0xFFFAF9FF) +val onBackgroundLight = Color(0xFF1A1B20) +val surfaceLight = Color(0xFFFAF9FF) +val onSurfaceLight = Color(0xFF1A1B20) +val surfaceVariantLight = Color(0xFFE1E2EC) +val onSurfaceVariantLight = Color(0xFF44464F) +val outlineLight = Color(0xFF757780) +val outlineVariantLight = Color(0xFFC5C6D0) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF2F3036) +val inverseOnSurfaceLight = Color(0xFFF1F0F7) +val inversePrimaryLight = Color(0xFFAFC6FF) +val surfaceDimLight = Color(0xFFDAD9E0) +val surfaceBrightLight = Color(0xFFFAF9FF) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF3F3FA) +val surfaceContainerLight = Color(0xFFEEEDF4) +val surfaceContainerHighLight = Color(0xFFE8E7EF) +val surfaceContainerHighestLight = Color(0xFFE2E2E9) + +val primaryLightMediumContrast = Color(0xFF1A3566) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF556CA1) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF2F3648) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF666D80) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF472D49) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF816382) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF740006) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFCF2C27) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFAF9FF) +val onBackgroundLightMediumContrast = Color(0xFF1A1B20) +val surfaceLightMediumContrast = Color(0xFFFAF9FF) +val onSurfaceLightMediumContrast = Color(0xFF0F1116) +val surfaceVariantLightMediumContrast = Color(0xFFE1E2EC) +val onSurfaceVariantLightMediumContrast = Color(0xFF34363E) +val outlineLightMediumContrast = Color(0xFF50525A) +val outlineVariantLightMediumContrast = Color(0xFF6B6D75) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF2F3036) +val inverseOnSurfaceLightMediumContrast = Color(0xFFF1F0F7) +val inversePrimaryLightMediumContrast = Color(0xFFAFC6FF) +val surfaceDimLightMediumContrast = Color(0xFFC6C6CD) +val surfaceBrightLightMediumContrast = Color(0xFFFAF9FF) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFF3F3FA) +val surfaceContainerLightMediumContrast = Color(0xFFE8E7EF) +val surfaceContainerHighLightMediumContrast = Color(0xFFDCDCE3) +val surfaceContainerHighestLightMediumContrast = Color(0xFFD1D1D8) + +val primaryLightHighContrast = Color(0xFF0D2A5B) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF30487B) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF252C3D) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF42495B) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF3C233E) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF5B405D) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF600004) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF98000A) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFAF9FF) +val onBackgroundLightHighContrast = Color(0xFF1A1B20) +val surfaceLightHighContrast = Color(0xFFFAF9FF) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFE1E2EC) +val onSurfaceVariantLightHighContrast = Color(0xFF000000) +val outlineLightHighContrast = Color(0xFF292C33) +val outlineVariantLightHighContrast = Color(0xFF474951) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF2F3036) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFAFC6FF) +val surfaceDimLightHighContrast = Color(0xFFB8B8BF) +val surfaceBrightLightHighContrast = Color(0xFFFAF9FF) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFF1F0F7) +val surfaceContainerLightHighContrast = Color(0xFFE2E2E9) +val surfaceContainerHighLightHighContrast = Color(0xFFD4D4DB) +val surfaceContainerHighestLightHighContrast = Color(0xFFC6C6CD) + +val primaryDark = Color(0xFFAFC6FF) +val onPrimaryDark = Color(0xFF132F60) +val primaryContainerDark = Color(0xFF2D4678) +val onPrimaryContainerDark = Color(0xFFD9E2FF) +val secondaryDark = Color(0xFFBFC6DC) +val onSecondaryDark = Color(0xFF293042) +val secondaryContainerDark = Color(0xFF3F4759) +val onSecondaryContainerDark = Color(0xFFDBE2F9) +val tertiaryDark = Color(0xFFDFBBDE) +val onTertiaryDark = Color(0xFF412743) +val tertiaryContainerDark = Color(0xFF593E5A) +val onTertiaryContainerDark = Color(0xFFFCD7FB) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF121318) +val onBackgroundDark = Color(0xFFE2E2E9) +val surfaceDark = Color(0xFF121318) +val onSurfaceDark = Color(0xFFE2E2E9) +val surfaceVariantDark = Color(0xFF44464F) +val onSurfaceVariantDark = Color(0xFFC5C6D0) +val outlineDark = Color(0xFF8F9099) +val outlineVariantDark = Color(0xFF44464F) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE2E2E9) +val inverseOnSurfaceDark = Color(0xFF2F3036) +val inversePrimaryDark = Color(0xFF465D91) +val surfaceDimDark = Color(0xFF121318) +val surfaceBrightDark = Color(0xFF38393E) +val surfaceContainerLowestDark = Color(0xFF0C0E13) +val surfaceContainerLowDark = Color(0xFF1A1B20) +val surfaceContainerDark = Color(0xFF1E1F25) +val surfaceContainerHighDark = Color(0xFF282A2F) +val surfaceContainerHighestDark = Color(0xFF33353A) + +val primaryDarkMediumContrast = Color(0xFFD0DCFF) +val onPrimaryDarkMediumContrast = Color(0xFF042355) +val primaryContainerDarkMediumContrast = Color(0xFF7990C7) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFD5DCF3) +val onSecondaryDarkMediumContrast = Color(0xFF1E2536) +val secondaryContainerDarkMediumContrast = Color(0xFF8990A5) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFFF6D1F4) +val onTertiaryDarkMediumContrast = Color(0xFF351D37) +val tertiaryContainerDarkMediumContrast = Color(0xFFA786A7) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFD2CC) +val onErrorDarkMediumContrast = Color(0xFF540003) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF121318) +val onBackgroundDarkMediumContrast = Color(0xFFE2E2E9) +val surfaceDarkMediumContrast = Color(0xFF121318) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF44464F) +val onSurfaceVariantDarkMediumContrast = Color(0xFFDBDCE6) +val outlineDarkMediumContrast = Color(0xFFB0B1BB) +val outlineVariantDarkMediumContrast = Color(0xFF8E9099) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFE2E2E9) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF282A2F) +val inversePrimaryDarkMediumContrast = Color(0xFF2E4779) +val surfaceDimDarkMediumContrast = Color(0xFF121318) +val surfaceBrightDarkMediumContrast = Color(0xFF43444A) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF06070C) +val surfaceContainerLowDarkMediumContrast = Color(0xFF1C1D23) +val surfaceContainerDarkMediumContrast = Color(0xFF26282D) +val surfaceContainerHighDarkMediumContrast = Color(0xFF313238) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3C3D43) + +val primaryDarkHighContrast = Color(0xFFECEFFF) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFAAC2FD) +val onPrimaryContainerDarkHighContrast = Color(0xFF000A23) +val secondaryDarkHighContrast = Color(0xFFECEFFF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFBBC2D8) +val onSecondaryContainerDarkHighContrast = Color(0xFF040B1B) +val tertiaryDarkHighContrast = Color(0xFFFFEAFB) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFDBB8DA) +val onTertiaryContainerDarkHighContrast = Color(0xFF18031B) +val errorDarkHighContrast = Color(0xFFFFECE9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFAEA4) +val onErrorContainerDarkHighContrast = Color(0xFF220001) +val backgroundDarkHighContrast = Color(0xFF121318) +val onBackgroundDarkHighContrast = Color(0xFFE2E2E9) +val surfaceDarkHighContrast = Color(0xFF121318) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF44464F) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFEFEFF9) +val outlineVariantDarkHighContrast = Color(0xFFC1C2CC) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFE2E2E9) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF2E4779) +val surfaceDimDarkHighContrast = Color(0xFF121318) +val surfaceBrightDarkHighContrast = Color(0xFF4F5056) +val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) +val surfaceContainerLowDarkHighContrast = Color(0xFF1E1F25) +val surfaceContainerDarkHighContrast = Color(0xFF2F3036) +val surfaceContainerHighDarkHighContrast = Color(0xFF3A3B41) +val surfaceContainerHighestDarkHighContrast = Color(0xFF45464C) diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt new file mode 100644 index 0000000..36d1ab1 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt @@ -0,0 +1,47 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.material3.AppBarWithSearchColors +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import ru.shadowsparky.ui.isBlurEnabled + +object ComponentDefaults { + + @Composable + fun topAppBarColors(): TopAppBarColors { + return if (isBlurEnabled()) { + TopAppBarDefaults.topAppBarColors( + Color.Transparent, + Color.Transparent + ) + } else { + TopAppBarDefaults.topAppBarColors() + } + } + + @Composable + fun navBarContentColor(): Color { + return if (isBlurEnabled()) { + Color.Transparent + } else { + NavigationBarDefaults.containerColor + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun appBarWithSearchDefaults(): AppBarWithSearchColors { + return if (isBlurEnabled()) { + SearchBarDefaults.appBarWithSearchColors( + appBarContainerColor = Color.Transparent + ) + } else { + SearchBarDefaults.appBarWithSearchColors() + } + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Theme.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Theme.kt new file mode 100644 index 0000000..726c5fa --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Theme.kt @@ -0,0 +1,111 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AppTheme( + content: @Composable () -> Unit +) { + MaterialExpressiveTheme( + colorScheme = getColorScheme(), + typography = AppTypography, + content = content + ) +} + +@Composable +fun getColorScheme(): ColorScheme { + return when { + isSystemInDarkTheme() -> darkDynamicScheme() ?: darkScheme + else -> lightDynamicScheme() ?: lightScheme + } +} + +@Composable +expect fun lightDynamicScheme(): ColorScheme? + +@Composable +expect fun darkDynamicScheme(): ColorScheme? diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Type.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Type.kt new file mode 100644 index 0000000..2fbb1e6 --- /dev/null +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.material3.Typography + +val AppTypography = Typography() diff --git a/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.desktop.kt b/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.desktop.kt new file mode 100644 index 0000000..408d080 --- /dev/null +++ b/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.desktop.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.runtime.Composable + +@Composable +actual fun isTv(): Boolean = false diff --git a/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/theme/Theme.desktop.kt b/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/theme/Theme.desktop.kt new file mode 100644 index 0000000..9062018 --- /dev/null +++ b/libs/ui/src/desktopMain/kotlin/ru/shadowsparky/ui/theme/Theme.desktop.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +@Composable +actual fun lightDynamicScheme(): ColorScheme? = null + +@Composable +actual fun darkDynamicScheme(): ColorScheme? = null diff --git a/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt new file mode 100644 index 0000000..f5f64c1 --- /dev/null +++ b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt @@ -0,0 +1,80 @@ +package ru.shadowsparky.ui + +import com.arkivanov.essenty.lifecycle.Lifecycle +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.create +import com.arkivanov.essenty.lifecycle.pause +import com.arkivanov.essenty.lifecycle.resume +import com.arkivanov.essenty.lifecycle.start +import com.arkivanov.essenty.lifecycle.stop +import kotlinx.browser.document +import org.w3c.dom.events.Event + +@JsFun( + """ + function hideElementById(id) { + var el = document.getElementById(id); + el.style.display = 'none'; + } +""" +) +external fun hideElementById(id: String) + +@JsFun("(function(){ return document.visibilityState; })") +external fun pageVisibilityState(): String + +fun hookPageVisibility(lifecycle: LifecycleRegistry) { + + fun bootstrap() { + lifecycle.resume() + } + + fun onVisibilityChange() { + when (pageVisibilityState()) { + "visible" -> lifecycle.resume() + else -> lifecycle.stop() + } + } + + fun onPageHideShow(hidden: Boolean) { + if (hidden) { + // уходим глубже: STARTED → CREATED + when (lifecycle.state) { + Lifecycle.State.RESUMED -> { + lifecycle.pause(); lifecycle.stop() + } + + Lifecycle.State.STARTED -> { + lifecycle.stop() + } + + else -> Unit + } + } else { + // pageshow: поднимемся обратно + when (lifecycle.state) { + Lifecycle.State.INITIALIZED -> { + lifecycle.create(); lifecycle.start(); if (pageVisibilityState() == "visible") lifecycle.resume() + } + + Lifecycle.State.CREATED -> { + lifecycle.start(); if (pageVisibilityState() == "visible") lifecycle.resume() + } + + Lifecycle.State.STARTED -> { + if (pageVisibilityState() == "visible") lifecycle.resume() + } + + else -> Unit + } + } + } + + bootstrap() + + val visHandler: (Event) -> Unit = { onVisibilityChange() } + document.addEventListener("visibilitychange", visHandler) + + document.addEventListener("pagehide", { onPageHideShow(true) }) + document.addEventListener("pageshow", { onPageHideShow(false) }) +} diff --git a/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.wasmJs.kt b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.wasmJs.kt new file mode 100644 index 0000000..408d080 --- /dev/null +++ b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/components/InsetLazyColumn.wasmJs.kt @@ -0,0 +1,6 @@ +package ru.shadowsparky.ui.components + +import androidx.compose.runtime.Composable + +@Composable +actual fun isTv(): Boolean = false diff --git a/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/theme/Theme.wasm.kt b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/theme/Theme.wasm.kt new file mode 100644 index 0000000..9062018 --- /dev/null +++ b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/theme/Theme.wasm.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +@Composable +actual fun lightDynamicScheme(): ColorScheme? = null + +@Composable +actual fun darkDynamicScheme(): ColorScheme? = null diff --git a/libs/video-player/.gitignore b/libs/video-player/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/libs/video-player/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/libs/video-player/build.gradle.kts b/libs/video-player/build.gradle.kts new file mode 100644 index 0000000..61d1f83 --- /dev/null +++ b/libs/video-player/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.convention.kmp) + alias(libs.plugins.convention.android.lib) + alias(libs.plugins.convention.serialization) + alias(libs.plugins.convention.koin) + alias(libs.plugins.convention.compose) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":libs:ui")) + } + } + val androidMain by getting { + dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + } + } + } + android { namespace = "ru.shadowsparky.videoplayer" } +} diff --git a/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt new file mode 100644 index 0000000..16526b5 --- /dev/null +++ b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt @@ -0,0 +1,213 @@ +package ru.shadowsparky.videoplayer + +import android.content.Context +import androiddev.libs.video_player.generated.resources.Res +import androiddev.libs.video_player.generated.resources.player_audio_track +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.getString +import org.koin.core.annotation.Factory + +@Factory +class AndroidPlayerController(context: Context) : PlayerController { + private val exoPlayer by lazy { ExoPlayer.Builder(context).build() } + + private val _state = MutableStateFlow(PlayerState()) + override val state: StateFlow = _state.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + override val events: Flow = _events.receiveAsFlow() + override val audioTracks = MutableStateFlow>(emptyList()) + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var progressJob: Job? = null + private var isListenerBound = false + private val listener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + updateState() + if (playbackState == Player.STATE_ENDED) { + _events.trySend(PlayerEvent.Ended) + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + updateState() + if (isPlaying) startProgressWatcher() else stopProgressWatcher() + } + + private var trackJob: Job? = null + override fun onEvents(player: Player, events: Player.Events) { + updateState() + if (events.contains(Player.EVENT_TRACKS_CHANGED)) { + trackJob?.cancel() + trackJob = scope.launch { updateAudioTracks() } + } + } + + override fun onPlayerError(error: PlaybackException) { + _events.trySend(PlayerEvent.Error(error.message ?: "Unknown ExoPlayer error")) + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + _state.update { + it.copy(videoWidth = videoSize.width, videoHeight = videoSize.height) + } + } + } + + init { bindListenerIfNeeded(false) } + + private fun bindListenerIfNeeded(allowFail: Boolean) { + try { + if (isListenerBound) return + exoPlayer.addListener(listener) + isListenerBound = true + } catch (t: Throwable) { + if (allowFail) throw t + } + } + + private var isTrackInitialized = false + + @OptIn(UnstableApi::class) + private suspend fun updateAudioTracks() { + val player = exoPlayer + val tracks = player.currentTracks + + val list = tracks.groups.mapIndexedNotNull { groupIndex, group -> + if (group.type != C.TRACK_TYPE_AUDIO) return@mapIndexedNotNull null + + val trackGroup = group.mediaTrackGroup + if (trackGroup.length == 0) return@mapIndexedNotNull null + + (0 until trackGroup.length).map { trackIndex -> + val desc = getString(Res.string.player_audio_track, groupIndex) + AudioTrackInfo( + id = "$groupIndex:$trackIndex", + groupIndex = groupIndex, + trackIndex = trackIndex, + description = desc.trim(), + isSelected = group.isTrackSelected(trackIndex) + ) + } + }.flatten() + + audioTracks.value = list + + if (!isTrackInitialized && list.isNotEmpty()) { + isTrackInitialized = true + list.firstOrNull()?.let { selectAudioTrack(it.id) } + } + } + + @OptIn(UnstableApi::class) + override suspend fun selectAudioTrack(id: String) = withContext(Dispatchers.Main) { + val parts = id.split(":") + if (parts.size != 2) return@withContext + val groupIndex = parts.first().toIntOrNull() ?: return@withContext + val trackIndex = parts.last().toIntOrNull() ?: return@withContext + + val tracks = exoPlayer.currentTracks + val group = tracks.groups.getOrNull(groupIndex) ?: return@withContext + if (group.type != C.TRACK_TYPE_AUDIO) return@withContext + + val trackGroup = group.mediaTrackGroup + if (trackIndex !in 0 until trackGroup.length) return@withContext + + val override = TrackSelectionOverride(trackGroup, listOf(trackIndex)) + + exoPlayer.trackSelectionParameters = + exoPlayer.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .addOverride(override) + .build() + + updateAudioTracks() + } + + override suspend fun load(url: String, startPositionMs: Long): Unit = + withContext(Dispatchers.Main) { + bindListenerIfNeeded(true) + val mediaItem = MediaItem.fromUri(url) + exoPlayer.setMediaItem(mediaItem) + exoPlayer.prepare() + if (startPositionMs > 0) exoPlayer.seekTo(startPositionMs) + _state.update { it.copy(videoUrl = url) } + _events.trySend(PlayerEvent.Playing) + } + + override suspend fun play() = withContext(Dispatchers.Main) { + exoPlayer.play() + } + + override suspend fun pause() = withContext(Dispatchers.Main) { + exoPlayer.pause() + } + + override suspend fun seekTo(positionMs: Long) = withContext(Dispatchers.Main) { + exoPlayer.seekTo(positionMs) + updateState() + } + + override suspend fun release() = withContext(Dispatchers.Main) { + stopProgressWatcher() + exoPlayer.removeListener(listener) + exoPlayer.release() + scope.cancel() + } + + private fun updateState() { + _state.update { s -> + s.copy( + isPlaying = exoPlayer.isPlaying, + isBuffering = exoPlayer.playbackState == Player.STATE_BUFFERING, + isEnded = exoPlayer.playbackState == Player.STATE_ENDED, + durationMs = exoPlayer.duration.coerceAtLeast(0), + currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0), + bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0), + volume = exoPlayer.volume + ) + } + } + + private fun startProgressWatcher() { + if (progressJob?.isActive == true) return + progressJob = scope.launch { + while (isActive) { + updateState() + delay(200) + } + } + } + + private fun stopProgressWatcher() { + progressJob?.cancel() + progressJob = null + } + + internal fun getPlayerInstance(): ExoPlayer = exoPlayer +} diff --git a/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.android.kt b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.android.kt new file mode 100644 index 0000000..97d2ff2 --- /dev/null +++ b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.android.kt @@ -0,0 +1,34 @@ +package ru.shadowsparky.videoplayer + +import android.app.Application +import android.content.Intent +import androidx.core.net.toUri +import org.koin.core.annotation.Factory + +@Factory +class AndroidExternalPlayer(private val app: Application) : ExternalPlayer { + + override fun play(url: String) { + if (!launchInternal(url, true)) { + if (!launchInternal(url, false)) { + error("Unable to launch video") + } + } + } + + private fun launchInternal(uri: String, withType: Boolean): Boolean { + val intent = Intent(Intent.ACTION_VIEW) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (withType) { + intent.setDataAndType(uri.toUri(), "video/*") + } else { + intent.data = uri.toUri() + } + try { + app.startActivity(intent) + } catch (_: Exception) { + return false + } + return true + } +} diff --git a/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/PictureInPicture.kt b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/PictureInPicture.kt new file mode 100644 index 0000000..83fe78b --- /dev/null +++ b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/PictureInPicture.kt @@ -0,0 +1,57 @@ +package ru.shadowsparky.videoplayer.ui + +import android.app.PictureInPictureParams +import android.content.Context +import android.graphics.Rect +import android.os.Build +import android.util.Rational +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer + +internal fun Context.updatePipAutoEnter(playerView: View, enabled: Boolean) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + val w = if (playerView.width > 0) playerView.width else 16 + val h = if (playerView.height > 0) playerView.height else 9 + + val rect = Rect() + playerView.getGlobalVisibleRect(rect) + + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(w, h)) + .setSourceRectHint(rect) + .setAutoEnterEnabled(enabled) + .build() + + findActivity()?.setPictureInPictureParams(params) +} + +@Composable +internal fun rememberPipMode(): State { + val ctx = LocalContext.current + val activity = ctx.findActivity() as? ComponentActivity + val state = remember { mutableStateOf(activity?.isInPictureInPictureMode == true) } + + DisposableEffect(activity) { + if (activity == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return@DisposableEffect onDispose { } + } + + val cb = Consumer { info -> + state.value = info.isInPictureInPictureMode + } + + activity.addOnPictureInPictureModeChangedListener(cb) + onDispose { activity.removeOnPictureInPictureModeChangedListener(cb) } + } + + return state +} diff --git a/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.android.kt b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.android.kt new file mode 100644 index 0000000..f3486e9 --- /dev/null +++ b/libs/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.android.kt @@ -0,0 +1,115 @@ +package ru.shadowsparky.videoplayer.ui + +import android.app.Activity +import android.content.Context +import android.content.pm.ActivityInfo +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.annotation.OptIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import ru.shadowsparky.videoplayer.AndroidPlayerController +import ru.shadowsparky.videoplayer.CanvasEvent +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.VideoScaleType + +@OptIn(UnstableApi::class) +@Composable +actual fun VideoCanvas( + controller: PlayerController, + modifier: Modifier, + scaleType: VideoScaleType, + onCanvasEvent: (CanvasEvent) -> Unit +) { + val androidController = controller as? AndroidPlayerController + ?: throw IllegalArgumentException("Controller must be AndroidPlayerController") + + val context = LocalContext.current + + val pipMode by rememberPipMode() + + LaunchedEffect(pipMode) { + if (pipMode) onCanvasEvent(CanvasEvent.HideControls) + } + + DisposableEffect(Unit) { + context.enterFullscreen() + onDispose { + context.exitFullscreen() + } + } + + AndroidView( + modifier = modifier, + factory = { ctx -> + PlayerView(ctx).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + useController = false + + resizeMode = when (scaleType) { + VideoScaleType.FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT + VideoScaleType.FULL -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + VideoScaleType.STRETCH -> AspectRatioFrameLayout.RESIZE_MODE_FILL + } + + keepScreenOn = true + context.updatePipAutoEnter(this, true) + } + }, + update = { playerView -> + if (playerView.player != androidController.getPlayerInstance()) { + playerView.player = androidController.getPlayerInstance() + } + }, + onRelease = { playerView -> + context.updatePipAutoEnter(playerView, false) + playerView.player = null + } + ) +} + +internal fun Context.findActivity(): Activity? { + var ctx = this + while (ctx is android.content.ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null +} + +private fun Context.enterFullscreen() { + val activity = findActivity() ?: return + val window = activity.window ?: return + + WindowCompat.setDecorFitsSystemWindows(window, false) + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE +} + +private fun Context.exitFullscreen() { + val activity = findActivity() ?: return + val window = activity.window ?: return + + WindowCompat.setDecorFitsSystemWindows(window, true) + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.show(WindowInsetsCompat.Type.systemBars()) + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED +} diff --git a/libs/video-player/src/commonMain/composeResources/drawable/lock_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/lock_24px.xml new file mode 100644 index 0000000..163f148 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/lock_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/drawable/lock_open_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/lock_open_24px.xml new file mode 100644 index 0000000..1140e97 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/lock_open_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/drawable/music_note_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/music_note_24px.xml new file mode 100644 index 0000000..6a8bdd8 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/music_note_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/drawable/pause_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/pause_24px.xml new file mode 100644 index 0000000..37a379d --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/pause_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/drawable/play_arrow_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/play_arrow_24px.xml new file mode 100644 index 0000000..edfc427 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/play_arrow_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/drawable/replay_24px.xml b/libs/video-player/src/commonMain/composeResources/drawable/replay_24px.xml new file mode 100644 index 0000000..952cce7 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/drawable/replay_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/video-player/src/commonMain/composeResources/values/strings.xml b/libs/video-player/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000..a4f7579 --- /dev/null +++ b/libs/video-player/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,6 @@ + + Ошибка плеера + Во время проигрывания видео произошла ошибка: %1$s + Повторить + Аудио %1$d + diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/AudioTrackInfo.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/AudioTrackInfo.kt new file mode 100644 index 0000000..4fa557b --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/AudioTrackInfo.kt @@ -0,0 +1,9 @@ +package ru.shadowsparky.videoplayer + +data class AudioTrackInfo( + val id: String, + val groupIndex: Int, + val trackIndex: Int, + val description: String, + val isSelected: Boolean +) diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/CanvasEvent.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/CanvasEvent.kt new file mode 100644 index 0000000..f856a97 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/CanvasEvent.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.videoplayer + +sealed class CanvasEvent { + data object HideControls : CanvasEvent() +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ControlsState.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ControlsState.kt new file mode 100644 index 0000000..e0a3939 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ControlsState.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.videoplayer + +import androidx.compose.runtime.MutableState + +class ControlsState( + val isLocked: MutableState +) diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.kt new file mode 100644 index 0000000..650cf71 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.videoplayer + +interface ExternalPlayer { + fun play(url: String) +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerController.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerController.kt new file mode 100644 index 0000000..5b04c75 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerController.kt @@ -0,0 +1,19 @@ +package ru.shadowsparky.videoplayer + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface PlayerController { + val state: StateFlow + val events: Flow + val audioTracks: StateFlow> + + suspend fun load(url: String, startPositionMs: Long = 0L) + + suspend fun play() + suspend fun pause() + suspend fun seekTo(positionMs: Long) + suspend fun selectAudioTrack(id: String) + + suspend fun release() +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerEvent.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerEvent.kt new file mode 100644 index 0000000..fa5abb2 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerEvent.kt @@ -0,0 +1,8 @@ +package ru.shadowsparky.videoplayer + +sealed interface PlayerEvent { + data object Idle : PlayerEvent + data object Playing : PlayerEvent + data class Error(val message: String) : PlayerEvent + data object Ended : PlayerEvent +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerState.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerState.kt new file mode 100644 index 0000000..21f8c95 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/PlayerState.kt @@ -0,0 +1,18 @@ +package ru.shadowsparky.videoplayer + +data class PlayerState( + val isPlaying: Boolean = false, + val isBuffering: Boolean = false, + val isEnded: Boolean = false, + + val durationMs: Long = 0L, + val currentPositionMs: Long = 0L, + val bufferedPositionMs: Long = 0L, + + val videoWidth: Int = 0, + val videoHeight: Int = 0, + + val volume: Float = 1.0f, + + val videoUrl: String? = null +) diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoPlayerModule.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoPlayerModule.kt new file mode 100644 index 0000000..6418cc2 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoPlayerModule.kt @@ -0,0 +1,10 @@ +package ru.shadowsparky.videoplayer + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@Configuration +@ComponentScan +class VideoPlayerModule diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoScaleType.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoScaleType.kt new file mode 100644 index 0000000..30f3cfc --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/VideoScaleType.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.videoplayer + +enum class VideoScaleType { + FIT, + FULL, + STRETCH +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt new file mode 100644 index 0000000..06727d9 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt @@ -0,0 +1,447 @@ +package ru.shadowsparky.videoplayer.ui + +import androiddev.libs.video_player.generated.resources.Res +import androiddev.libs.video_player.generated.resources.lock_24px +import androiddev.libs.video_player.generated.resources.lock_open_24px +import androiddev.libs.video_player.generated.resources.music_note_24px +import androiddev.libs.video_player.generated.resources.pause_24px +import androiddev.libs.video_player.generated.resources.play_arrow_24px +import androiddev.libs.video_player.generated.resources.replay_24px +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusProperties +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import ru.shadowsparky.videoplayer.AudioTrackInfo +import ru.shadowsparky.videoplayer.ControlsState +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.PlayerState + +private const val DEF_SEEK_TIME = 10L + +@Composable +fun DefaultControls( + state: PlayerState, + controlsState: ControlsState, + controller: PlayerController, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + val audioTracksState by controller.audioTracks.collectAsState() + + val isUiLocked = controlsState.isLocked + var isDragging by rememberSaveable { mutableStateOf(false) } + var sliderPosition by rememberSaveable { mutableStateOf(0f) } + + val currentUiPosition = if (isDragging) sliderPosition else state.currentPositionMs.toFloat() + val totalDuration = state.durationMs.toFloat().coerceAtLeast(1f) + + val focus = remember { FocusContainer() } + + Box( + modifier = modifier + .fillMaxSize() + .background(if (isUiLocked.value) Color.Transparent else Color.Black.copy(alpha = 0.4f)) + ) { + SideDoubleTapSeekZones(controller) + Row(modifier = Modifier.align(Alignment.TopEnd).focusGroup().padding(8.dp)) { + if (audioTracksState.isNotEmpty() && !isUiLocked.value) { + AudioTracksButton( + focus, + tracks = audioTracksState, + onSelect = { id -> + scope.launch { controller.selectAudioTrack(id) } + } + ) + } + LockUiButton(focus, isUiLocked.value, audioTracksState.isNotEmpty()) { + isUiLocked.value = it + } + } + if (!isUiLocked.value) { + CenterControls(focus, state, controller) + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(16.dp) + .controlsBackground() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatDuration(currentUiPosition.toLong()), + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = formatDuration(state.durationMs), + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + Slider( + value = currentUiPosition, + valueRange = 0f..totalDuration, + onValueChange = { newPos -> + isDragging = true + sliderPosition = newPos + }, + onValueChangeFinished = { + isDragging = false + scope.launch { + controller.seekTo(sliderPosition.toLong()) + } + }, + modifier = Modifier.fillMaxWidth() + .focusableWithBorder(focus.slider) { + next = if (audioTracksState.isNotEmpty()) focus.audioTrack else focus.lock + previous = focus.next + } + .onPreviewKeyEvent { + if (it.type == KeyEventType.KeyDown) { + when (it.key) { + Key.DirectionLeft -> { + scope.launch { controller.seek(true) } + } + Key.DirectionRight -> { + scope.launch { controller.seek(false) } + } + Key.DirectionUp -> focus.next.requestFocus() + Key.DirectionDown -> focus.lock.requestFocus() + else -> return@onPreviewKeyEvent false + } + } + true + } + ) + } + } + } +} + +@Composable +private fun LockUiButton( + focusContainer: FocusContainer, + state: Boolean, + hasAudioTrack: Boolean, + onChanged: (Boolean) -> Unit +) { + IconButton( + onClick = { onChanged(!state) }, + modifier = Modifier.focusableWithBorder(focusContainer.lock) { + next = focusContainer.prev + previous = if (hasAudioTrack) focusContainer.audioTrack else focusContainer.slider + } + ) { + val icon = if (state) Res.drawable.lock_24px else Res.drawable.lock_open_24px + Icon(painterResource(icon), null, tint = Color.White) + } +} + +@Composable +private fun AudioTracksButton( + focusContainer: FocusContainer, + tracks: List, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + val tracks = tracks.map { track -> track to FocusRequester() } + IconButton( + onClick = { expanded = true }, + modifier = Modifier.focusableWithBorder(focusContainer.audioTrack) { + next = focusContainer.lock + previous = focusContainer.slider + } + ) { + Icon( + painter = painterResource(Res.drawable.music_note_24px), + contentDescription = "Audio tracks", + tint = Color.White + ) + } + LaunchedEffect(expanded) { + if (expanded) { + delay(500) + tracks.firstOrNull()?.second?.requestFocus() + } + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.focusable() + ) { + for (i in 0 until tracks.size) { + val (track, requester) = tracks[i] + DropdownMenuItem( + text = { + Text( + text = if (track.isSelected) + "● ${track.description}" + else + track.description + ) + }, + onClick = { + expanded = false + onSelect(track.id) + }, + modifier = Modifier.focusableWithBorder(requester) { + previous = tracks.getOrNull(i - 1)?.second ?: focusContainer.audioTrack + next = tracks.getOrNull(i + 1)?.second ?: focusContainer.audioTrack + } + ) + } + } + } +} + +@Composable +private fun BoxScope.SideDoubleTapSeekZones(controller: PlayerController, enabled: Boolean = true) { + val scope = rememberCoroutineScope() + if (!enabled) return + Row(modifier = Modifier.matchParentSize()) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.3f) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + scope.launch { controller.seek(true) } + } + ) + } + ) + Spacer(modifier = Modifier.fillMaxHeight().weight(0.3f)) + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.3f) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + scope.launch { + controller.seek(false) + } + } + ) + } + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun BoxScope.CenterControls( + focusContainer: FocusContainer, + state: PlayerState, + controller: PlayerController +) { + val scope = rememberCoroutineScope() + Box( + modifier = Modifier + .align(Alignment.Center) + .focusGroup() + .controlsBackground() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + when { + state.isBuffering -> { + CircularWavyProgressIndicator() + } + + state.isEnded -> { + IconButton( + onClick = { + scope.launch { + controller.seekTo(0) + controller.play() + } + }, modifier = Modifier.focusableWithBorder(focusContainer.next) { + previous = focusContainer.lock + next = focusContainer.slider + } + ) { + Icon( + painter = painterResource(Res.drawable.replay_24px), + contentDescription = "Replay", + modifier = Modifier.size(64.dp), + tint = Color.White + ) + } + } + + else -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + IconButton( + onClick = { + scope.launch { controller.seek(true) } + }, modifier = Modifier.focusableWithBorder(focusContainer.prev) { + next = focusContainer.next + previous = focusContainer.play + } + ) { + Text( + text = "-$DEF_SEEK_TIME", + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + + IconButton( + onClick = { + scope.launch { + if (state.isPlaying) controller.pause() else controller.play() + } + }, modifier = Modifier.focusableWithBorder(focusContainer.next) { + next = focusContainer.next + previous = focusContainer.prev + } + ) { + Icon( + painter = painterResource( + if (state.isPlaying) + Res.drawable.pause_24px + else + Res.drawable.play_arrow_24px + ), + contentDescription = "Play/Pause", + modifier = Modifier.size(64.dp), + tint = Color.White + ) + } + + IconButton( + onClick = { + scope.launch { controller.seek(false) } + }, modifier = Modifier.focusableWithBorder(focusContainer.next) { + next = focusContainer.slider + previous = focusContainer.play + } + ) { + Text( + text = "+$DEF_SEEK_TIME", + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } +} + +private suspend fun PlayerController.seek(minus: Boolean) { + val state = state.value + val newPos = if (minus) { + (state.currentPositionMs - DEF_SEEK_TIME * 1_000) + .coerceAtLeast(0L) + } else { + (state.currentPositionMs + DEF_SEEK_TIME * 1_000) + .coerceAtMost(state.durationMs) + } + seekTo(newPos) +} + +private fun Modifier.controlsBackground(): Modifier { + return background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(16.dp)) +} + +private fun formatDuration(millis: Long): String { + if (millis < 0) return "00:00:00" + + val totalSeconds = millis / 1000 + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + val h = hours.toString().padStart(2, '0') + val m = minutes.toString().padStart(2, '0') + val s = seconds.toString().padStart(2, '0') + + return "$h:$m:$s" +} + +private class FocusContainer( + val audioTrack: FocusRequester = FocusRequester(), + val audioTrackContainer: FocusRequester = FocusRequester(), + val lock: FocusRequester = FocusRequester(), + val prev: FocusRequester = FocusRequester(), + val play: FocusRequester = FocusRequester(), + val next: FocusRequester = FocusRequester(), + val slider: FocusRequester = FocusRequester() +) + +@Composable +private fun Modifier.focusableWithBorder( + requester: FocusRequester, + focusProperties: FocusProperties.() -> Unit +): Modifier { + var isFocused by remember { mutableStateOf(false) } + return focusRequester(requester) + .focusProperties(focusProperties) + .onFocusChanged { isFocused = it.isFocused } + .border( + width = 2.dp, + color = if (isFocused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = CircleShape + ) +} diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.kt new file mode 100644 index 0000000..b5f7786 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.kt @@ -0,0 +1,15 @@ +package ru.shadowsparky.videoplayer.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import ru.shadowsparky.videoplayer.CanvasEvent +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.VideoScaleType + +@Composable +expect fun VideoCanvas( + controller: PlayerController, + modifier: Modifier, + scaleType: VideoScaleType = VideoScaleType.FIT, + onCanvasEvent: (CanvasEvent) -> Unit +) diff --git a/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt new file mode 100644 index 0000000..8dc3b97 --- /dev/null +++ b/libs/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt @@ -0,0 +1,170 @@ +package ru.shadowsparky.videoplayer.ui + +import androiddev.libs.video_player.generated.resources.Res +import androiddev.libs.video_player.generated.resources.player_error_summary +import androiddev.libs.video_player.generated.resources.player_error_title +import androiddev.libs.video_player.generated.resources.player_retry +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import ru.shadowsparky.ui.components.isTv +import ru.shadowsparky.videoplayer.CanvasEvent +import ru.shadowsparky.videoplayer.ControlsState +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.PlayerEvent +import ru.shadowsparky.videoplayer.PlayerState + +@Composable +fun VideoPlayer( + controller: PlayerController, + modifier: Modifier = Modifier, + overlayContent: @Composable (PlayerState, ControlsState) -> Unit = { player, controls -> + DefaultControls(player, controls, controller = controller) + } +) { + val isTv = isTv() + val state by controller.state.collectAsState() + val scope = rememberCoroutineScope() + val controlsState by remember { + mutableStateOf(ControlsState(mutableStateOf(false))) + } + var areControlsVisible by remember { mutableStateOf(false) } + var lastErrorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(controller) { + controller.events.collect { event -> + when (event) { + is PlayerEvent.Error -> { + lastErrorMessage = event.message + areControlsVisible = true + } + + is PlayerEvent.Ended -> { + areControlsVisible = true + } + + is PlayerEvent.Idle, is PlayerEvent.Playing -> { + lastErrorMessage = null + areControlsVisible = true + } + } + } + } + + LaunchedEffect(areControlsVisible, state.isPlaying, lastErrorMessage) { + if (areControlsVisible && state.isPlaying && lastErrorMessage == null && !isTv) { + delay(3000) + areControlsVisible = false + } + } + + val rootModifier = if (isTv) { + modifier + } else { + modifier.clickable { areControlsVisible = !areControlsVisible } + } + + Box(modifier = rootModifier.background(Color.Black)) { + VideoCanvas( + controller = controller, + modifier = rootModifier.fillMaxSize(), + onCanvasEvent = { + areControlsVisible = when (it) { + CanvasEvent.HideControls -> false + } + } + ) + + if (lastErrorMessage != null) { + PlaybackError(lastErrorMessage!!, scope, state, controller) { + lastErrorMessage = null + } + } else { + AnimatedVisibility( + visible = areControlsVisible || !state.isPlaying, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.matchParentSize() + ) { overlayContent(state, controlsState) } + } + } +} + +@Composable +private fun PlaybackError( + lastErrorMessage: String, + scope: CoroutineScope, + state: PlayerState, + controller: PlayerController, + onReset: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.8f)) + .clickable(enabled = false) {}, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(Res.string.player_error_title), + style = MaterialTheme.typography.titleSmall, + color = Color.White + ) + Text( + text = stringResource(Res.string.player_error_summary, lastErrorMessage), + style = MaterialTheme.typography.bodyMedium, + color = Color.LightGray, + modifier = Modifier.padding(top = 4.dp) + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + onReset() + scope.launch { + val currentUrl = state.videoUrl + val currentPos = state.currentPositionMs + if (currentUrl != null) { + controller.load(currentUrl, currentPos) + controller.play() + } + } + }, + ) { + Text(stringResource(Res.string.player_retry)) + } + } + } +} diff --git a/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.desktop.kt b/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.desktop.kt new file mode 100644 index 0000000..1e241f6 --- /dev/null +++ b/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.desktop.kt @@ -0,0 +1,8 @@ +package ru.shadowsparky.videoplayer + +import org.koin.core.annotation.Factory + +@Factory +class DesktopExternalPlayer : ExternalPlayer { + override fun play(url: String) { throw NotImplementedError() } +} diff --git a/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.desktop.kt b/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.desktop.kt new file mode 100644 index 0000000..22c0853 --- /dev/null +++ b/libs/video-player/src/desktopMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.desktop.kt @@ -0,0 +1,17 @@ +package ru.shadowsparky.videoplayer.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import ru.shadowsparky.videoplayer.CanvasEvent +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.VideoScaleType + +@Composable +actual fun VideoCanvas( + controller: PlayerController, + modifier: Modifier, + scaleType: VideoScaleType, + onCanvasEvent: (CanvasEvent) -> Unit +) { + throw NotImplementedError() +} diff --git a/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.wasmJs.kt b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.wasmJs.kt new file mode 100644 index 0000000..159d55a --- /dev/null +++ b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ExternalPlayer.wasmJs.kt @@ -0,0 +1,11 @@ +package ru.shadowsparky.videoplayer + +import kotlinx.browser.window +import org.koin.core.annotation.Factory + +@Factory +class WasmExternalPlayer : ExternalPlayer { + override fun play(url: String) { + window.open(url, "_blank", "noreferrer") + } +} diff --git a/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/WasmPlayerController.kt b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/WasmPlayerController.kt new file mode 100644 index 0000000..e77c513 --- /dev/null +++ b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/WasmPlayerController.kt @@ -0,0 +1,139 @@ +package ru.shadowsparky.videoplayer + +import kotlinx.browser.document +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.await +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import org.koin.core.annotation.Factory +import org.w3c.dom.HTMLVideoElement + +@Factory +class WasmPlayerController : PlayerController { + + internal val videoElement = (document.createElement("video") as HTMLVideoElement).apply { + style.apply { + setProperty("position", "absolute") + setProperty("left", "0") + setProperty("top", "0") + setProperty("width", "100%") + setProperty("height", "100%") + setProperty("background-color", "black") + } + setAttribute("controls", "") + setAttribute("preload", "auto") + } + + private val _state = MutableStateFlow(PlayerState()) + override val state: StateFlow = _state.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + override val events: Flow = _events.receiveAsFlow() + + override val audioTracks: StateFlow> = MutableStateFlow(emptyList()) + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + init { + setupListeners() + } + + private fun setupListeners() { + videoElement.onplay = { updateState() } + videoElement.onpause = { updateState() } + + videoElement.ontimeupdate = { updateState() } + + videoElement.onwaiting = { + _state.update { it.copy(isBuffering = true) } + } + + videoElement.onplaying = { + _state.update { it.copy(isBuffering = false) } + } + + videoElement.onended = { + updateState() + _events.trySend(PlayerEvent.Ended) + } + + videoElement.addEventListener("error") { + val err = videoElement.error?.toString() ?: "Unknown HTML5 Video Error" + _events.trySend(PlayerEvent.Error(err)) + } + + videoElement.onloadedmetadata = { + updateState() + _state.update { + it.copy( + videoWidth = videoElement.videoWidth, + videoHeight = videoElement.videoHeight + ) + } + } + } + + override suspend fun load(url: String, startPositionMs: Long) { + println("load $url called") + videoElement.src = url + videoElement.load() + if (startPositionMs > 0) { + videoElement.currentTime = startPositionMs / 1000.0 + } + _state.update { it.copy(videoUrl = url) } + _events.trySend(PlayerEvent.Playing) + println("load $url end") + } + + @OptIn(ExperimentalWasmJsInterop::class) + override suspend fun play() { + try { + println("play called") + videoElement.play().await() + println("play end") + } catch (e: Throwable) { + println("Play failed (autoplay policy?): $e. src: ${videoElement?.src?.toString()}") + } + } + + override suspend fun pause() { + videoElement.pause() + } + + override suspend fun seekTo(positionMs: Long) { + videoElement.currentTime = positionMs / 1000.0 + updateState() + } + + override suspend fun release() { + videoElement.pause() + videoElement.src = "" + videoElement.remove() + scope.cancel() + } + + private fun updateState() { + _state.update { s -> + s.copy( + isPlaying = !videoElement.paused && !videoElement.ended, + isBuffering = false, + isEnded = videoElement.ended, + durationMs = (videoElement.duration * 1000).toLong().coerceAtLeast(0), + currentPositionMs = (videoElement.currentTime * 1000).toLong().coerceAtLeast(0), + bufferedPositionMs = (videoElement.currentTime * 1000).toLong(), + volume = videoElement.volume.toFloat() + ) + } + } + + override suspend fun selectAudioTrack(id: String) { + } +} diff --git a/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.wasmJs.kt b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.wasmJs.kt new file mode 100644 index 0000000..b87df83 --- /dev/null +++ b/libs/video-player/src/wasmJsMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoCanvas.wasmJs.kt @@ -0,0 +1,78 @@ +package ru.shadowsparky.videoplayer.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import kotlinx.browser.document +import org.w3c.dom.HTMLElement +import ru.shadowsparky.videoplayer.CanvasEvent +import ru.shadowsparky.videoplayer.PlayerController +import ru.shadowsparky.videoplayer.VideoScaleType +import ru.shadowsparky.videoplayer.WasmPlayerController + +private const val VIDEO_LAYER = "video-layer" +private const val ROOT_LAYER = "compose-root" + +@Composable +actual fun VideoCanvas( + controller: PlayerController, + modifier: Modifier, + scaleType: VideoScaleType, + onCanvasEvent: (CanvasEvent) -> Unit +) { + val wasmController = controller as? WasmPlayerController ?: return + + DisposableEffect(Unit) { + showVideoLayer(wasmController, scaleType) + onDispose { + hideVideoLayer(wasmController) + } + } +} + +private fun showVideoLayer( + wasmController: WasmPlayerController, + scaleType: VideoScaleType +) { + setVideoLayer(true) + val videoLayer = document.getElementById(VIDEO_LAYER) as HTMLElement + val video = wasmController.videoElement + videoLayer.appendChild(video) + + video.style.apply { + val fit = when (scaleType) { + VideoScaleType.FIT -> "contain" + VideoScaleType.FULL -> "cover" + VideoScaleType.STRETCH -> "fill" + } + setProperty("object-fit", fit) + } +} + +private fun hideVideoLayer(wasmController: WasmPlayerController) { + val video = wasmController.videoElement + + video.pause() + video.removeAttribute("src") + video.load() + setVideoLayer(false) +} + +private fun setVideoLayer(isVideoEnabled: Boolean) { + val videoLayer = document.getElementById(VIDEO_LAYER) as HTMLElement + val composeRoot = document.getElementById(ROOT_LAYER) as HTMLElement + + videoLayer.innerHTML = "" + videoLayer.setVisibility(isVideoEnabled) + composeRoot.setVisibility(!isVideoEnabled) +} + +private fun HTMLElement.setVisibility(isVisible: Boolean) { + if (isVisible) { + style.setProperty("visibility", "visible") + style.removeProperty("pointer-events") + } else { + style.setProperty("visibility", "hidden") + style.setProperty("pointer-events", "none") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..95a2aa9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,48 @@ +pluginManagement { + includeBuild("build-logic") + + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { + setUrl("https://jitpack.io") // maven repo where the current library resides + } + } +} + +rootProject.name = "AndroidDev" + +include(":apps:vbox:common") +include(":apps:vbox:client:androidApp") +include(":apps:vbox:client:shared") +include(":apps:vbox:client:web") +include(":apps:vbox:backend") + +include(":apps:call-blocker") + +include(":apps:experimentarium:androidApp") +include(":apps:experimentarium:shared") +include(":apps:experimentarium:web") + +include(":libs:backend-base") +include(":libs:base") +include(":libs:ui") +include(":libs:http-client") +include(":libs:android-stub") +include(":libs:video-player")