init projects
@@ -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
|
||||||
@@ -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.
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build
|
||||||
|
/bin
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.android.app)
|
||||||
|
alias(libs.plugins.convention.compose)
|
||||||
|
alias(libs.plugins.convention.koin)
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions.getByType<ApplicationExtension>().apply {
|
||||||
|
namespace = "ru.shadowsparky.callblocker"
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = namespace
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.koin.android)
|
||||||
|
implementation(libs.koin.androidx.compose)
|
||||||
|
implementation(libs.activity.compose)
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
|
|
||||||
|
implementation(project(":libs:base"))
|
||||||
|
implementation(project(":libs:ui"))
|
||||||
|
|
||||||
|
debugImplementation(libs.ui.tooling)
|
||||||
|
compileOnly(libs.ui.tooling.preview.android)
|
||||||
|
compileOnly(project(":libs:android-stub"))
|
||||||
|
testImplementation(libs.koin.test)
|
||||||
|
testImplementation(libs.mockk)
|
||||||
|
testImplementation(kotlin("test-junit"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.telephony"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
|
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".presentation.App"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.CallBlocker"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name=".presentation.ui.MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.CallBlocker">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.lib_name"
|
||||||
|
android:value="" />
|
||||||
|
</activity>
|
||||||
|
<receiver
|
||||||
|
android:name=".presentation.RejectCallReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PHONE_STATE" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.shadowsparky.callblocker
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
|
import org.koin.core.annotation.ComponentScan
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import org.koin.core.annotation.Module
|
||||||
|
import ru.shadowsparky.callblocker.data.LRejectCallDelegate
|
||||||
|
import ru.shadowsparky.callblocker.data.PRejectCallDelegate
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectCallDelegate
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@ComponentScan
|
||||||
|
class AppModule {
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
fun provideAlwaysRejectCallDelegate(app: Application): RejectCallDelegate {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
PRejectCallDelegate(app)
|
||||||
|
} else {
|
||||||
|
LRejectCallDelegate(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package ru.shadowsparky.callblocker.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.callblocker.domain.ContactEntry
|
||||||
|
import ru.shadowsparky.callblocker.domain.ContactReader
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class ContentProviderContactReader(
|
||||||
|
private val context: Context
|
||||||
|
) : ContactReader {
|
||||||
|
|
||||||
|
override suspend fun read(): Set<ContactEntry> {
|
||||||
|
val projection = arrayOf(
|
||||||
|
ContactsContract.Contacts.DISPLAY_NAME,
|
||||||
|
ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER
|
||||||
|
)
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
context.contentResolver.query(
|
||||||
|
ContactsContract.Data.CONTENT_URI,
|
||||||
|
projection,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
).use { queryContacts(it ?: return@withContext emptySet()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryContacts(cursor: Cursor): Set<ContactEntry> {
|
||||||
|
if (!cursor.moveToFirst()) return emptySet()
|
||||||
|
val result = mutableSetOf<ContactEntry>()
|
||||||
|
do {
|
||||||
|
val name = cursor.getString(ContactsContract.Contacts.DISPLAY_NAME)
|
||||||
|
val phone = cursor.getString(ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER)
|
||||||
|
if (phone != null && name != null) {
|
||||||
|
result.add(ContactEntry(name, phone))
|
||||||
|
}
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Cursor.getString(column: String): String? {
|
||||||
|
val idx = getColumnIndex(column)
|
||||||
|
if (idx < 0) return null
|
||||||
|
return getString(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package ru.shadowsparky.callblocker.data
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.telephony.TelephonyManager
|
||||||
|
import android.util.Log
|
||||||
|
import com.android.internal.telephony.ITelephony
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectCallDelegate
|
||||||
|
|
||||||
|
class LRejectCallDelegate(private val app: Application) : RejectCallDelegate {
|
||||||
|
|
||||||
|
override suspend fun reject() {
|
||||||
|
getTeleService()?.endCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTeleService(): ITelephony? {
|
||||||
|
val tm = app.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
|
||||||
|
try {
|
||||||
|
val c = Class.forName(tm.javaClass.name)
|
||||||
|
val m = c.getDeclaredMethod("getITelephony")
|
||||||
|
m.isAccessible = true
|
||||||
|
return m.invoke(tm) as ITelephony
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("LRejectCallDelegate", "err", e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package ru.shadowsparky.callblocker.data
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import ru.shadowsparky.callblocker.BuildConfig
|
||||||
|
import ru.shadowsparky.callblocker.domain.ContactReader
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectCallDelegate
|
||||||
|
|
||||||
|
class NonContactsRejectCallDelegate(
|
||||||
|
private val contactReader: ContactReader,
|
||||||
|
private val number: String?,
|
||||||
|
private val wrapper: RejectCallDelegate
|
||||||
|
) : RejectCallDelegate {
|
||||||
|
|
||||||
|
override suspend fun reject() {
|
||||||
|
log("reject called with number $number")
|
||||||
|
if (number == null) {
|
||||||
|
log("number is null!!! exiting...")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val contacts = contactReader.read()
|
||||||
|
log("contacts found ${contacts.joinToString()}")
|
||||||
|
if (!contacts.any { it.phoneNumber == number }) {
|
||||||
|
log("calling number not found in contacts. rejecting")
|
||||||
|
wrapper.reject()
|
||||||
|
} else {
|
||||||
|
log("calling number found in contacts!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(msg: String) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NonContactsCallDelegate"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ru.shadowsparky.callblocker.data
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.telecom.TelecomManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectCallDelegate
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.P)
|
||||||
|
class PRejectCallDelegate(private val context: Context) : RejectCallDelegate {
|
||||||
|
@SuppressLint("MissingPermission") // пофиг
|
||||||
|
@Suppress("DEPRECATION") // еще больше пофиг, пока работает будем использовать!
|
||||||
|
override suspend fun reject() {
|
||||||
|
Log.e("PRejectCallDelegate", "reject called", RuntimeException())
|
||||||
|
context.getSystemService<TelecomManager>()?.endCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package ru.shadowsparky.callblocker.data
|
||||||
|
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.callblocker.domain.ContactReader
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectCallDelegate
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectPolicy
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectPolicyManager
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class PolicyBasedRejectDelegateFactory(
|
||||||
|
private val rejectPolicyManager: RejectPolicyManager,
|
||||||
|
private val contactReader: ContactReader,
|
||||||
|
private val rejectDelegate: RejectCallDelegate
|
||||||
|
) {
|
||||||
|
fun create(callingNumber: String?): PolicyBasedRejectDelegate {
|
||||||
|
return PolicyBasedRejectDelegate(
|
||||||
|
rejectPolicyManager,
|
||||||
|
callingNumber,
|
||||||
|
contactReader,
|
||||||
|
rejectDelegate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PolicyBasedRejectDelegate(
|
||||||
|
private val rejectPolicyManager: RejectPolicyManager,
|
||||||
|
private val callingNumber: String?,
|
||||||
|
private val contactReader: ContactReader,
|
||||||
|
private val rejectDelegate: RejectCallDelegate
|
||||||
|
) : RejectCallDelegate {
|
||||||
|
override suspend fun reject() {
|
||||||
|
val wrapper = when (rejectPolicyManager.get()) {
|
||||||
|
RejectPolicy.All -> rejectDelegate
|
||||||
|
RejectPolicy.NonContacts -> NonContactsRejectCallDelegate(
|
||||||
|
contactReader,
|
||||||
|
callingNumber,
|
||||||
|
rejectDelegate
|
||||||
|
)
|
||||||
|
|
||||||
|
RejectPolicy.None -> null
|
||||||
|
}
|
||||||
|
wrapper?.reject()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.shadowsparky.callblocker.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectPolicy
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectPolicyManager
|
||||||
|
import ru.shadowsparky.domain.KeyValueStorage
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class PreferenceRejectPolicyManager(
|
||||||
|
private val keyValueStorage: KeyValueStorage
|
||||||
|
) : RejectPolicyManager {
|
||||||
|
override val flow = keyValueStorage.getInt(POLICY_KEY).map {
|
||||||
|
RejectPolicy.findByCode(it ?: RejectPolicy.None.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun set(newPolicy: RejectPolicy) {
|
||||||
|
keyValueStorage.putInt(POLICY_KEY, newPolicy.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(): RejectPolicy = flow.first()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val POLICY_KEY = "policy"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ru.shadowsparky.callblocker.domain
|
||||||
|
|
||||||
|
interface ContactReader {
|
||||||
|
|
||||||
|
suspend fun read(): Set<ContactEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ContactEntry(
|
||||||
|
val name: String,
|
||||||
|
val phoneNumber: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package ru.shadowsparky.callblocker.domain
|
||||||
|
|
||||||
|
interface RejectCallDelegate {
|
||||||
|
suspend fun reject()
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.shadowsparky.callblocker.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface RejectPolicyManager {
|
||||||
|
val flow: Flow<RejectPolicy>
|
||||||
|
|
||||||
|
suspend fun set(newPolicy: RejectPolicy)
|
||||||
|
suspend fun get(): RejectPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class RejectPolicy(val code: Int) {
|
||||||
|
data object None : RejectPolicy(0)
|
||||||
|
data object All : RejectPolicy(1)
|
||||||
|
data object NonContacts : RejectPolicy(2)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val values: Set<RejectPolicy> get() = setOf(None, All, NonContacts)
|
||||||
|
|
||||||
|
fun findByCode(code: Int): RejectPolicy {
|
||||||
|
return values.first { it.code == code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.shadowsparky.callblocker.presentation
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.annotation.KoinApplication
|
||||||
|
import org.koin.plugin.module.dsl.startKoin
|
||||||
|
import ru.shadowsparky.callblocker.AppModule
|
||||||
|
|
||||||
|
@KoinApplication(modules = [AppModule::class])
|
||||||
|
object CallBlocker
|
||||||
|
|
||||||
|
class App : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
startKoin<CallBlocker> {
|
||||||
|
androidContext(this@App)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ru.shadowsparky.callblocker.presentation
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.telephony.TelephonyManager
|
||||||
|
import android.util.Log
|
||||||
|
import ru.shadowsparky.callblocker.BuildConfig
|
||||||
|
|
||||||
|
class RejectCallReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
val keys = intent?.extras?.keySet() ?: return
|
||||||
|
val values = keys.map { it to intent.extras?.get(it).toString() }
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d("RejectCallReceiver", "start")
|
||||||
|
values.forEach {
|
||||||
|
Log.d("RejectCallReceiver", "key ${it.first}; value: ${it.second}")
|
||||||
|
}
|
||||||
|
Log.d("RejectCallReceiver", "end")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.action == TelephonyManager.ACTION_PHONE_STATE_CHANGED && context != null) {
|
||||||
|
val state = intent.getStringExtra(TelephonyManager.EXTRA_STATE)
|
||||||
|
if (state == TelephonyManager.EXTRA_STATE_RINGING) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val number = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER)
|
||||||
|
RejectCallWorker.start(context, number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ru.shadowsparky.callblocker.presentation
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import ru.shadowsparky.callblocker.data.PolicyBasedRejectDelegateFactory
|
||||||
|
import ru.shadowsparky.koin
|
||||||
|
|
||||||
|
class RejectCallWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
private val delegateFactory by koin.inject<PolicyBasedRejectDelegateFactory>()
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val callingNumber = inputData.getString(NUMBER_KEY)
|
||||||
|
delegateFactory.create(callingNumber).reject()
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val NUMBER_KEY = "number"
|
||||||
|
|
||||||
|
fun start(context: Context?, number: String?) {
|
||||||
|
context ?: return
|
||||||
|
val wm = WorkManager.getInstance(context)
|
||||||
|
val request = OneTimeWorkRequestBuilder<RejectCallWorker>()
|
||||||
|
.setInputData(workDataOf(NUMBER_KEY to number))
|
||||||
|
.build()
|
||||||
|
wm.enqueue(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package ru.shadowsparky.callblocker.presentation.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import ru.shadowsparky.callblocker.R
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectPolicyManager
|
||||||
|
import ru.shadowsparky.koin
|
||||||
|
import ru.shadowsparky.ui.BaseScreen
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val rejectPolicyManager by koin.inject<RejectPolicyManager>()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
val permissionRequest = newPermissionRequest()
|
||||||
|
setContent {
|
||||||
|
BaseScreen(
|
||||||
|
appBar = {
|
||||||
|
TopAppBar(title = { Text(stringResource(R.string.app_name)) })
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalPermissionRequest provides permissionRequest) {
|
||||||
|
Box(modifier = Modifier.padding(it)) {
|
||||||
|
PolicyContent(rejectPolicyManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package ru.shadowsparky.callblocker.presentation.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import ru.shadowsparky.callblocker.R
|
||||||
|
|
||||||
|
interface PermissionRequest {
|
||||||
|
val hasPermission: StateFlow<Boolean>
|
||||||
|
|
||||||
|
fun getTitle(): String
|
||||||
|
|
||||||
|
fun getDescription(): String
|
||||||
|
|
||||||
|
fun requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class MutablePermissionRequest(private val hasPermissionCallback: () -> Boolean) :
|
||||||
|
PermissionRequest {
|
||||||
|
override val hasPermission = MutableStateFlow(hasPermissionCallback())
|
||||||
|
|
||||||
|
init {
|
||||||
|
initState()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestPermission() {
|
||||||
|
if (!hasPermission.value) requestPermissionInternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initState() {
|
||||||
|
if (!hasPermission.value) {
|
||||||
|
this.hasPermission.tryEmit(hasPermissionCallback())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun requestPermissionInternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
class MultiplePermissionRequest(private val requests: List<PermissionRequest>) : PermissionRequest {
|
||||||
|
override val hasPermission
|
||||||
|
get() = getLatestDeclinedRequest()?.hasPermission ?: MutableStateFlow(true)
|
||||||
|
|
||||||
|
override fun getTitle(): String {
|
||||||
|
return getLatestDeclinedRequest()?.getTitle() ?: "null"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescription(): String {
|
||||||
|
return getLatestDeclinedRequest()?.getDescription() ?: "null"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLatestDeclinedRequest(): PermissionRequest? {
|
||||||
|
return requests.firstOrNull { !it.hasPermission.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestPermission() {
|
||||||
|
getLatestDeclinedRequest()?.requestPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalPermissionRequest: ProvidableCompositionLocal<PermissionRequest> =
|
||||||
|
staticCompositionLocalOf { throw IllegalStateException("request must be initialized here") }
|
||||||
|
|
||||||
|
fun ComponentActivity.newPermissionRequest(): PermissionRequest {
|
||||||
|
val list = mutableListOf(
|
||||||
|
ReadContactsPermissionRequest(this),
|
||||||
|
ReadCallLogPermissionRequest(this),
|
||||||
|
newAnswerPhonePermissionRequest(),
|
||||||
|
ReadPhoneStatePermissionRequest(this),
|
||||||
|
)
|
||||||
|
return MultiplePermissionRequest(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class AndroidPermissionRequest(
|
||||||
|
private val activity: ComponentActivity,
|
||||||
|
private val permission: String,
|
||||||
|
hasPermissionCallback: () -> Boolean = {
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
activity,
|
||||||
|
permission
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
) : MutablePermissionRequest(hasPermissionCallback) {
|
||||||
|
private val launcher =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||||
|
hasPermission.tryEmit(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestPermissionInternal() {
|
||||||
|
launcher.launch(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun getString(code: Int, vararg args: Any): String {
|
||||||
|
return activity.getString(code, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReadContactsPermissionRequest(
|
||||||
|
activity: ComponentActivity
|
||||||
|
) : AndroidPermissionRequest(activity, Manifest.permission.READ_CONTACTS) {
|
||||||
|
override fun getDescription(): String = getString(R.string.read_contacts_desc)
|
||||||
|
override fun getTitle(): String = getString(R.string.read_contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReadCallLogPermissionRequest(
|
||||||
|
activity: ComponentActivity
|
||||||
|
) : AndroidPermissionRequest(activity, Manifest.permission.READ_CALL_LOG) {
|
||||||
|
override fun getDescription(): String = getString(R.string.read_call_log_desc)
|
||||||
|
override fun getTitle(): String = getString(R.string.read_call_log)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReadPhoneStatePermissionRequest(
|
||||||
|
activity: ComponentActivity
|
||||||
|
) : AndroidPermissionRequest(activity, Manifest.permission.READ_PHONE_STATE) {
|
||||||
|
override fun getDescription(): String = getString(R.string.read_phone_state_desc)
|
||||||
|
override fun getTitle(): String = getString(R.string.read_phone_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ComponentActivity.newAnswerPhonePermissionRequest(): PermissionRequest {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
AnswerPhoneCallPermissionRequest(this)
|
||||||
|
} else {
|
||||||
|
CallPhonePermissionRequest(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallPhonePermissionRequest(
|
||||||
|
activity: ComponentActivity
|
||||||
|
) : AndroidPermissionRequest(activity, Manifest.permission.CALL_PHONE) {
|
||||||
|
override fun getDescription(): String = getString(R.string.answer_phone_calls_desc)
|
||||||
|
override fun getTitle(): String = getString(R.string.answer_phone_calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
class AnswerPhoneCallPermissionRequest(
|
||||||
|
activity: ComponentActivity
|
||||||
|
) : AndroidPermissionRequest(activity, Manifest.permission.ANSWER_PHONE_CALLS) {
|
||||||
|
override fun getDescription(): String = getString(R.string.answer_phone_calls_desc)
|
||||||
|
override fun getTitle(): String = getString(R.string.answer_phone_calls)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package ru.shadowsparky.callblocker.presentation.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.shadowsparky.callblocker.R
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectPolicy
|
||||||
|
import ru.shadowsparky.callblocker.domain.RejectPolicyManager
|
||||||
|
import ru.shadowsparky.ui.components.ListPreference
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PolicyContent(policyManager: RejectPolicyManager) {
|
||||||
|
val state = policyManager.flow.collectAsState(null)
|
||||||
|
if (state.value != null) {
|
||||||
|
InitializedPolicyContent(state, policyManager, LocalPermissionRequest.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun InitializedPolicyScreenPreview() {
|
||||||
|
val state = remember { mutableStateOf<RejectPolicy>(RejectPolicy.None) }
|
||||||
|
InitializedPolicyContent(
|
||||||
|
state = state,
|
||||||
|
manager = object : RejectPolicyManager {
|
||||||
|
override val flow = MutableStateFlow<RejectPolicy>(RejectPolicy.None)
|
||||||
|
override suspend fun set(newPolicy: RejectPolicy) = flow.emit(newPolicy)
|
||||||
|
override suspend fun get(): RejectPolicy = flow.value
|
||||||
|
},
|
||||||
|
MultiplePermissionRequest(
|
||||||
|
listOf(
|
||||||
|
NoOpPermissionRequest("1"),
|
||||||
|
NoOpPermissionRequest("2"),
|
||||||
|
NoOpPermissionRequest("3")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NoOpPermissionRequest(private val prefix: String) : PermissionRequest {
|
||||||
|
override val hasPermission = MutableStateFlow(false)
|
||||||
|
|
||||||
|
override fun getTitle(): String {
|
||||||
|
return "$prefix noop"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescription(): String {
|
||||||
|
return "$prefix noop"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestPermission() {
|
||||||
|
hasPermission.tryEmit(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InitializedPolicyContent(
|
||||||
|
state: State<RejectPolicy?>,
|
||||||
|
manager: RejectPolicyManager,
|
||||||
|
permissionRequest: PermissionRequest
|
||||||
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
Column {
|
||||||
|
val items = RejectPolicy.values.associateWith { it.getTitle() }
|
||||||
|
val item = remember { mutableStateOf(state.value!!) }
|
||||||
|
val hasPermission by permissionRequest.hasPermission.collectAsState()
|
||||||
|
if (!hasPermission) {
|
||||||
|
MissingPermissionCard(
|
||||||
|
permissionRequest,
|
||||||
|
modifier = Modifier.padding(4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ListPreference(
|
||||||
|
title = stringResource(id = R.string.selected_policy),
|
||||||
|
items = items,
|
||||||
|
selectedItem = item.value,
|
||||||
|
onItemSelected = {
|
||||||
|
item.value = it
|
||||||
|
coroutineScope.launch {
|
||||||
|
manager.set(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RejectPolicy.getTitle(): String {
|
||||||
|
val code = when (this) {
|
||||||
|
RejectPolicy.All -> R.string.reject_all
|
||||||
|
RejectPolicy.NonContacts -> R.string.reject_non_contacts
|
||||||
|
RejectPolicy.None -> R.string.reject_none
|
||||||
|
}
|
||||||
|
return stringResource(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun MissingPermissionCardPreview() {
|
||||||
|
MissingPermissionCard(NoOpPermissionRequest("1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MissingPermissionCard(
|
||||||
|
request: PermissionRequest,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(8.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.warning),
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
id = R.string.mission_permission_text,
|
||||||
|
request.getTitle(),
|
||||||
|
request.getDescription()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.CenterEnd,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { request.requestPermission() },
|
||||||
|
) {
|
||||||
|
Text(stringResource(id = R.string.grant_permission))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:tint="#000000"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<group
|
||||||
|
android:scaleX="0.464"
|
||||||
|
android:scaleY="0.464"
|
||||||
|
android:translateX="6.432"
|
||||||
|
android:translateY="6.432">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M13,9h-2v2h2L13,9zM17,9h-2v2h2L17,9zM20,15.5c-1.25,0 -2.45,-0.2 -3.57,-0.57 -0.35,-0.11 -0.74,-0.03 -1.02,0.24l-2.2,2.2c-2.83,-1.44 -5.15,-3.75 -6.59,-6.58l2.2,-2.21c0.28,-0.27 0.36,-0.66 0.25,-1.01C8.7,6.45 8.5,5.25 8.5,4c0,-0.55 -0.45,-1 -1,-1L4,3c-0.55,0 -1,0.45 -1,1 0,9.39 7.61,17 17,17 0.55,0 1,-0.45 1,-1v-3.5c0,-0.55 -0.45,-1 -1,-1zM19,9v2h2L21,9h-2z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:tint="@android:color/white"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<group
|
||||||
|
android:scaleX="0.464"
|
||||||
|
android:scaleY="0.464"
|
||||||
|
android:translateX="6.432"
|
||||||
|
android:translateY="6.432">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M13,9h-2v2h2L13,9zM17,9h-2v2h2L17,9zM20,15.5c-1.25,0 -2.45,-0.2 -3.57,-0.57 -0.35,-0.11 -0.74,-0.03 -1.02,0.24l-2.2,2.2c-2.83,-1.44 -5.15,-3.75 -6.59,-6.58l2.2,-2.21c0.28,-0.27 0.36,-0.66 0.25,-1.01C8.7,6.45 8.5,5.25 8.5,4c0,-0.55 -0.45,-1 -1,-1L4,3c-0.55,0 -1,0.45 -1,1 0,9.39 7.61,17 17,17 0.55,0 1,-0.45 1,-1v-3.5c0,-0.55 -0.45,-1 -1,-1zM19,9v2h2L21,9h-2z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Блокировка звонков</string>
|
||||||
|
<string name="permission_rejected">Права не выданы</string>
|
||||||
|
<string name="reject_none">Выключено</string>
|
||||||
|
<string name="reject_all">Отменять любые звонки</string>
|
||||||
|
<string name="reject_non_contacts">Отменять звонки не из контактов</string>
|
||||||
|
<string name="selected_policy">Режим работы</string>
|
||||||
|
<string name="read_contacts">чтения контактов</string>
|
||||||
|
<string name="read_contacts_desc">распознавания находится ли номер в списке контактов или нет</string>
|
||||||
|
<string name="warning">Внимание!</string>
|
||||||
|
<string name="mission_permission_text">Не выданы права для %1$s. Приложению эти права необходимы для %2$s. Пожалуйста, выдайте права, иначе приложение может работать некорректно"</string>
|
||||||
|
<string name="read_call_log_desc">просмотра номера звонящего</string>
|
||||||
|
<string name="read_call_log">просмотра информации о звонке</string>
|
||||||
|
<string name="read_phone_state_desc">получения уведомлений о звонках</string>
|
||||||
|
<string name="read_phone_state">чтение состояния телефона</string>
|
||||||
|
<string name="answer_phone_calls_desc">сброса входящего звонка</string>
|
||||||
|
<string name="answer_phone_calls">ответа на звонок</string>
|
||||||
|
<string name="grant_permission">Выдать права</string>
|
||||||
|
<string name="settings">Настройки</string>
|
||||||
|
<string name="about_app">О приложении</string>
|
||||||
|
<string name="version">Версия</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.CallBlocker" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@color/black</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older that API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
keystore.properties
|
||||||
|
release.keystore
|
||||||
|
*.keystore
|
||||||
|
build
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.android.app)
|
||||||
|
alias(libs.plugins.convention.compose)
|
||||||
|
alias(libs.plugins.convention.koin)
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions.getByType<ApplicationExtension>().apply {
|
||||||
|
buildFeatures { buildConfig = true }
|
||||||
|
namespace = "ru.shadowsparky.experimentarium"
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = namespace
|
||||||
|
minSdk = libs.versions.proj.min.get().toInt()
|
||||||
|
targetSdk = libs.versions.proj.target.get().toInt()
|
||||||
|
versionCode = 1000
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
koinCompiler {
|
||||||
|
compileSafety = false
|
||||||
|
unsafeDslChecks = false
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.activity.compose)
|
||||||
|
implementation(libs.material)
|
||||||
|
implementation(project(":apps:experimentarium:shared"))
|
||||||
|
implementation(project(":libs:video-player"))
|
||||||
|
implementation(project(":libs:ui"))
|
||||||
|
implementation(project(":libs:base"))
|
||||||
|
debugImplementation(libs.ui.tooling)
|
||||||
|
compileOnly(libs.ui.tooling.preview.android)
|
||||||
|
testImplementation(libs.koin.test)
|
||||||
|
testImplementation(libs.mockk)
|
||||||
|
testImplementation(kotlin("test-junit"))
|
||||||
|
androidTestImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.runner)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".App"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Material3.DayNight.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="screenSize|screenLayout|orientation|smallestScreenSize"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleInstance"
|
||||||
|
android:supportsPictureInPicture="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
package ru.shadowsparky.experimentarium
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.annotation.KoinApplication
|
||||||
|
import org.koin.plugin.module.dsl.startKoin
|
||||||
|
|
||||||
|
@KoinApplication
|
||||||
|
object Experimentarium
|
||||||
|
|
||||||
|
class App : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
startKoin<Experimentarium> {
|
||||||
|
androidContext(this@App)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.shadowsparky.experimentarium
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import ru.shadowsparky.experimentarium.shared.TestPlayer
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
TestPlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:tint="#000000"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group
|
||||||
|
android:scaleX="0.58"
|
||||||
|
android:scaleY="0.58"
|
||||||
|
android:translateX="201.6"
|
||||||
|
android:translateY="201.6">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M40,720Q49,613 105.5,523Q162,433 256,380L182,252Q176,243 179,233Q182,223 192,218Q200,213 210,216Q220,219 226,228L300,356Q386,320 480,320Q574,320 660,356L734,228Q740,219 750,216Q760,213 768,218Q778,223 781,233Q784,243 778,252L704,380Q798,433 854.5,523Q911,613 920,720L40,720ZM280,610Q301,610 315.5,595.5Q330,581 330,560Q330,539 315.5,524.5Q301,510 280,510Q259,510 244.5,524.5Q230,539 230,560Q230,581 244.5,595.5Q259,610 280,610ZM680,610Q701,610 715.5,595.5Q730,581 730,560Q730,539 715.5,524.5Q701,510 680,510Q659,510 644.5,524.5Q630,539 630,560Q630,581 644.5,595.5Q659,610 680,610Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 764 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 554 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1012 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Experimentarium</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
build
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.kmp)
|
||||||
|
alias(libs.plugins.convention.android.lib)
|
||||||
|
alias(libs.plugins.convention.compose)
|
||||||
|
alias(libs.plugins.convention.serialization)
|
||||||
|
alias(libs.plugins.convention.koin)
|
||||||
|
}
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":libs:base"))
|
||||||
|
implementation(project(":libs:video-player"))
|
||||||
|
}
|
||||||
|
androidMain.dependencies {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
android {
|
||||||
|
namespace = "ru.shadowsparky.experimentarium.shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ru.shadowsparky.experimentarium.shared
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import ru.shadowsparky.koin
|
||||||
|
import ru.shadowsparky.videoplayer.PlayerController
|
||||||
|
import ru.shadowsparky.videoplayer.ui.VideoPlayer
|
||||||
|
|
||||||
|
private val controller = koin.get<PlayerController>()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TestPlayer() {
|
||||||
|
VideoPlayer(controller, modifier = Modifier.fillMaxSize())
|
||||||
|
LaunchedEffect(controller) {
|
||||||
|
controller.load("https://samplelib.com/mp4/sample-5s.mp4")
|
||||||
|
controller.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
build
|
||||||
|
videobox.db
|
||||||
|
cache
|
||||||
|
bin
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@file:OptIn(ExperimentalWasmDsl::class)
|
||||||
|
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.kmp)
|
||||||
|
alias(libs.plugins.convention.compose)
|
||||||
|
alias(libs.plugins.convention.koin)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
val wasmJsMain by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":libs:base"))
|
||||||
|
implementation(project(":libs:ui"))
|
||||||
|
implementation(project(":libs:video-player"))
|
||||||
|
implementation(project(":apps:experimentarium:shared"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wasmJs {
|
||||||
|
browser {
|
||||||
|
commonWebpackConfig {
|
||||||
|
outputFileName = "composeApp.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binaries.executable()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package ru.shadowsparky.experimentarium
|
||||||
|
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.window.ComposeViewport
|
||||||
|
import com.arkivanov.essenty.lifecycle.LifecycleRegistry
|
||||||
|
import com.arkivanov.essenty.lifecycle.doOnResume
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.ksp.generated.module
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
import ru.shadowsparky.CommonModule
|
||||||
|
import ru.shadowsparky.experimentarium.shared.TestPlayer
|
||||||
|
import ru.shadowsparky.ui.hideElementById
|
||||||
|
import ru.shadowsparky.ui.hookPageVisibility
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
fun main() {
|
||||||
|
val lifecycle = LifecycleRegistry()
|
||||||
|
startKoin { modules(CommonModule().module) }
|
||||||
|
val composeRoot = document.getElementById("compose-root") as HTMLElement
|
||||||
|
hookPageVisibility(lifecycle)
|
||||||
|
lifecycle.doOnResume { hideElementById("loader") }
|
||||||
|
ComposeViewport(viewportContainer = composeRoot) {
|
||||||
|
TestPlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Test Layers</title>
|
||||||
|
<style>
|
||||||
|
#loader {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: #111;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 6px solid #ccc;
|
||||||
|
border-top-color: #09f;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script defer src="/composeApp.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app-container" style="position:fixed; inset:0; overflow:hidden;">
|
||||||
|
<div id="loader">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
<div id="video-layer"
|
||||||
|
style="position:absolute; inset:0; background:#000; visibility: hidden;"></div>
|
||||||
|
<div id="compose-root" style="position:absolute; inset:0;"></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/build
|
||||||
|
/videobox
|
||||||
|
/bin
|
||||||
|
back.env
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
FROM bellsoft/liberica-openjdk-alpine:21
|
||||||
|
|
||||||
|
WORKDIR /usr/local/app
|
||||||
|
COPY ./build/libs/server-fat-1.0.jar ./server-fat-1.0.jar
|
||||||
|
EXPOSE 8181
|
||||||
|
CMD ["java", "-jar", "server-fat-1.0.jar"]
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.backend.app)
|
||||||
|
alias(libs.plugins.ktor)
|
||||||
|
alias(libs.plugins.sqldelight)
|
||||||
|
alias(libs.plugins.serialization)
|
||||||
|
alias(libs.plugins.convention.koin)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqldelight {
|
||||||
|
databases {
|
||||||
|
create("AppDatabase") {
|
||||||
|
packageName.set("ru.shadowsparky.vbox.backend")
|
||||||
|
dialect("app.cash.sqldelight:postgresql-dialect:2.0.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":apps:vbox:common"))
|
||||||
|
implementation(project(":libs:backend-base"))
|
||||||
|
implementation(project(":libs:base"))
|
||||||
|
implementation(project(":libs:http-client"))
|
||||||
|
|
||||||
|
implementation(libs.ktor.server.core.jvm)
|
||||||
|
implementation(libs.ktor.server.host.common.jvm)
|
||||||
|
implementation(libs.ktor.server.status.pages.jvm)
|
||||||
|
implementation(libs.ktor.server.content.negotiation.jvm)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json.jvm)
|
||||||
|
implementation(libs.ktor.server.netty.jvm)
|
||||||
|
implementation(libs.ktor.server.auth)
|
||||||
|
implementation(libs.ktor.server.auth.jwt)
|
||||||
|
implementation(libs.ktor.server.websockets.jvm)
|
||||||
|
implementation(libs.ktor.server.cors)
|
||||||
|
|
||||||
|
implementation(libs.androidx.datastore.preferences.core)
|
||||||
|
implementation(libs.kxml2)
|
||||||
|
implementation(libs.jsoup)
|
||||||
|
implementation(libs.jdbc.driver)
|
||||||
|
implementation(libs.postgresql)
|
||||||
|
implementation(libs.coroutines.extensions)
|
||||||
|
|
||||||
|
testImplementation(libs.koin.test)
|
||||||
|
testImplementation(libs.mockk)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get())
|
||||||
|
targetCompatibility = JavaVersion.toVersion(libs.versions.proj.server.java.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "ru.shadowsparky.vbox"
|
||||||
|
version = "1.0"
|
||||||
|
|
||||||
|
val mainClassName = "ru.shadowsparky.vbox.backend.MainKt"
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set(mainClassName)
|
||||||
|
|
||||||
|
val isDevelopment: Boolean = project.ext.has("development")
|
||||||
|
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks {
|
||||||
|
register("fatJar", Jar::class) {
|
||||||
|
archiveBaseName = "server-fat"
|
||||||
|
manifest {
|
||||||
|
attributes["Implementation-Title"] = "Videobox Server"
|
||||||
|
attributes["Implementation-Version"] = version
|
||||||
|
attributes["Main-Class"] = mainClassName
|
||||||
|
}
|
||||||
|
from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) })
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.WARN
|
||||||
|
with(jar.get() as CopySpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
"build" {
|
||||||
|
dependsOn("fatJar")
|
||||||
|
}
|
||||||
|
|
||||||
|
register<Exec>("deployRemote") {
|
||||||
|
dependsOn("fatJar")
|
||||||
|
commandLine(
|
||||||
|
getCommandLine(
|
||||||
|
"docker context use remote",
|
||||||
|
"docker build . -t videobox-server",
|
||||||
|
"docker context use default"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
register<Exec>("deployLocal") {
|
||||||
|
dependsOn("fatJar")
|
||||||
|
commandLine(
|
||||||
|
getCommandLine(
|
||||||
|
"docker compose down",
|
||||||
|
"docker compose up -d --build --force-recreate"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
notCompatibleWithConfigurationCache("This task uses Exec which is not compatible with configuration cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun getCommandLine(vararg commands: String): List<String> {
|
||||||
|
val osName = System.getProperty("os.name").lowercase()
|
||||||
|
return if (osName.contains("win")) {
|
||||||
|
listOf("cmd", "/c") + commands.joinToString(" && ")
|
||||||
|
} else {
|
||||||
|
listOf("sh", "-c") + commands.joinToString(" && ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- postgres.env
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
networks:
|
||||||
|
- serv-network
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
|
||||||
|
videobox:
|
||||||
|
container_name: videobox
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
env_file:
|
||||||
|
- postgres.env
|
||||||
|
- back.env
|
||||||
|
ports:
|
||||||
|
- 8181:8181
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./videobox/cache/:/usr/local/app/cache:rw
|
||||||
|
networks:
|
||||||
|
- serv-network
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
networks:
|
||||||
|
serv-network:
|
||||||
|
driver: bridge
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
POSTGRES_DB=videobox
|
||||||
|
POSTGRES_USER=login
|
||||||
|
POSTGRES_PASSWORD=password
|
||||||
|
|
||||||
|
VIDEOBOX_SERVER_URI=postgres
|
||||||
|
VIDEOBOX_SECRET=144b5446caea8d7d1a33cc87b0447f3be608f0b12bef3d130ec37264375df912468411e37b432299d7f037db81f099b4b3572d4898fa6cc154e6931d7d12f78f
|
||||||
|
|
||||||
|
ALLOW_REGISTRATION=1
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Videobox Server
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
### Remote
|
||||||
|
|
||||||
|
Сначала нужно добавить remote context
|
||||||
|
`docker context create remote --docker "host=ssh://root@localhost"`.
|
||||||
|
Далее публикация происходит таской gradle: `gradle :apps:vbox:backend:deployRemote`
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend
|
||||||
|
|
||||||
|
import org.koin.core.annotation.ComponentScan
|
||||||
|
import org.koin.core.annotation.Module
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
import ru.shadowsparky.backend.data.EnvFetcher
|
||||||
|
import ru.shadowsparky.backend.domain.JwtInfo
|
||||||
|
import ru.shadowsparky.vbox.backend.di.DbModule
|
||||||
|
import ru.shadowsparky.vbox.backend.di.EventModule
|
||||||
|
import ru.shadowsparky.vbox.backend.di.HttpModule
|
||||||
|
|
||||||
|
@Module
|
||||||
|
class VboxModule {
|
||||||
|
|
||||||
|
@Single
|
||||||
|
fun provideJwtInfo(envFetcher: EnvFetcher): JwtInfo {
|
||||||
|
return JwtInfo(
|
||||||
|
envFetcher.get("VIDEOBOX_SECRET", "secret"),
|
||||||
|
"https://shadowsparky.ru/",
|
||||||
|
"https://box.shadowsparky.ru/",
|
||||||
|
"server side data storage"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module(
|
||||||
|
includes = [
|
||||||
|
DbModule::class,
|
||||||
|
HttpModule::class,
|
||||||
|
EventModule::class,
|
||||||
|
VboxModule::class
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@ComponentScan
|
||||||
|
class BackendModule
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend
|
||||||
|
|
||||||
|
import io.ktor.server.application.Application
|
||||||
|
import org.koin.core.annotation.KoinApplication
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.get
|
||||||
|
import org.koin.plugin.module.dsl.startKoin
|
||||||
|
import ru.shadowsparky.vbox.backend.presentation.configureJwt
|
||||||
|
import ru.shadowsparky.vbox.backend.presentation.configureRouting
|
||||||
|
import ru.shadowsparky.vbox.backend.presentation.configureSerialization
|
||||||
|
import ru.shadowsparky.vbox.backend.presentation.configureWebSocket
|
||||||
|
|
||||||
|
@KoinApplication
|
||||||
|
object VBoxBackend
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
startKoin<VBoxBackend>()
|
||||||
|
io.ktor.server.netty.EngineMain.main(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Application.module() {
|
||||||
|
val koin = object : KoinComponent {}
|
||||||
|
configureSerialization()
|
||||||
|
configureJwt(koin.get())
|
||||||
|
configureWebSocket(koin.get())
|
||||||
|
configureRouting(koin.get(), koin.get())
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
||||||
|
import java.sql.SQLException
|
||||||
|
|
||||||
|
class BackendRecentlyWatchedRepository(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val userId: Long,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
private val remoteEventHandler: RemoteEventHandler
|
||||||
|
) : RecentlyWatchedRepository {
|
||||||
|
|
||||||
|
override suspend fun remove(info: RecentlyWatchedInfo) =
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
db.recentQueries.deleteRwByIds(info.movieId, info.episode, info.season, userId)
|
||||||
|
notifyChanged(info.movieId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun write(info: RecentlyWatchedInfo) =
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
try {
|
||||||
|
db.recentQueries.insertRecentlyWatched(
|
||||||
|
userId,
|
||||||
|
info.movieId,
|
||||||
|
info.episode,
|
||||||
|
info.season
|
||||||
|
)
|
||||||
|
notifyChanged(info.movieId)
|
||||||
|
} catch (ignored: SQLException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun toggleInfo(info: RecentlyWatchedInfo) =
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
val hasInfo =
|
||||||
|
db.recentQueries.selectByIds(info.movieId, info.episode, info.season, userId)
|
||||||
|
.executeAsOneOrNull() != null
|
||||||
|
if (hasInfo) {
|
||||||
|
remove(info)
|
||||||
|
} else {
|
||||||
|
write(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryRecentlyWatched(
|
||||||
|
movieId: Long,
|
||||||
|
seasonId: Long?
|
||||||
|
): Flow<List<RecentlyWatchedInfo>> = flow {
|
||||||
|
val rsp = if (seasonId == null) {
|
||||||
|
db.recentQueries.selectByMovieId(movieId, userId)
|
||||||
|
.executeAsList()
|
||||||
|
.map {
|
||||||
|
RecentlyWatchedInfo(
|
||||||
|
it.movie_id,
|
||||||
|
it.episode,
|
||||||
|
it.season
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
db.recentQueries.selectByMovieIdAndSeason(movieId, userId, seasonId)
|
||||||
|
.executeAsList()
|
||||||
|
.map {
|
||||||
|
RecentlyWatchedInfo(
|
||||||
|
it.movie_id,
|
||||||
|
it.episode,
|
||||||
|
seasonId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(rsp)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAllRecentlyWatched(): List<RecentlyWatchedInfo> {
|
||||||
|
return withContext(dispatcherProvider.io) {
|
||||||
|
db.recentQueries.selectAll(userId).executeAsList()
|
||||||
|
.map {
|
||||||
|
RecentlyWatchedInfo(
|
||||||
|
it.movie_id,
|
||||||
|
it.episode,
|
||||||
|
it.season
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun notifyChanged(seasonId: Long) {
|
||||||
|
remoteEventHandler.notify(
|
||||||
|
RemoteEvent.OnRecent(
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
userId,
|
||||||
|
seasonId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import org.slf4j.Logger
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||||
|
|
||||||
|
val eventLogger: Logger = LoggerFactory.getLogger("events")
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class BackendRemoteEventHandler(
|
||||||
|
private val json: Json,
|
||||||
|
private val sessionCache: SessionCache
|
||||||
|
) : RemoteEventHandler {
|
||||||
|
|
||||||
|
suspend fun put(userId: Long, socketSession: SessionCache.Writer) {
|
||||||
|
sessionCache.put(userId, socketSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun remove(userId: Long, socketSession: SessionCache.Writer) {
|
||||||
|
sessionCache.remove(userId, socketSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun notify(eventInfo: RemoteEvent) {
|
||||||
|
val json = json.encodeToString(eventInfo)
|
||||||
|
val writers = sessionCache.get(eventInfo.userId)
|
||||||
|
if (writers.isNullOrEmpty()) {
|
||||||
|
eventLogger.info("unable to notify $eventInfo. sessions not found, cache $sessionCache")
|
||||||
|
} else {
|
||||||
|
writers.forEach {
|
||||||
|
eventLogger.info("notify[$eventInfo]. session $it", RuntimeException("called"))
|
||||||
|
try {
|
||||||
|
it.writeText(json)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
eventLogger.error("unable to notify $it. delete session")
|
||||||
|
remove(eventInfo.userId, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.shadowsparky.backend.data.Logger
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.server.Saved_movie
|
||||||
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||||
|
import java.sql.SQLException
|
||||||
|
|
||||||
|
class BackendSavedMovieRepository(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val userId: Long,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
private val logger: Logger,
|
||||||
|
private val eventHandler: RemoteEventHandler
|
||||||
|
) : SavedMovieRepository {
|
||||||
|
|
||||||
|
override fun getAll(): Flow<List<VideoDetails>> {
|
||||||
|
val request = db.saved_movieQueries.selectByUserId(userId)
|
||||||
|
return flow {
|
||||||
|
emit(request.executeAsList().parse().reversed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun List<Saved_movie>.parse(): List<VideoDetails> =
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
mapNotNull {
|
||||||
|
val movie = db.movieQueries.selectMovie(it.movie_id)
|
||||||
|
.executeAsOneOrNull() ?: return@mapNotNull null
|
||||||
|
VideoDetails(
|
||||||
|
movie.movie_id,
|
||||||
|
movie.poster_url,
|
||||||
|
movie.description,
|
||||||
|
movie.title,
|
||||||
|
(movie.flags and SERIAL_FLAG) != 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(details: VideoDetails) {
|
||||||
|
logger.debug(TAG, "save(${details.id}) ${details.title}")
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
try {
|
||||||
|
db.saved_movieQueries.addSavedMovie(
|
||||||
|
details.id,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
eventHandler.notify(RemoteEvent.OnSaved(userId, details.id))
|
||||||
|
} catch (_: SQLException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isSaved(id: Long): Flow<Boolean> {
|
||||||
|
return flow {
|
||||||
|
val request = db.saved_movieQueries.selectByUserIdAndMovieId(userId, id)
|
||||||
|
emit(request.executeAsOneOrNull() != null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(id: Long) {
|
||||||
|
logger.debug(TAG, "remove(${id})")
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
db.saved_movieQueries.removeSavedMovie(userId, id)
|
||||||
|
eventHandler.notify(RemoteEvent.OnSaved(userId, id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "BackendSavedMovieRepository"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||||
|
import java.sql.SQLException
|
||||||
|
|
||||||
|
class BackendSearchRepository(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val userId: Long,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
private val eventHandler: RemoteEventHandler
|
||||||
|
) : SearchRepository {
|
||||||
|
|
||||||
|
override fun search(query: String): Flow<List<String>> {
|
||||||
|
return flow { emit(searchSingle(query)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun searchSingle(query: String): List<String> = withContext(Dispatchers.IO) {
|
||||||
|
if (query.trim().isEmpty()) {
|
||||||
|
db.searchQueries.selectAll(userId)
|
||||||
|
} else {
|
||||||
|
db.searchQueries.selectByQuery(query.addWilcard(), userId)
|
||||||
|
}.executeAsList().map { it.query }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.addWilcard(): String {
|
||||||
|
return if (this.endsWith("%")) this else "$this%"
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addToSearch(query: String) {
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
try {
|
||||||
|
db.searchQueries.insertSearchInfo(query, userId)
|
||||||
|
notifyChanged()
|
||||||
|
} catch (_: SQLException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteFromSearch(query: String) {
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
db.searchQueries.deleteSearchInfo(query, userId)
|
||||||
|
notifyChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear() {
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
db.searchQueries.deleteAll(userId)
|
||||||
|
notifyChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun notifyChanged() {
|
||||||
|
eventHandler.notify(RemoteEvent.OnSearch(System.currentTimeMillis(), userId))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
typealias SessionMap = MutableMap<Long, List<SessionCache.Writer>>
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class SessionCache {
|
||||||
|
private val sessionMap: SessionMap = Collections.synchronizedMap(hashMapOf())
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun put(userId: Long, socketSession: Writer) {
|
||||||
|
eventLogger.info("put[$userId]=$socketSession")
|
||||||
|
mutex.withLock {
|
||||||
|
sessionMap[userId] = (sessionMap[userId] ?: mutableListOf()) + listOf(socketSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun get(userId: Long): List<Writer>? {
|
||||||
|
mutex.withLock {
|
||||||
|
return sessionMap[userId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun remove(userId: Long, session: Writer) {
|
||||||
|
eventLogger.info("remove[$userId]=$session")
|
||||||
|
mutex.withLock {
|
||||||
|
val sessionFromMap = sessionMap[userId]?.toMutableList()
|
||||||
|
val item = sessionFromMap?.firstOrNull { it == session } ?: return
|
||||||
|
sessionFromMap.remove(item)
|
||||||
|
sessionMap[userId] = sessionFromMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface Writer {
|
||||||
|
suspend fun writeText(text: String)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data.auth
|
||||||
|
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.backend.data.EnvFetcher
|
||||||
|
import ru.shadowsparky.backend.data.JwtPreparer
|
||||||
|
import ru.shadowsparky.backend.data.StringFetcher
|
||||||
|
import ru.shadowsparky.backend.data.StringResource
|
||||||
|
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||||
|
import ru.shadowsparky.domain.Log
|
||||||
|
import ru.shadowsparky.http.domain.BadRequestException
|
||||||
|
import ru.shadowsparky.http.domain.TokenInfo
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.server.Refresh_tokens
|
||||||
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.TokenRequest
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.LoginInfo
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class AuthTokenRepositoryFactory(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
private val jwtPreparer: JwtPreparer,
|
||||||
|
private val loginVerifier: LoginVerifier,
|
||||||
|
private val log: Log,
|
||||||
|
private val stringFetcher: StringFetcher,
|
||||||
|
private val envFetcher: EnvFetcher
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun create(userId: Long = -1): AuthTokenRepository {
|
||||||
|
return BackendAuthTokenRepository(
|
||||||
|
db,
|
||||||
|
dispatcherProvider,
|
||||||
|
jwtPreparer,
|
||||||
|
loginVerifier,
|
||||||
|
userId,
|
||||||
|
log,
|
||||||
|
stringFetcher,
|
||||||
|
envFetcher
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackendAuthTokenRepository(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
private val jwtPreparer: JwtPreparer,
|
||||||
|
private val loginVerifier: LoginVerifier,
|
||||||
|
private val userId: Long,
|
||||||
|
private val log: Log,
|
||||||
|
private val stringFetcher: StringFetcher,
|
||||||
|
private val envFetcher: EnvFetcher
|
||||||
|
) : AuthTokenRepository {
|
||||||
|
private val random = SecureRandom()
|
||||||
|
|
||||||
|
override suspend fun register(loginInfo: LoginInfo): TokenInfo {
|
||||||
|
return if (envFetcher.get("ALLOW_REGISTRATION", "0") == "1") {
|
||||||
|
registerInternal(loginInfo)
|
||||||
|
} else {
|
||||||
|
throw BadRequestException(stringFetcher.get(StringResource.REG_BLOCKED))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun registerInternal(loginInfo: LoginInfo): TokenInfo {
|
||||||
|
return withContext(dispatcherProvider.io) {
|
||||||
|
val user = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull()
|
||||||
|
if (user != null) throw BadRequestException(stringFetcher.get(StringResource.ALREADY_REGISTERED))
|
||||||
|
db.usersQueries.addUser(
|
||||||
|
loginInfo.login, loginInfo.passwordHash, System.currentTimeMillis(), null
|
||||||
|
)
|
||||||
|
TokenInfo("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun login(loginInfo: LoginInfo): TokenInfo =
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
val userInfo = db.usersQueries.selectUserByLogin(loginInfo.login).executeAsOneOrNull()
|
||||||
|
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||||
|
if (loginInfo.passwordHash != userInfo.password_hash) {
|
||||||
|
throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||||
|
}
|
||||||
|
create(loginInfo.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun update(refresh: TokenRequest): TokenInfo {
|
||||||
|
val info = db.refresh_tokensQueries.selectByHash(refresh.token.toHash())
|
||||||
|
.executeAsOneOrNull()
|
||||||
|
?: throw BadRequestException("Token not found")
|
||||||
|
val userInfo = db.usersQueries.selectUserByUserId(info.user_id).executeAsOneOrNull()
|
||||||
|
?: throw BadRequestException("User not found")
|
||||||
|
val newTokens = create(userInfo.login)
|
||||||
|
revokeInternal(refresh)
|
||||||
|
return newTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun revoke(tokenRequest: TokenRequest) {
|
||||||
|
revokeInternal(tokenRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun revokeInternal(request: TokenRequest) {
|
||||||
|
log.d("BackendAuthTokenRepository", "revoke ${request.token.toHash()}")
|
||||||
|
val info = getTokenAndCheck(request)
|
||||||
|
db.refresh_tokensQueries.revokeToken(info.token_id).await()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getTokenAndCheck(tokenRequest: TokenRequest): Refresh_tokens =
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
val info = db.refresh_tokensQueries.selectByHash(tokenRequest.token.toHash())
|
||||||
|
.executeAsOneOrNull()
|
||||||
|
?: throw BadRequestException("Token not found")
|
||||||
|
if (info.revoked) throw BadRequestException("Token already revoked")
|
||||||
|
if (info.expires_at < System.currentTimeMillis()) {
|
||||||
|
throw BadRequestException(
|
||||||
|
"Refresh token expired. " +
|
||||||
|
"Expires at ${info.expires_at} < ${System.currentTimeMillis()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
info
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun create(login: String): TokenInfo = withContext(dispatcherProvider.io) {
|
||||||
|
loginVerifier.verify(login)
|
||||||
|
val userInfo = db.usersQueries.selectUserByLogin(login).executeAsOneOrNull()
|
||||||
|
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||||
|
val rsp = jwtPreparer.prepare(login, userInfo.user_id)
|
||||||
|
val refresh = ByteArray(32)
|
||||||
|
random.nextBytes(refresh)
|
||||||
|
val refreshStr = refresh.toHexString()
|
||||||
|
db.refresh_tokensQueries.insertToken(
|
||||||
|
userInfo.user_id,
|
||||||
|
refreshStr.toHash(),
|
||||||
|
System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30)
|
||||||
|
).executeAsOneOrNull()
|
||||||
|
log.d(
|
||||||
|
"BackendAuthTokenRepository",
|
||||||
|
"${refreshStr.toHash()} created for user ${userInfo.user_id}"
|
||||||
|
)
|
||||||
|
TokenInfo(rsp, refreshStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun changePassword(request: ChangePasswordRequest): Unit =
|
||||||
|
withContext(dispatcherProvider.io) {
|
||||||
|
val user = db.usersQueries.selectUserByUserId(userId).executeAsOneOrNull()
|
||||||
|
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||||
|
if (user.password_hash != request.oldPasswordHash) {
|
||||||
|
throw BadRequestException(stringFetcher.get(StringResource.INVALID_PASSWORD))
|
||||||
|
} else if (user.password_hash == request.newPasswordHash) {
|
||||||
|
throw BadRequestException(stringFetcher.get(StringResource.NO_CHANGES_PASS))
|
||||||
|
}
|
||||||
|
db.usersQueries.updatePassword(request.newPasswordHash, userId).await()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toHash(): String {
|
||||||
|
val md = MessageDigest.getInstance("SHA-256")
|
||||||
|
md.update(SALT.toByteArray())
|
||||||
|
val result = md.digest(this.toByteArray())
|
||||||
|
return Base64.encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SALT = "G0yCPoy7gCj1a5OxPaeYLeJm69NvPQ50HbV0ZVYVqahUPdKun4MKgg86u9HbWq2e"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data.auth
|
||||||
|
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.backend.data.StringFetcher
|
||||||
|
import ru.shadowsparky.backend.data.StringResource
|
||||||
|
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||||
|
import ru.shadowsparky.backend.domain.VerifyTokenException
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.FLAG_USER_BLOCKED
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class BackendLoginVerifier(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val stringFetcher: StringFetcher
|
||||||
|
) : LoginVerifier {
|
||||||
|
override suspend fun verify(login: String) {
|
||||||
|
val user = db.usersQueries.selectUserByLogin(login)
|
||||||
|
.executeAsOneOrNull()
|
||||||
|
?: throw VerifyTokenException(stringFetcher.get(StringResource.UNKNOWN_USER))
|
||||||
|
user.flags?.let {
|
||||||
|
if ((it and FLAG_USER_BLOCKED) != 0) {
|
||||||
|
throw VerifyTokenException(stringFetcher.get(StringResource.USER_BLOCKED))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data.http
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.client.request.url
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.backend.data.EnvFetcher
|
||||||
|
import ru.shadowsparky.backend.data.StringFetcher
|
||||||
|
import ru.shadowsparky.backend.data.StringResource
|
||||||
|
import ru.shadowsparky.http.domain.NotFoundException
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.Season
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.VideoLinksResponse
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.VideosResponse
|
||||||
|
|
||||||
|
private const val FALLBACK_SEASON_ID = -1L
|
||||||
|
private const val SEASON_FALLBACK_NAME = "Сезон 1"
|
||||||
|
|
||||||
|
@Factory(binds = [ExternalBackendApi::class])
|
||||||
|
class ExternalBackendApi(
|
||||||
|
private val client: HttpClient,
|
||||||
|
private val mapper: ExternalBackendMapper,
|
||||||
|
private val cache: ExternalCache,
|
||||||
|
private val envFetcher: EnvFetcher,
|
||||||
|
private val stringFetcher: StringFetcher
|
||||||
|
) : VideoApi {
|
||||||
|
private val notFoundException =
|
||||||
|
NotFoundException(stringFetcher.get(StringResource.VIDEO_NOT_FOUND))
|
||||||
|
private val endpoint = envFetcher.get(BACKEND_HOST)
|
||||||
|
|
||||||
|
override suspend fun fetchNewVideos(query: String?): VideosResponse {
|
||||||
|
return coroutineScope {
|
||||||
|
val movies = async { fetchFavoritesInternal("movie", query) }
|
||||||
|
val series = async { fetchFavoritesInternal("series", query) }
|
||||||
|
val anime = async { fetchFavoritesInternal("anime", query) }
|
||||||
|
val result = (series.await() + movies.await() + anime.await())
|
||||||
|
result.map {
|
||||||
|
async { cache.put(it) }
|
||||||
|
}.awaitAll()
|
||||||
|
return@coroutineScope VideosResponse(false, result, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchFavoritesInternal(request: String, search: String?): List<VideoItem> {
|
||||||
|
val rsp = client.get {
|
||||||
|
url("https", endpoint, path = "${envFetcher.get(FAVORITES_PREFIX)}$request")
|
||||||
|
if (search != null) {
|
||||||
|
parameter("search", search)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val rspText = rsp.bodyAsText()
|
||||||
|
return mapper.movieRspToItems(rspText, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchDetails(id: Long): VideoDetails {
|
||||||
|
val info = cache.get(id) ?: throw notFoundException
|
||||||
|
return VideoDetails(
|
||||||
|
poster = mapper.mapPoster(info.poster),
|
||||||
|
id = info.id,
|
||||||
|
title = info.title,
|
||||||
|
desc = info.description ?: "",
|
||||||
|
isSerial = info.isSerial
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchVideoLinks(
|
||||||
|
id: Long,
|
||||||
|
seasonId: Long?
|
||||||
|
): VideoLinksResponse {
|
||||||
|
val isSerial = cache.get(id)?.isSerial ?: throw notFoundException
|
||||||
|
return if (isSerial) {
|
||||||
|
val seasons = mutableListOf<Season>()
|
||||||
|
if (seasonId == null) {
|
||||||
|
getSerialSeasonIds(id).forEach { seasons.add(Season(emptyList(), it.id, it.title)) }
|
||||||
|
if (seasons.isEmpty()) {
|
||||||
|
seasons.add(Season(emptyList(), FALLBACK_SEASON_ID, SEASON_FALLBACK_NAME))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (seasonId == FALLBACK_SEASON_ID) {
|
||||||
|
seasons.add(getSerialSeasonFallback(id))
|
||||||
|
} else {
|
||||||
|
val entry = getSerialSeasonIds(id).firstOrNull { it.id == seasonId }
|
||||||
|
?: throw notFoundException
|
||||||
|
seasons.add(getSeason(entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seasons.isEmpty()) throw notFoundException
|
||||||
|
VideoLinksResponse(seasons = seasons)
|
||||||
|
} else {
|
||||||
|
val rsp = client.get {
|
||||||
|
url("https", endpoint, path = envFetcher.get(LINKS_PATH))
|
||||||
|
parameter("id", id)
|
||||||
|
parameter("movie", "")
|
||||||
|
}.bodyAsText()
|
||||||
|
VideoLinksResponse(files = mapper.parseMovie(rsp) ?: throw notFoundException)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getSerialSeasonIds(id: Long): List<ExternalBackendMapper.SeasonEntry> {
|
||||||
|
val rsp = client.get {
|
||||||
|
url("https", endpoint, path = envFetcher.get(SEASON_IDS_PATH))
|
||||||
|
parameter("id", id)
|
||||||
|
}
|
||||||
|
val text = rsp.bodyAsText()
|
||||||
|
return mapper.categoryRspToSeasonInfo(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getSerialSeasonFallback(id: Long): Season {
|
||||||
|
val rsp = client.get {
|
||||||
|
url("https", endpoint, path = envFetcher.get(LINKS_PATH))
|
||||||
|
parameter("id", id)
|
||||||
|
}
|
||||||
|
val text = rsp.bodyAsText()
|
||||||
|
return mapper.parseSeason(
|
||||||
|
text,
|
||||||
|
ExternalBackendMapper.SeasonEntry(
|
||||||
|
SEASON_FALLBACK_NAME,
|
||||||
|
FALLBACK_SEASON_ID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getSeason(entry: ExternalBackendMapper.SeasonEntry): Season {
|
||||||
|
val rsp2 = client.get {
|
||||||
|
url("https", endpoint, path = envFetcher.get(SEASON_PATH))
|
||||||
|
parameter("id", entry.id)
|
||||||
|
}.bodyAsText()
|
||||||
|
return mapper.parseSeason(rsp2, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data.http
|
||||||
|
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.backend.data.EnvFetcher
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.Episode
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.File
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.Season
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class ExternalBackendMapper(
|
||||||
|
private val envFetcher: EnvFetcher
|
||||||
|
) {
|
||||||
|
private val readerFactory: () -> XmlReader = { XmlReader() }
|
||||||
|
|
||||||
|
fun movieRspToItems(rsp: String, rootTag: String): List<VideoItem> {
|
||||||
|
val reader = readerFactory().apply { setInput(rsp) }
|
||||||
|
return readRoot(reader, rootTag).toItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun categoryRspToSeasonInfo(rsp: String): List<SeasonEntry> {
|
||||||
|
val reader = readerFactory().apply { setInput(rsp) }
|
||||||
|
val movies = readRoot(reader, "category").unit
|
||||||
|
return movies.map { SeasonEntry(it.title!!, it.id.toLong()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSeason(rsp: String, seasonEntry: SeasonEntry): Season {
|
||||||
|
val reader = readerFactory().apply { setInput(rsp) }
|
||||||
|
val videos = readRoot(reader, "video").unit
|
||||||
|
val episodes = mutableListOf<Episode>()
|
||||||
|
videos.forEachIndexed { index, hlamerUnit ->
|
||||||
|
episodes.add(hlamerUnit.toEpisode(index + 1L))
|
||||||
|
}
|
||||||
|
return Season(episodes, seasonEntry.id, seasonEntry.title.parseHtml())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseMovie(rsp: String): List<File>? {
|
||||||
|
val reader = readerFactory().apply { setInput(rsp) }
|
||||||
|
val files = readRoot(reader, "video").unit.firstOrNull() ?: return null
|
||||||
|
return listOf(File(false, -1, files.file?.loadMp4FromSparky() ?: return null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ExternalUnit.toEpisode(epNumber: Long): Episode {
|
||||||
|
val hasTitle = title?.trim()?.isNotEmpty() == true
|
||||||
|
val splitTitle = title?.split(""")
|
||||||
|
?.lastOrNull { it.isNotEmpty() }
|
||||||
|
?: title?.removeSurrounding(""")
|
||||||
|
val episodeText = "Серия"
|
||||||
|
return Episode(
|
||||||
|
epNumber,
|
||||||
|
listOf(File(false, -1, file!!.loadMp4FromSparky())),
|
||||||
|
if (hasTitle) "$epNumber. $splitTitle" else "$episodeText $epNumber"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readRoot(reader: XmlReader, rootTag: String): Movie {
|
||||||
|
reader.require(XmlReader.Tag.START, rootTag)
|
||||||
|
val entries = mutableListOf<ExternalUnit>()
|
||||||
|
while (reader.next != XmlReader.Tag.END) {
|
||||||
|
if (reader.eventType != XmlReader.Tag.START) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (reader.name == "unit") {
|
||||||
|
val entry = readUnitEntry(reader)
|
||||||
|
entries.add(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Movie(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readUnitEntry(reader: XmlReader): ExternalUnit {
|
||||||
|
reader.require(XmlReader.Tag.START, "unit")
|
||||||
|
val id = reader.getAttributeValue("id")
|
||||||
|
var title: String? = null
|
||||||
|
var description: String? = null
|
||||||
|
var thumb: String? = null
|
||||||
|
var section: String? = null
|
||||||
|
var file: String? = null
|
||||||
|
var image: String? = null
|
||||||
|
while (reader.next != XmlReader.Tag.END) {
|
||||||
|
if (reader.eventType != XmlReader.Tag.START) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when (reader.name) {
|
||||||
|
"title" -> title = readText(reader)
|
||||||
|
"description" -> description = readText(reader)
|
||||||
|
"thumb" -> thumb = readText(reader)
|
||||||
|
"section" -> section = readText(reader)
|
||||||
|
"file" -> file = readText(reader)
|
||||||
|
"image" -> image = readText(reader)
|
||||||
|
else -> readText(reader) // skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ExternalUnit(id, title, description, thumb, section, file, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readText(reader: XmlReader): String {
|
||||||
|
var result = ""
|
||||||
|
if (reader.next == XmlReader.Tag.TEXT) {
|
||||||
|
result = reader.text
|
||||||
|
reader.nextTag()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Movie.toItems(): List<VideoItem> {
|
||||||
|
return unit.mapNotNull { it.toVideoItem() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ExternalUnit.toVideoItem(): VideoItem? {
|
||||||
|
val poster = thumb?.loadPosterFromSparky() ?: return null
|
||||||
|
if (poster.endsWith("/_180.jpg")) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val isMovie = section == "movie"
|
||||||
|
return VideoItem(
|
||||||
|
id = id.toLong(),
|
||||||
|
title = title?.parseHtml() ?: return null,
|
||||||
|
poster = poster,
|
||||||
|
description = description?.parseHtml(),
|
||||||
|
isSerial = !isMovie
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.loadMp4FromSparky(): String {
|
||||||
|
return mapExternalToBox(this) ?: this
|
||||||
|
}
|
||||||
|
|
||||||
|
private val hostRegex = Regex("""^m(\d+)\.${envFetcher.get(REGEX_HOST)}\.ru$""")
|
||||||
|
|
||||||
|
private fun mapExternalToBox(sourceUrl: String): String? {
|
||||||
|
val uri = try {
|
||||||
|
URI(sourceUrl)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val host = uri.host ?: return null
|
||||||
|
|
||||||
|
val m = hostRegex.matchEntire(host) ?: return null
|
||||||
|
val idx = m.groupValues[1]
|
||||||
|
|
||||||
|
val rawPath = uri.rawPath ?: return null
|
||||||
|
if (!rawPath.startsWith("/video")) return null
|
||||||
|
|
||||||
|
val rest = rawPath.removePrefix("/video")
|
||||||
|
val newPath = "/video/m$idx$rest"
|
||||||
|
|
||||||
|
return URI(
|
||||||
|
"https",
|
||||||
|
null,
|
||||||
|
"box.shadowsparky.ru",
|
||||||
|
uri.port,
|
||||||
|
newPath,
|
||||||
|
uri.rawQuery,
|
||||||
|
uri.rawFragment
|
||||||
|
).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapPoster(poster: String): String {
|
||||||
|
return poster.loadPosterFromSparky()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.loadPosterFromSparky(): String {
|
||||||
|
val imageEndpoint = envFetcher.get(IMAGE_ENDPOINT)
|
||||||
|
if (startsWith(imageEndpoint)) {
|
||||||
|
return replace(imageEndpoint, SPARKY_IMAGE_ENDPOINT)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SeasonEntry(
|
||||||
|
val title: String,
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class Movie(
|
||||||
|
val unit: List<ExternalUnit>
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ExternalUnit(
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val description: String?,
|
||||||
|
val thumb: String?,
|
||||||
|
val section: String?,
|
||||||
|
val file: String?,
|
||||||
|
val image: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun String.parseHtml(): String {
|
||||||
|
return Jsoup.parse(this@parseHtml).wholeText()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SPARKY_IMAGE_ENDPOINT = "https://box.shadowsparky.ru/images/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data.http
|
||||||
|
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.MOVIE_FLAG
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
|
||||||
|
|
||||||
|
@Single
|
||||||
|
class ExternalCache(
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
private val db: AppDatabase
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun put(item: VideoItem) {
|
||||||
|
insertIfNeeded(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun get(id: Long): VideoItem? = queryOrNull(id)
|
||||||
|
|
||||||
|
private suspend fun queryOrNull(id: Long): VideoItem? {
|
||||||
|
return withContext(dispatcherProvider.io) {
|
||||||
|
val oldInfo = db.movieQueries.selectMovie(id)
|
||||||
|
.executeAsOneOrNull()
|
||||||
|
?: return@withContext null
|
||||||
|
VideoItem(
|
||||||
|
oldInfo.movie_id,
|
||||||
|
oldInfo.poster_url,
|
||||||
|
oldInfo.title,
|
||||||
|
oldInfo.description,
|
||||||
|
oldInfo.flags == SERIAL_FLAG
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun insertIfNeeded(new: VideoItem) = withContext(dispatcherProvider.io) {
|
||||||
|
val old = queryOrNull(new.id)
|
||||||
|
if (old == null) {
|
||||||
|
db.movieQueries.insertMovie(
|
||||||
|
new.id,
|
||||||
|
new.poster,
|
||||||
|
new.description ?: "",
|
||||||
|
new.title,
|
||||||
|
new.serializeFlags()
|
||||||
|
)
|
||||||
|
} else if (old != new) {
|
||||||
|
db.movieQueries.updateMovie(
|
||||||
|
new.poster,
|
||||||
|
new.description ?: "",
|
||||||
|
new.title,
|
||||||
|
new.serializeFlags(),
|
||||||
|
new.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun VideoItem.serializeFlags(): Int {
|
||||||
|
return if (isSerial) {
|
||||||
|
SERIAL_FLAG
|
||||||
|
} else {
|
||||||
|
MOVIE_FLAG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data.http
|
||||||
|
|
||||||
|
const val REGEX_HOST = "EXTERNAL_HOST"
|
||||||
|
const val IMAGE_ENDPOINT = "EXTERNAL_IMAGE_ENDPOINT"
|
||||||
|
const val CACHE_DIR = "EXTERNAL_CACHE_DIR"
|
||||||
|
const val BACKEND_HOST = "EXTERNAL_BACKEND_HOST"
|
||||||
|
const val FAVORITES_PREFIX = "EXTERNAL_FAVORITES_PREFIX"
|
||||||
|
const val LINKS_PATH = "EXTERNAL_LINKS_PATH"
|
||||||
|
const val SEASON_IDS_PATH = "EXTERNAL_SEASON_IDS_PATH"
|
||||||
|
const val SEASON_PATH = "EXTERNAL_SEASON_PATH"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data.http
|
||||||
|
|
||||||
|
import org.kxml2.io.KXmlParser
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.io.StringReader
|
||||||
|
|
||||||
|
class XmlReader {
|
||||||
|
private var parser: XmlPullParser? = null
|
||||||
|
|
||||||
|
val name: String
|
||||||
|
get() = parser!!.name
|
||||||
|
val eventType: Tag
|
||||||
|
get() = parser!!.eventType.fromType()
|
||||||
|
val next: Tag
|
||||||
|
get() = parser!!.next().fromType()
|
||||||
|
|
||||||
|
fun setInput(raw: String) {
|
||||||
|
parser = KXmlParser().apply {
|
||||||
|
setInput(StringReader(raw))
|
||||||
|
nextTag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun require(
|
||||||
|
tag: Tag,
|
||||||
|
name: String
|
||||||
|
) {
|
||||||
|
parser?.require(tag.toType(), null, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Tag.toType(): Int {
|
||||||
|
return when (this) {
|
||||||
|
Tag.START -> XmlPullParser.START_TAG
|
||||||
|
Tag.END -> XmlPullParser.END_TAG
|
||||||
|
Tag.TEXT -> XmlPullParser.TEXT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Int.fromType(): Tag {
|
||||||
|
return when (this) {
|
||||||
|
XmlPullParser.START_TAG -> Tag.START
|
||||||
|
XmlPullParser.END_TAG -> Tag.END
|
||||||
|
else -> Tag.TEXT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAttributeValue(name: String): String {
|
||||||
|
return parser!!.getAttributeValue(null, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextTag() {
|
||||||
|
parser!!.nextTag()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Tag {
|
||||||
|
START, END, TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
val text: String
|
||||||
|
get() = parser!!.text
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data.tags
|
||||||
|
|
||||||
|
import app.cash.sqldelight.coroutines.asFlow
|
||||||
|
import app.cash.sqldelight.coroutines.mapToList
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import org.koin.core.annotation.Named
|
||||||
|
import ru.shadowsparky.domain.DispatcherProvider
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class MovieTagRepositoryFactory(
|
||||||
|
private val appDatabase: AppDatabase,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
@Named(EventType.MOVIE_TAG) private val movieEventHandler: RemoteEventHandler
|
||||||
|
) {
|
||||||
|
fun create(userId: Long): MovieTagRepository {
|
||||||
|
return BackendMovieTagRepository(
|
||||||
|
appDatabase,
|
||||||
|
dispatcherProvider,
|
||||||
|
userId,
|
||||||
|
movieEventHandler
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackendMovieTagRepository(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
private val userId: Long,
|
||||||
|
private val movieEventHandler: RemoteEventHandler
|
||||||
|
) : MovieTagRepository {
|
||||||
|
private val dbQueries get() = db.tagsQueries
|
||||||
|
|
||||||
|
override fun query(movieId: Long): Flow<List<TagInfo>> = dbQueries
|
||||||
|
.getTagsForMovie(movieId, userId)
|
||||||
|
.asFlow()
|
||||||
|
.mapToList(dispatcherProvider.io)
|
||||||
|
.map { list ->
|
||||||
|
list.map { row -> TagInfo(id = row.id, tag = row.tag) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryMovies(tagId: Long): Flow<Set<Long>> = dbQueries
|
||||||
|
.getMoviesByTagId(tagId, userId)
|
||||||
|
.asFlow()
|
||||||
|
.mapToList(dispatcherProvider.io)
|
||||||
|
.map { list -> list.toSet() }
|
||||||
|
|
||||||
|
override suspend fun link(movieId: Long, tagId: Long) {
|
||||||
|
dbQueries.linkMovieTag(movieId, tagId, userId).await()
|
||||||
|
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unlink(movieId: Long, tagId: Long) {
|
||||||
|
dbQueries.unlinkMovieTag(movieId, tagId, userId).await()
|
||||||
|
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.data.tags
|
||||||
|
|
||||||
|
import app.cash.sqldelight.coroutines.asFlow
|
||||||
|
import app.cash.sqldelight.coroutines.mapToList
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import org.koin.core.annotation.Named
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class UserTagRepositoryFactory(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
@Named(EventType.USER_TAG) private val userTagEventHandler: RemoteEventHandler
|
||||||
|
) {
|
||||||
|
fun create(userId: Long): UserTagRepository {
|
||||||
|
return BackendUserTagRepository(
|
||||||
|
userId,
|
||||||
|
db,
|
||||||
|
dispatcherProvider,
|
||||||
|
userTagEventHandler
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackendUserTagRepository(
|
||||||
|
private val userId: Long,
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
private val userTagEventHandler: RemoteEventHandler
|
||||||
|
) : UserTagRepository {
|
||||||
|
private val queries get() = db.tagsQueries
|
||||||
|
|
||||||
|
override val userTags: Flow<List<TagInfo>> by lazy {
|
||||||
|
queries.getUserTagsByUserId(user_id = userId)
|
||||||
|
.asFlow()
|
||||||
|
.mapToList(dispatcherProvider.io)
|
||||||
|
.map { list ->
|
||||||
|
list.map { row -> TagInfo(id = row.id, tag = row.tag) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun add(tag: String) {
|
||||||
|
queries.insertUserTag(userId, tag).await()
|
||||||
|
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun edit(id: Long, newTag: String) {
|
||||||
|
checkOwner(id)
|
||||||
|
queries.updateTagText(newTag, id).await()
|
||||||
|
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: Long) {
|
||||||
|
checkOwner(id)
|
||||||
|
queries.deleteTagById(id).await()
|
||||||
|
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkOwner(id: Long) = withContext(dispatcherProvider.io) {
|
||||||
|
val ownerId = queries.getUserIdByTagId(id).executeAsOneOrNull()
|
||||||
|
check(ownerId == userId) { "tag owner mismatch. owner: $ownerId, current user: $userId" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.di
|
||||||
|
|
||||||
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
|
import app.cash.sqldelight.driver.jdbc.asJdbcDriver
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import org.koin.core.annotation.Module
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
import org.postgresql.ds.PGSimpleDataSource
|
||||||
|
import ru.shadowsparky.backend.data.EnvFetcher
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
|
||||||
|
@Module
|
||||||
|
class DbModule {
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
fun provideSqlDriver(envFetcher: EnvFetcher): SqlDriver {
|
||||||
|
return PGSimpleDataSource().apply {
|
||||||
|
val db = envFetcher.get("POSTGRES_DB", "videobox")
|
||||||
|
val uri = envFetcher.get("VIDEOBOX_SERVER_URI", "localhost")
|
||||||
|
val login = envFetcher.get("POSTGRES_USER", "login")
|
||||||
|
val pass = envFetcher.get("POSTGRES_PASSWORD", "password")
|
||||||
|
|
||||||
|
setUrl("jdbc:postgresql://$uri:5432/$db")
|
||||||
|
user = login
|
||||||
|
password = pass
|
||||||
|
}.asJdbcDriver()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Single
|
||||||
|
fun provideDb(driver: SqlDriver): AppDatabase {
|
||||||
|
return AppDatabase.Companion.invoke(driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.di
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.core.annotation.Named
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
import ru.shadowsparky.backend.data.TokenVerifier
|
||||||
|
import ru.shadowsparky.backend.domain.JwtInfo
|
||||||
|
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||||
|
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||||
|
import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory
|
||||||
|
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
|
||||||
|
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
|
||||||
|
import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory
|
||||||
|
import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory
|
||||||
|
import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||||
|
|
||||||
|
@Single
|
||||||
|
class RoutingEntryPoint(
|
||||||
|
val videoApi: VideoApi,
|
||||||
|
val searchFactory: SearchRepositoryFactory,
|
||||||
|
val recentlyFactory: RecentlyWatchedRepositoryFactory,
|
||||||
|
val savedMovieFactory: SavedMovieRepositoryFactory,
|
||||||
|
val json: Json,
|
||||||
|
val movieTagFactory: MovieTagRepositoryFactory,
|
||||||
|
val userTagFactory: UserTagRepositoryFactory
|
||||||
|
)
|
||||||
|
|
||||||
|
@Single
|
||||||
|
class WebSocketEntryPoint(
|
||||||
|
@Named(EventType.SEARCH) val searchEventHandler: BackendRemoteEventHandler,
|
||||||
|
@Named(EventType.RECENT) val recentlyEventHandler: BackendRemoteEventHandler,
|
||||||
|
@Named(EventType.SAVED) val savedEventHandler: BackendRemoteEventHandler,
|
||||||
|
@Named(EventType.USER_TAG) val userTagEventHandler: BackendRemoteEventHandler,
|
||||||
|
@Named(EventType.MOVIE_TAG) val movieTagEventHandler: BackendRemoteEventHandler,
|
||||||
|
val tokenVerifier: TokenVerifier
|
||||||
|
)
|
||||||
|
|
||||||
|
@Single
|
||||||
|
class AuthEntryPoint(
|
||||||
|
val loginVerifier: LoginVerifier,
|
||||||
|
val authTokenRepositoryFactory: AuthTokenRepositoryFactory,
|
||||||
|
val jwtInfo: JwtInfo,
|
||||||
|
val tokenVerifier: TokenVerifier
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.di
|
||||||
|
|
||||||
|
import org.koin.core.annotation.Module
|
||||||
|
import org.koin.core.annotation.Named
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||||
|
|
||||||
|
@Module
|
||||||
|
class EventModule {
|
||||||
|
|
||||||
|
@Single(binds = [BackendRemoteEventHandler::class])
|
||||||
|
@Named(EventType.SEARCH)
|
||||||
|
fun provideSearch(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||||
|
|
||||||
|
@Single(binds = [BackendRemoteEventHandler::class])
|
||||||
|
@Named(EventType.RECENT)
|
||||||
|
fun provideRecent(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||||
|
|
||||||
|
@Single(binds = [BackendRemoteEventHandler::class])
|
||||||
|
@Named(EventType.SAVED)
|
||||||
|
fun provideSaved(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||||
|
|
||||||
|
@Single(binds = [BackendRemoteEventHandler::class])
|
||||||
|
@Named(EventType.USER_TAG)
|
||||||
|
fun provideUserTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||||
|
|
||||||
|
@Single(binds = [BackendRemoteEventHandler::class])
|
||||||
|
@Named(EventType.MOVIE_TAG)
|
||||||
|
fun provideMovieTag(impl: BackendRemoteEventHandler): RemoteEventHandler = impl
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.di
|
||||||
|
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import org.koin.core.annotation.Module
|
||||||
|
import ru.shadowsparky.vbox.backend.data.http.ExternalBackendApi
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||||
|
|
||||||
|
@Module
|
||||||
|
class HttpModule {
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
fun provideVideoApi(impl: ExternalBackendApi): VideoApi = impl
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.di.factory
|
||||||
|
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import org.koin.core.annotation.Named
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.backend.data.BackendRecentlyWatchedRepository
|
||||||
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class RecentlyWatchedRepositoryFactory(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
@Named(EventType.RECENT) private val remoteEventHandler: RemoteEventHandler
|
||||||
|
) {
|
||||||
|
fun create(userId: Long): BackendRecentlyWatchedRepository {
|
||||||
|
return BackendRecentlyWatchedRepository(db, userId, dispatcherProvider, remoteEventHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.di.factory
|
||||||
|
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import org.koin.core.annotation.Named
|
||||||
|
import ru.shadowsparky.backend.data.Logger
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||||
|
import ru.shadowsparky.vbox.backend.data.BackendSavedMovieRepository
|
||||||
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class SavedMovieRepositoryFactory(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
private val logger: Logger,
|
||||||
|
@Named(EventType.SAVED) private val eventHandler: BackendRemoteEventHandler
|
||||||
|
) {
|
||||||
|
fun create(userId: Long): SavedMovieRepository {
|
||||||
|
return BackendSavedMovieRepository(db, userId, dispatcherProvider, logger, eventHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package ru.shadowsparky.vbox.backend.di.factory
|
||||||
|
|
||||||
|
import org.koin.core.annotation.Factory
|
||||||
|
import org.koin.core.annotation.Named
|
||||||
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
|
import ru.shadowsparky.vbox.backend.data.BackendSearchRepository
|
||||||
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.EventType
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||||
|
|
||||||
|
@Factory
|
||||||
|
class SearchRepositoryFactory(
|
||||||
|
private val db: AppDatabase,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
@Named(EventType.SEARCH) private val eventHandler: RemoteEventHandler
|
||||||
|
) {
|
||||||
|
fun create(userId: Long): SearchRepository {
|
||||||
|
return BackendSearchRepository(db, userId, dispatcherProvider, eventHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||