From 826d65edce2f38d5534a87579ca3ee22aa4a8770 Mon Sep 17 00:00:00 2001 From: SaeedDev94 Date: Thu, 16 Jan 2025 12:32:14 -0700 Subject: [PATCH] Replace thread with CoroutineScope --- .../java/io/github/saeeddev94/xray/Xray.kt | 2 + .../xray/activity/AssetsActivity.kt | 54 ++++---- .../xray/activity/ExcludeActivity.kt | 10 +- .../saeeddev94/xray/activity/LinksActivity.kt | 130 +++++++++++++++++- .../saeeddev94/xray/activity/MainActivity.kt | 95 +++++++------ .../xray/activity/ProfileActivity.kt | 40 +++--- .../saeeddev94/xray/adapter/ProfileAdapter.kt | 25 ++-- .../saeeddev94/xray/database/ProfileDao.kt | 59 +++++--- .../saeeddev94/xray/helper/ConfigHelper.kt | 20 +++ .../saeeddev94/xray/helper/DownloadHelper.kt | 85 +++++++----- .../saeeddev94/xray/helper/HttpHelper.kt | 88 +++++++----- .../saeeddev94/xray/helper/LinkHelper.kt | 22 ++- .../xray/repository/ProfileRepository.kt | 56 ++++++++ .../saeeddev94/xray/service/TProxyService.kt | 16 ++- .../xray/viewmodel/ProfileViewModel.kt | 60 ++++++++ 15 files changed, 561 insertions(+), 201 deletions(-) create mode 100644 app/src/main/java/io/github/saeeddev94/xray/helper/ConfigHelper.kt create mode 100644 app/src/main/java/io/github/saeeddev94/xray/repository/ProfileRepository.kt create mode 100644 app/src/main/java/io/github/saeeddev94/xray/viewmodel/ProfileViewModel.kt diff --git a/app/src/main/java/io/github/saeeddev94/xray/Xray.kt b/app/src/main/java/io/github/saeeddev94/xray/Xray.kt index 19cce7e..b8693da 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/Xray.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/Xray.kt @@ -3,9 +3,11 @@ package io.github.saeeddev94.xray import android.app.Application import io.github.saeeddev94.xray.database.XrayDatabase import io.github.saeeddev94.xray.repository.LinkRepository +import io.github.saeeddev94.xray.repository.ProfileRepository class Xray : Application() { private val xrayDatabase by lazy { XrayDatabase.ref(this) } val linkRepository by lazy { LinkRepository(xrayDatabase.linkDao()) } + val profileRepository by lazy { ProfileRepository(xrayDatabase.profileDao()) } } diff --git a/app/src/main/java/io/github/saeeddev94/xray/activity/AssetsActivity.kt b/app/src/main/java/io/github/saeeddev94/xray/activity/AssetsActivity.kt index 2c25447..6c16273 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/activity/AssetsActivity.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/activity/AssetsActivity.kt @@ -9,10 +9,14 @@ import android.widget.ProgressBar import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.databinding.ActivityAssetsBinding import io.github.saeeddev94.xray.helper.DownloadHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.lang.Exception @@ -92,47 +96,45 @@ class AssetsActivity : AppCompatActivity() { progressBar.progress = 0 downloading = true - Thread { - DownloadHelper(url, file, object : DownloadHelper.DownloadListener { - override fun onProgress(progress: Int) { - runOnUiThread { progressBar.progress = progress } - } + DownloadHelper(lifecycleScope, url, file, object : DownloadHelper.DownloadListener { + override fun onProgress(progress: Int) { + progressBar.progress = progress + } - override fun onError(exception: Exception) { - runOnUiThread { - downloading = false - Toast.makeText(applicationContext, exception.message, Toast.LENGTH_SHORT).show() - setAssetStatus() - } - } + override fun onError(exception: Exception) { + downloading = false + Toast.makeText(applicationContext, exception.message, Toast.LENGTH_SHORT).show() + setAssetStatus() + } - override fun onComplete() { - runOnUiThread { - downloading = false - setAssetStatus() - } - } - }).start() - }.start() + override fun onComplete() { + downloading = false + setAssetStatus() + } + }).start() } private fun writeToFile(uri: Uri?, file: File) { if (uri == null) return - Thread { + lifecycleScope.launch { contentResolver.openInputStream(uri).use { input -> FileOutputStream(file).use { output -> input?.copyTo(output) } } - runOnUiThread { setAssetStatus() } - }.start() + withContext(Dispatchers.Main) { + setAssetStatus() + } + } } private fun delete(file: File) { - Thread { + lifecycleScope.launch { file.delete() - runOnUiThread { setAssetStatus() } - }.start() + withContext(Dispatchers.Main) { + setAssetStatus() + } + } } } diff --git a/app/src/main/java/io/github/saeeddev94/xray/activity/ExcludeActivity.kt b/app/src/main/java/io/github/saeeddev94/xray/activity/ExcludeActivity.kt index 438869c..9132edf 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/activity/ExcludeActivity.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/activity/ExcludeActivity.kt @@ -10,6 +10,7 @@ import android.view.View import android.widget.ImageView import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.github.saeeddev94.xray.R @@ -17,6 +18,9 @@ import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.adapter.ExcludeAdapter import io.github.saeeddev94.xray.databinding.ActivityExcludeBinding import io.github.saeeddev94.xray.dto.AppList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ExcludeActivity : AppCompatActivity() { @@ -93,7 +97,7 @@ class ExcludeActivity : AppCompatActivity() { } private fun getApps() { - Thread { + lifecycleScope.launch { val selected = ArrayList() val unselected = ArrayList() packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS).forEach { @@ -106,7 +110,7 @@ class ExcludeActivity : AppCompatActivity() { val isSelected = Settings.excludedApps.contains(packageName) if (isSelected) selected.add(app) else unselected.add(app) } - runOnUiThread { + withContext(Dispatchers.Main) { apps = ArrayList(selected + unselected) filtered = apps.toMutableList() excludedApps = Settings.excludedApps.split("\n").toMutableSet() @@ -115,7 +119,7 @@ class ExcludeActivity : AppCompatActivity() { appsList.adapter = excludeAdapter appsList.layoutManager = LinearLayoutManager(applicationContext) } - }.start() + } } private fun saveExcludedApps() { diff --git a/app/src/main/java/io/github/saeeddev94/xray/activity/LinksActivity.kt b/app/src/main/java/io/github/saeeddev94/xray/activity/LinksActivity.kt index 21a8734..0ec2507 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/activity/LinksActivity.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/activity/LinksActivity.kt @@ -1,6 +1,7 @@ package io.github.saeeddev94.xray.activity import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem @@ -8,6 +9,7 @@ import android.widget.EditText import android.widget.LinearLayout import android.widget.RadioButton import android.widget.RadioGroup +import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Lifecycle @@ -20,19 +22,28 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.radiobutton.MaterialRadioButton import io.github.saeeddev94.xray.R +import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.adapter.LinkAdapter import io.github.saeeddev94.xray.database.Link +import io.github.saeeddev94.xray.database.Profile import io.github.saeeddev94.xray.databinding.ActivityLinksBinding +import io.github.saeeddev94.xray.helper.ConfigHelper +import io.github.saeeddev94.xray.helper.HttpHelper +import io.github.saeeddev94.xray.helper.LinkHelper import io.github.saeeddev94.xray.viewmodel.LinkViewModel +import io.github.saeeddev94.xray.viewmodel.ProfileViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.URI import kotlin.reflect.cast class LinksActivity : AppCompatActivity() { private val linkViewModel: LinkViewModel by viewModels() + private val profileViewModel: ProfileViewModel by viewModels() private val adapter by lazy { LinkAdapter() } private val linksRecyclerView by lazy { findViewById(R.id.linksRecyclerView) } private var links: List = listOf() @@ -52,10 +63,8 @@ class LinksActivity : AppCompatActivity() { lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { linkViewModel.links.collectLatest { - withContext(Dispatchers.Main) { - links = it - adapter.submitList(it) - } + links = it + adapter.submitList(it) } } } @@ -76,6 +85,112 @@ class LinksActivity : AppCompatActivity() { } private fun refreshLinks() { + Toast.makeText(applicationContext, "Getting update", Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + val profiles = profileViewModel.activeLinks() + links.filter { it.isActive }.forEach { link -> + runCatching { + val content = HttpHelper.get(link.address).trim() + val newProfiles = if (link.type == Link.Type.Json) { + jsonProfile(link, content) + } else { + subscriptionProfiles(link, content) + } + if (newProfiles.isNotEmpty()) { + val linkProfiles = profiles.filter { it.linkId == link.id } + manageProfiles(link, linkProfiles, newProfiles) + } + } + } + withContext(Dispatchers.Main) { + setResult(RESULT_OK) + Toast.makeText(applicationContext, "Done", Toast.LENGTH_SHORT).show() + } + } + } + + private suspend fun jsonProfile(link: Link, value: String): List { + val list = arrayListOf() + runCatching { + val error = ConfigHelper.isValid(applicationContext, value) + if (error.isEmpty()) { + val name = LinkHelper.remark(URI(link.address)) + val config = JSONObject(value).toString(2) + val profile = Profile() + profile.linkId = link.id + profile.name = name + profile.config = config + list.add(profile) + } + } + return list.toList() + } + + private suspend fun subscriptionProfiles(link: Link, value: String): List { + return runCatching { + val decoded = LinkHelper.decodeBase64(value).trim() + decoded.split("\n") + .reversed() + .map { LinkHelper(it) } + .filter { it.isValid() } + .map { linkHelper -> + val profile = Profile() + profile.linkId = link.id + profile.config = linkHelper.json() + profile.name = linkHelper.remark() + profile + }.filter { + val error = ConfigHelper.isValid(applicationContext, it.config) + error.isEmpty() + } + }.getOrNull() ?: listOf() + } + + private suspend fun manageProfiles(link: Link, linkProfiles: List, newProfiles: List) { + if (newProfiles.size >= linkProfiles.size) { + newProfiles.forEachIndexed { index, newProfile -> + if (index >= linkProfiles.size) { + newProfile.linkId = link.id + insertProfile(newProfile) + } else { + val linkProfile = linkProfiles[index] + updateProfile(linkProfile, newProfile) + } + } + return + } + linkProfiles.forEachIndexed { index, linkProfile -> + if (index >= newProfiles.size) { + deleteProfile(linkProfile) + } else { + val newProfile = newProfiles[index] + updateProfile(linkProfile, newProfile) + } + } + } + + private suspend fun insertProfile(newProfile: Profile) { + profileViewModel.insert(newProfile) + profileViewModel.fixInsertIndex() + } + + private suspend fun updateProfile(linkProfile: Profile, newProfile: Profile) { + Log.e("INJA", "updateProfile: ${linkProfile.name}, ${newProfile.name}") + linkProfile.name = newProfile.name + linkProfile.config = newProfile.config + profileViewModel.update(linkProfile) + } + + private suspend fun deleteProfile(linkProfile: Profile) { + profileViewModel.delete(linkProfile) + profileViewModel.fixDeleteIndex(linkProfile.index) + withContext(Dispatchers.Main) { + val selectedProfile = Settings.selectedProfile + if (selectedProfile == linkProfile.id) { + Settings.selectedProfile = 0L + Settings.save(applicationContext) + } + } } private fun openLink(index: Int = -1, link: Link = Link()) { @@ -137,7 +252,14 @@ class LinksActivity : AppCompatActivity() { private fun deleteLink(link: Link) { lifecycleScope.launch { + profileViewModel.linkProfiles(link.id) + .forEach { linkProfile -> + deleteProfile(linkProfile) + } linkViewModel.delete(link) + withContext(Dispatchers.Main) { + setResult(RESULT_OK) + } } } } diff --git a/app/src/main/java/io/github/saeeddev94/xray/activity/MainActivity.kt b/app/src/main/java/io/github/saeeddev94/xray/activity/MainActivity.kt index eaa1ddf..5304879 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/activity/MainActivity.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/activity/MainActivity.kt @@ -22,12 +22,14 @@ import android.view.MenuItem import android.widget.LinearLayout import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.viewModels import androidx.annotation.RequiresApi import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.GravityCompat +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -37,13 +39,16 @@ import io.github.saeeddev94.xray.BuildConfig import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.adapter.ProfileAdapter -import io.github.saeeddev94.xray.database.XrayDatabase import io.github.saeeddev94.xray.databinding.ActivityMainBinding import io.github.saeeddev94.xray.dto.ProfileList import io.github.saeeddev94.xray.helper.HttpHelper import io.github.saeeddev94.xray.helper.LinkHelper import io.github.saeeddev94.xray.helper.ProfileTouchHelper import io.github.saeeddev94.xray.service.TProxyService +import io.github.saeeddev94.xray.viewmodel.ProfileViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.json.JSONException import org.json.JSONObject import java.net.URI @@ -51,6 +56,7 @@ import java.net.URISyntaxException class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { + private val profileViewModel: ProfileViewModel by viewModels() private lateinit var binding: ActivityMainBinding private lateinit var vpnService: TProxyService private var vpnLauncher = registerForActivityResult(StartActivityForResult()) { @@ -66,6 +72,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val id = it.data!!.getLongExtra("id", 0L) onProfileActivityResult(id, index) } + private val linkLauncher = registerForActivityResult(StartActivityForResult()) { + if (it.resultCode != RESULT_OK) return@registerForActivityResult + getProfiles(dataOnly = true) + } private var vpnServiceBound: Boolean = false private var serviceConnection = object : ServiceConnection { @@ -161,7 +171,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte override fun onNavigationItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.assets -> Intent(applicationContext, AssetsActivity::class.java) - R.id.links -> Intent(applicationContext, LinksActivity::class.java) R.id.logs -> Intent(applicationContext, LogsActivity::class.java) R.id.excludedApps -> Intent(applicationContext, ExcludeActivity::class.java) R.id.settings -> Intent(applicationContext, SettingsActivity::class.java) @@ -169,6 +178,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte }.also { if (it != null) startActivity(it) } + if (item.itemId == R.id.links) { + linkLauncher.launch(Intent(applicationContext, LinksActivity::class.java)) + } binding.drawerLayout.closeDrawer(GravityCompat.START) return true } @@ -221,15 +233,15 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private fun profileSelect(index: Int, profile: ProfileList) { if (vpnService.getIsRunning()) return val selectedProfile = Settings.selectedProfile - Thread { - val ref = if (selectedProfile > 0L) XrayDatabase.ref(applicationContext).profileDao().find(selectedProfile) else null - runOnUiThread { + lifecycleScope.launch { + val ref = if (selectedProfile > 0L) profileViewModel.find(selectedProfile) else null + withContext(Dispatchers.Main) { Settings.selectedProfile = if (selectedProfile == profile.id) 0L else profile.id Settings.save(applicationContext) profileAdapter.notifyItemChanged(index) if (ref != null && ref.index != index) profileAdapter.notifyItemChanged(ref.index) } - }.start() + } } private fun profileEdit(index: Int, profile: ProfileList) { @@ -239,20 +251,19 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private fun profileDelete(index: Int, profile: ProfileList) { if (vpnService.getIsRunning() && Settings.selectedProfile == profile.id) return - val selectedProfile = Settings.selectedProfile MaterialAlertDialogBuilder(this) .setTitle("Delete Profile#${profile.index + 1} ?") .setMessage("\"${profile.name}\" will delete forever !!") .setNegativeButton("No", null) .setPositiveButton("Yes") { dialog, _ -> dialog?.dismiss() - Thread { - val db = XrayDatabase.ref(applicationContext) - val ref = db.profileDao().find(profile.id) + lifecycleScope.launch { + val ref = profileViewModel.find(profile.id) val id = ref.id - db.profileDao().delete(ref) - db.profileDao().fixDeleteIndex(index) - runOnUiThread { + profileViewModel.delete(ref) + profileViewModel.fixDeleteIndex(index) + withContext(Dispatchers.Main) { + val selectedProfile = Settings.selectedProfile if (selectedProfile == id) { Settings.selectedProfile = 0L Settings.save(applicationContext) @@ -261,7 +272,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte profileAdapter.notifyItemRemoved(index) profileAdapter.notifyItemRangeChanged(index, profiles.size - index) } - }.start() + } }.show() } @@ -276,31 +287,38 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private fun onProfileActivityResult(id: Long, index: Int) { if (index == -1) { - Thread { - val newProfile = XrayDatabase.ref(applicationContext).profileDao().find(id) - runOnUiThread { + lifecycleScope.launch { + val newProfile = profileViewModel.find(id) + withContext(Dispatchers.Main) { profiles.add(0, ProfileList.fromProfile(newProfile)) profileAdapter.notifyItemRangeChanged(0, profiles.size) } - }.start() + } return } - Thread { - val profile = XrayDatabase.ref(applicationContext).profileDao().find(id) - runOnUiThread { + lifecycleScope.launch { + val profile = profileViewModel.find(id) + withContext(Dispatchers.Main) { profiles[index] = ProfileList.fromProfile(profile) profileAdapter.notifyItemChanged(index) } - }.start() + } } - private fun getProfiles() { - Thread { - val list = XrayDatabase.ref(applicationContext).profileDao().all() - runOnUiThread { + private fun getProfiles(dataOnly: Boolean = false) { + lifecycleScope.launch { + val list = profileViewModel.all() + withContext(Dispatchers.Main) { + if (dataOnly) { + profiles.clear() + profiles.addAll(ArrayList(list)) + @Suppress("NotifyDataSetChanged") + profileAdapter.notifyDataSetChanged() + return@withContext + } profiles = ArrayList(list) profilesList = binding.profilesList - profileAdapter = ProfileAdapter(applicationContext, profiles, object : ProfileAdapter.ProfileClickListener { + profileAdapter = ProfileAdapter(lifecycleScope, profileViewModel, profiles, object : ProfileAdapter.ProfileClickListener { override fun profileSelect(index: Int, profile: ProfileList) = this@MainActivity.profileSelect(index, profile) override fun profileEdit(index: Int, profile: ProfileList) = this@MainActivity.profileEdit(index, profile) override fun profileDelete(index: Int, profile: ProfileList) = this@MainActivity.profileDelete(index, profile) @@ -309,7 +327,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte profilesList.layoutManager = LinearLayoutManager(applicationContext) ItemTouchHelper(ProfileTouchHelper(profileAdapter)).also { it.attachToRecyclerView(profilesList) } } - }.start() + } } private fun processLink(link: String) { @@ -331,8 +349,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte Toast.makeText(applicationContext, "Invalid Link", Toast.LENGTH_SHORT).show() return } - val name = LinkHelper.remark(uri) val json = linkHelper.json() + val name = linkHelper.remark() profileLauncher.launch(profileIntent(name = name, config = json)) } @@ -343,10 +361,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte .setCancelable(false) .create() dialog.show() - Thread { + lifecycleScope.launch { try { - val config = HttpHelper().get(uri.toString()) - runOnUiThread { + val config = HttpHelper.get(uri.toString()) + withContext(Dispatchers.Main) { dialog.dismiss() try { val name = LinkHelper.remark(uri) @@ -357,23 +375,20 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } } } catch (error: Exception) { - runOnUiThread { + withContext(Dispatchers.Main) { dialog.dismiss() Toast.makeText(applicationContext, error.message, Toast.LENGTH_SHORT).show() } } - }.start() + } } private fun ping() { if (!vpnService.getIsRunning()) return binding.pingResult.text = getString(R.string.pingTesting) - Thread { - val delay = HttpHelper().measureDelay() - runOnUiThread { - binding.pingResult.text = delay - } - }.start() + HttpHelper(lifecycleScope).measureDelay { delay -> + binding.pingResult.text = delay + } } private fun hasPostNotification(): Boolean { diff --git a/app/src/main/java/io/github/saeeddev94/xray/activity/ProfileActivity.kt b/app/src/main/java/io/github/saeeddev94/xray/activity/ProfileActivity.kt index 7a5d3d5..73bcd32 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/activity/ProfileActivity.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/activity/ProfileActivity.kt @@ -5,25 +5,29 @@ import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import io.github.saeeddev94.xray.R -import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.database.Profile -import io.github.saeeddev94.xray.database.XrayDatabase import io.github.saeeddev94.xray.databinding.ActivityProfileBinding -import io.github.saeeddev94.xray.helper.FileHelper -import XrayCore.XrayCore import android.net.Uri import android.view.Menu import android.view.MenuItem +import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope import com.blacksquircle.ui.editorkit.plugin.autoindent.autoIndentation import com.blacksquircle.ui.editorkit.plugin.base.PluginSupplier import com.blacksquircle.ui.editorkit.plugin.delimiters.highlightDelimiters import com.blacksquircle.ui.editorkit.plugin.linenumbers.lineNumbers import com.blacksquircle.ui.language.json.JsonLanguage +import io.github.saeeddev94.xray.helper.ConfigHelper +import io.github.saeeddev94.xray.viewmodel.ProfileViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.InputStreamReader class ProfileActivity : AppCompatActivity() { + private val profileViewModel: ProfileViewModel by viewModels() private lateinit var binding: ActivityProfileBinding private lateinit var profile: Profile private var id: Long = 0L @@ -49,12 +53,12 @@ class ProfileActivity : AppCompatActivity() { profile.config = intent.getStringExtra("config") ?: "" resolved(profile) } else { - Thread { - val profile = XrayDatabase.ref(applicationContext).profileDao().find(id) - runOnUiThread { + lifecycleScope.launch { + val profile = profileViewModel.find(id) + withContext(Dispatchers.Main) { resolved(profile) } - }.start() + } } } @@ -110,23 +114,21 @@ class ProfileActivity : AppCompatActivity() { private fun save() { profile.name = binding.profileName.text.toString() profile.config = binding.profileConfig.text.toString() - Thread { - FileHelper().createOrUpdate(Settings.testConfig(applicationContext), profile.config) - val error = XrayCore.test(applicationContext.filesDir.absolutePath, Settings.testConfig(applicationContext).absolutePath) + lifecycleScope.launch { + val error = ConfigHelper.isValid(applicationContext, profile.config) if (error.isNotEmpty()) { - runOnUiThread { + withContext(Dispatchers.Main) { Toast.makeText(applicationContext, error, Toast.LENGTH_SHORT).show() } - return@Thread + return@launch } - val db = XrayDatabase.ref(applicationContext) if (profile.id == 0L) { - profile.id = db.profileDao().insert(profile) - db.profileDao().fixInsertIndex() + profile.id = profileViewModel.insert(profile) + profileViewModel.fixInsertIndex() } else { - db.profileDao().update(profile) + profileViewModel.update(profile) } - runOnUiThread { + withContext(Dispatchers.Main) { Intent().also { it.putExtra("id", profile.id) it.putExtra("index", index) @@ -134,7 +136,7 @@ class ProfileActivity : AppCompatActivity() { finish() } } - }.start() + } } } diff --git a/app/src/main/java/io/github/saeeddev94/xray/adapter/ProfileAdapter.kt b/app/src/main/java/io/github/saeeddev94/xray/adapter/ProfileAdapter.kt index b232250..0c065d7 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/adapter/ProfileAdapter.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/adapter/ProfileAdapter.kt @@ -1,6 +1,5 @@ package io.github.saeeddev94.xray.adapter -import android.content.Context import android.content.res.ColorStateList import android.view.LayoutInflater import android.view.View @@ -13,18 +12,21 @@ import androidx.recyclerview.widget.RecyclerView import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.dto.ProfileList -import io.github.saeeddev94.xray.database.XrayDatabase import io.github.saeeddev94.xray.helper.ProfileTouchHelper +import io.github.saeeddev94.xray.viewmodel.ProfileViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch class ProfileAdapter( - private var context: Context, + private val scope: CoroutineScope, + private val profileViewModel: ProfileViewModel, private var profiles: ArrayList, private var callback: ProfileClickListener, ) : RecyclerView.Adapter(), ProfileTouchHelper.ProfileTouchCallback { override fun onCreateViewHolder(container: ViewGroup, type: Int): ViewHolder { - val linearLayout = LinearLayout(context) - val item: View = LayoutInflater.from(context).inflate(R.layout.item_recycler_main, linearLayout, false) + val linearLayout = LinearLayout(container.context) + val item: View = LayoutInflater.from(container.context).inflate(R.layout.item_recycler_main, linearLayout, false) return ViewHolder(item) } @@ -36,7 +38,7 @@ class ProfileAdapter( val profile = profiles[index] profile.index = index val color = if (Settings.selectedProfile == profile.id) R.color.primaryColor else R.color.btnColor - holder.activeIndicator.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(context, color)) + holder.activeIndicator.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(holder.profileCard.context, color)) holder.profileName.text = profile.name holder.profileCard.setOnClickListener { callback.profileSelect(index, profile) @@ -62,15 +64,14 @@ class ProfileAdapter( override fun onItemMoveCompleted(startPosition: Int, endPosition: Int) { val id = profiles[endPosition].id - Thread { - val db = XrayDatabase.ref(context) - db.profileDao().updateIndex(endPosition, id) + scope.launch { + profileViewModel.updateIndex(endPosition, id) if (startPosition > endPosition) { - db.profileDao().fixMoveUpIndex(endPosition, startPosition, id) + profileViewModel.fixMoveUpIndex(endPosition, startPosition, id) } else { - db.profileDao().fixMoveDownIndex(startPosition, endPosition, id) + profileViewModel.fixMoveDownIndex(startPosition, endPosition, id) } - }.start() + } } class ViewHolder(item: View) : RecyclerView.ViewHolder(item) { diff --git a/app/src/main/java/io/github/saeeddev94/xray/database/ProfileDao.kt b/app/src/main/java/io/github/saeeddev94/xray/database/ProfileDao.kt index 2abbf7f..a2e4e16 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/database/ProfileDao.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/database/ProfileDao.kt @@ -10,32 +10,55 @@ import io.github.saeeddev94.xray.dto.ProfileList @Dao interface ProfileDao { @Query("SELECT `id`, `index`, `name` FROM profiles ORDER BY `index` ASC") - fun all(): List + suspend fun all(): List - @Query("UPDATE profiles SET `index` = `index` + 1") - fun fixInsertIndex() - - @Query("UPDATE profiles SET `index` = `index` - 1 WHERE `index` > :index") - fun fixDeleteIndex(index: Int) + @Query( + "SELECT profiles.* FROM profiles" + + " INNER JOIN links ON profiles.link_id = links.id" + + " WHERE links.is_active = 1" + + " ORDER BY links.id DESC, `index` DESC" + ) + suspend fun activeLinks(): List - @Query("UPDATE profiles SET `index` = :index WHERE `id` = :id") - fun updateIndex(index: Int, id: Long) - - @Query("UPDATE profiles SET `index` = `index` + 1 WHERE `index` >= :start AND `index` < :end AND `id` NOT IN (:exclude)") - fun fixMoveUpIndex(start: Int, end: Int, exclude: Long) - - @Query("UPDATE profiles SET `index` = `index` - 1 WHERE `index` > :start AND `index` <= :end AND `id` NOT IN (:exclude)") - fun fixMoveDownIndex(start: Int, end: Int, exclude: Long) + @Query("SELECT * FROM profiles WHERE link_id = :linkId") + suspend fun linkProfiles(linkId: Long): List @Query("SELECT * FROM profiles WHERE `id` = :id") - fun find(id: Long): Profile + suspend fun find(id: Long): Profile @Insert - fun insert(profile: Profile): Long + suspend fun insert(profile: Profile): Long @Update - fun update(profile: Profile) + suspend fun update(profile: Profile) @Delete - fun delete(profile: Profile) + suspend fun delete(profile: Profile) + + @Query("UPDATE profiles SET `index` = :index WHERE `id` = :id") + suspend fun updateIndex(index: Int, id: Long) + + @Query("UPDATE profiles SET `index` = `index` + 1") + suspend fun fixInsertIndex() + + @Query("UPDATE profiles SET `index` = `index` - 1 WHERE `index` > :index") + suspend fun fixDeleteIndex(index: Int) + + @Query( + "UPDATE profiles" + + " SET `index` = `index` + 1" + + " WHERE `index` >= :start" + + " AND `index` < :end" + + " AND `id` NOT IN (:exclude)" + ) + suspend fun fixMoveUpIndex(start: Int, end: Int, exclude: Long) + + @Query( + "UPDATE profiles" + + " SET `index` = `index` - 1" + + " WHERE `index` > :start" + + " AND `index` <= :end" + + " AND `id` NOT IN (:exclude)" + ) + suspend fun fixMoveDownIndex(start: Int, end: Int, exclude: Long) } diff --git a/app/src/main/java/io/github/saeeddev94/xray/helper/ConfigHelper.kt b/app/src/main/java/io/github/saeeddev94/xray/helper/ConfigHelper.kt new file mode 100644 index 0000000..1ac2a1e --- /dev/null +++ b/app/src/main/java/io/github/saeeddev94/xray/helper/ConfigHelper.kt @@ -0,0 +1,20 @@ +package io.github.saeeddev94.xray.helper + +import XrayCore.XrayCore +import android.content.Context +import io.github.saeeddev94.xray.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ConfigHelper { + companion object { + suspend fun isValid(context: Context, json: String): String { + return withContext(Dispatchers.IO) { + val pwd = context.filesDir.absolutePath + val testConfig = Settings.testConfig(context) + FileHelper().createOrUpdate(testConfig, json) + XrayCore.test(pwd, testConfig.absolutePath) + } + } + } +} diff --git a/app/src/main/java/io/github/saeeddev94/xray/helper/DownloadHelper.kt b/app/src/main/java/io/github/saeeddev94/xray/helper/DownloadHelper.kt index 2fe6faa..f940900 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/helper/DownloadHelper.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/helper/DownloadHelper.kt @@ -1,5 +1,9 @@ package io.github.saeeddev94.xray.helper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -9,47 +13,60 @@ import java.lang.Exception import java.net.HttpURLConnection import java.net.URL -class DownloadHelper(private val url: String, private val file: File, private val callback: DownloadListener) { +class DownloadHelper( + private val scope: CoroutineScope, + private val url: String, + private val file: File, + private val callback: DownloadListener, +) { fun start() { - var input: InputStream? = null - var output: OutputStream? = null - var connection: HttpURLConnection? = null + scope.launch(Dispatchers.IO) { + var input: InputStream? = null + var output: OutputStream? = null + var connection: HttpURLConnection? = null - try { - connection = URL(url).openConnection() as HttpURLConnection - connection.connect() + try { + connection = URL(url).openConnection() as HttpURLConnection + connection.connect() - if (connection.responseCode != HttpURLConnection.HTTP_OK) { - throw Exception("Expected HTTP ${HttpURLConnection.HTTP_OK} but received HTTP ${connection.responseCode}") - } + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + throw Exception("Expected HTTP ${HttpURLConnection.HTTP_OK} but received HTTP ${connection.responseCode}") + } - input = connection.inputStream - output = FileOutputStream(file) - - val fileLength = connection.contentLength - val data = ByteArray(4096) - var total: Long = 0 - var count: Int - while (input.read(data).also { count = it } != -1) { - total += count.toLong() - if (fileLength > 0) { - val progress = (total * 100 / fileLength).toInt() - callback.onProgress(progress) + input = connection.inputStream + output = FileOutputStream(file) + + val fileLength = connection.contentLength + val data = ByteArray(4096) + var total: Long = 0 + var count: Int + while (input.read(data).also { count = it } != -1) { + total += count.toLong() + if (fileLength > 0) { + val progress = (total * 100 / fileLength).toInt() + withContext(Dispatchers.Main) { + callback.onProgress(progress) + } + } + output.write(data, 0, count) + } + withContext(Dispatchers.Main) { + callback.onComplete() + } + } catch (exception: Exception) { + withContext(Dispatchers.Main) { + callback.onError(exception) + } + } finally { + try { + output?.close() + input?.close() + } catch (_: IOException) { } - output.write(data, 0, count) - } - callback.onComplete() - } catch (exception: Exception) { - callback.onError(exception) - } finally { - try { - output?.close() - input?.close() - } catch (_: IOException) { - } - connection?.disconnect() + connection?.disconnect() + } } } diff --git a/app/src/main/java/io/github/saeeddev94/xray/helper/HttpHelper.kt b/app/src/main/java/io/github/saeeddev94/xray/helper/HttpHelper.kt index 192ea28..dc094c0 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/helper/HttpHelper.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/helper/HttpHelper.kt @@ -1,6 +1,10 @@ package io.github.saeeddev94.xray.helper import io.github.saeeddev94.xray.Settings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.lang.Exception import java.net.Authenticator import java.net.HttpURLConnection @@ -9,53 +13,63 @@ import java.net.PasswordAuthentication import java.net.Proxy import java.net.URL -class HttpHelper { +class HttpHelper(var scope: CoroutineScope) { - fun get(link: String): String { - val url = URL(link) - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connect() - val responseCode = connection.responseCode - return if (responseCode == HttpURLConnection.HTTP_OK) { - connection.inputStream.bufferedReader().use { it.readText() } - } else { - throw Exception("HTTP Error: $responseCode") + companion object { + suspend fun get(link: String): String { + return withContext(Dispatchers.IO) { + val url = URL(link) + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connect() + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + connection.inputStream.bufferedReader().use { it.readText() } + } else { + throw Exception("HTTP Error: $responseCode") + } + } } } - fun measureDelay(): String { - val start = System.currentTimeMillis() - val connection = getConnection() - var result = "HTTP {status}, {delay} ms" + fun measureDelay(callback: (result: String) -> Unit) { + scope.launch(Dispatchers.IO) { + val start = System.currentTimeMillis() + val connection = getConnection() + var result = "HTTP {status}, {delay} ms" - result = try { - setSocksAuth(getSocksAuth()) - val responseCode = connection.responseCode - result.replace("{status}", "$responseCode") - } catch (error: Exception) { - error.message ?: "Http delay measure failed" - } finally { - connection.disconnect() - setSocksAuth(null) - } + result = try { + setSocksAuth(getSocksAuth()) + val responseCode = connection.responseCode + result.replace("{status}", "$responseCode") + } catch (error: Exception) { + error.message ?: "Http delay measure failed" + } finally { + connection.disconnect() + setSocksAuth(null) + } - val delay = System.currentTimeMillis() - start - return result.replace("{delay}", "$delay") + val delay = System.currentTimeMillis() - start + withContext(Dispatchers.Main) { + callback(result.replace("{delay}", "$delay")) + } + } } - private fun getConnection(): HttpURLConnection { - val address = InetSocketAddress(Settings.socksAddress, Settings.socksPort.toInt()) - val proxy = Proxy(Proxy.Type.SOCKS, address) - val timeout = Settings.pingTimeout * 1000 - val connection = URL(Settings.pingAddress).openConnection(proxy) as HttpURLConnection + private suspend fun getConnection(): HttpURLConnection { + return withContext(Dispatchers.IO) { + val address = InetSocketAddress(Settings.socksAddress, Settings.socksPort.toInt()) + val proxy = Proxy(Proxy.Type.SOCKS, address) + val timeout = Settings.pingTimeout * 1000 + val connection = URL(Settings.pingAddress).openConnection(proxy) as HttpURLConnection - connection.requestMethod = "HEAD" - connection.connectTimeout = timeout - connection.readTimeout = timeout - connection.setRequestProperty("Connection", "close") + connection.requestMethod = "HEAD" + connection.connectTimeout = timeout + connection.readTimeout = timeout + connection.setRequestProperty("Connection", "close") - return connection + connection + } } private fun getSocksAuth(): Authenticator? { diff --git a/app/src/main/java/io/github/saeeddev94/xray/helper/LinkHelper.kt b/app/src/main/java/io/github/saeeddev94/xray/helper/LinkHelper.kt index d8a2d1c..a72c67f 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/helper/LinkHelper.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/helper/LinkHelper.kt @@ -12,11 +12,11 @@ class LinkHelper(link: String) { private val success: Boolean private val outbound: JSONObject? + private var remark: String = REMARK_DEFAULT init { val base64: String = XrayCore.json(link) - val byteArray = Base64.decode(base64, Base64.DEFAULT) - val decoded = String(byteArray) + val decoded = decodeBase64(base64) val response = try { JSONObject(decoded) } catch (error: JSONException) { JSONObject() } val data = response.optJSONObject("data") ?: JSONObject() val outbounds = data.optJSONArray("outbounds") ?: JSONArray() @@ -25,9 +25,16 @@ class LinkHelper(link: String) { } companion object { + private const val REMARK_DEFAULT = "New Profile" + fun remark(uri: URI): String { val name = uri.fragment ?: "" - return name.ifEmpty { "New Profile" } + return name.ifEmpty { REMARK_DEFAULT } + } + + fun decodeBase64(value: String): String { + val byteArray = Base64.decode(value, Base64.DEFAULT) + return String(byteArray) } } @@ -39,6 +46,10 @@ class LinkHelper(link: String) { return config().toString(2) + "\n" } + fun remark(): String { + return remark + } + private fun log(): JSONObject { val log = JSONObject() log.put("loglevel", "warning") @@ -96,7 +107,10 @@ class LinkHelper(link: String) { val outbounds = JSONArray() val proxy = JSONObject(outbound!!.toString()) - if (proxy.has("sendThrough")) proxy.remove("sendThrough") + if (proxy.has("sendThrough")) { + remark = proxy.optString("sendThrough", REMARK_DEFAULT) + proxy.remove("sendThrough") + } proxy.put("tag", "proxy") val direct = JSONObject() diff --git a/app/src/main/java/io/github/saeeddev94/xray/repository/ProfileRepository.kt b/app/src/main/java/io/github/saeeddev94/xray/repository/ProfileRepository.kt new file mode 100644 index 0000000..c887186 --- /dev/null +++ b/app/src/main/java/io/github/saeeddev94/xray/repository/ProfileRepository.kt @@ -0,0 +1,56 @@ +package io.github.saeeddev94.xray.repository + +import io.github.saeeddev94.xray.database.Profile +import io.github.saeeddev94.xray.database.ProfileDao +import io.github.saeeddev94.xray.dto.ProfileList + +class ProfileRepository(private val profileDao: ProfileDao) { + + suspend fun all(): List { + return profileDao.all() + } + + suspend fun activeLinks(): List { + return profileDao.activeLinks() + } + + suspend fun linkProfiles(linkId: Long): List { + return profileDao.linkProfiles(linkId) + } + + suspend fun find(id: Long): Profile { + return profileDao.find(id) + } + + suspend fun insert(profile: Profile): Long { + return profileDao.insert(profile) + } + + suspend fun update(profile: Profile) { + profileDao.update(profile) + } + + suspend fun delete(profile: Profile) { + profileDao.delete(profile) + } + + suspend fun updateIndex(index: Int, id: Long) { + profileDao.updateIndex(index, id) + } + + suspend fun fixInsertIndex() { + profileDao.fixInsertIndex() + } + + suspend fun fixDeleteIndex(index: Int) { + profileDao.fixDeleteIndex(index) + } + + suspend fun fixMoveUpIndex(start: Int, end: Int, exclude: Long) { + profileDao.fixMoveUpIndex(start, end, exclude) + } + + suspend fun fixMoveDownIndex(start: Int, end: Int, exclude: Long) { + profileDao.fixMoveDownIndex(start, end, exclude) + } +} diff --git a/app/src/main/java/io/github/saeeddev94/xray/service/TProxyService.kt b/app/src/main/java/io/github/saeeddev94/xray/service/TProxyService.kt index 4f51295..b1d4274 100644 --- a/app/src/main/java/io/github/saeeddev94/xray/service/TProxyService.kt +++ b/app/src/main/java/io/github/saeeddev94/xray/service/TProxyService.kt @@ -25,10 +25,15 @@ import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.activity.MainActivity import io.github.saeeddev94.xray.database.Profile -import io.github.saeeddev94.xray.database.XrayDatabase import io.github.saeeddev94.xray.helper.FileHelper import XrayCore.XrayCore import android.util.Log +import io.github.saeeddev94.xray.Xray +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlin.reflect.cast class TProxyService : VpnService() { @@ -44,6 +49,9 @@ class TProxyService : VpnService() { const val STOP_VPN_SERVICE_ACTION_NAME = "${BuildConfig.APPLICATION_ID}.VpnStop" } + private val profileRepository by lazy { Xray::class.cast(application).profileRepository } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private external fun TProxyStartService(configPath: String, fd: Int) private external fun TProxyStopService() private external fun TProxyGetStats(): LongArray @@ -103,14 +111,14 @@ class TProxyService : VpnService() { } private fun findProfileAndStart() { - Thread { + scope.launch { val profile = if (Settings.selectedProfile == 0L) { null } else { - XrayDatabase.ref(applicationContext).profileDao().find(Settings.selectedProfile) + profileRepository.find(Settings.selectedProfile) } startVPN(profile) - }.start() + } } private fun startVPN(profile: Profile?) { diff --git a/app/src/main/java/io/github/saeeddev94/xray/viewmodel/ProfileViewModel.kt b/app/src/main/java/io/github/saeeddev94/xray/viewmodel/ProfileViewModel.kt new file mode 100644 index 0000000..db47c1d --- /dev/null +++ b/app/src/main/java/io/github/saeeddev94/xray/viewmodel/ProfileViewModel.kt @@ -0,0 +1,60 @@ +package io.github.saeeddev94.xray.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import io.github.saeeddev94.xray.Xray +import io.github.saeeddev94.xray.database.Profile +import io.github.saeeddev94.xray.dto.ProfileList + +class ProfileViewModel(application: Application) : AndroidViewModel(application) { + + private val profileRepository by lazy { getApplication().profileRepository } + + suspend fun all(): List { + return profileRepository.all() + } + + suspend fun activeLinks(): List { + return profileRepository.activeLinks() + } + + suspend fun linkProfiles(linkId: Long): List { + return profileRepository.linkProfiles(linkId) + } + + suspend fun find(id: Long): Profile { + return profileRepository.find(id) + } + + suspend fun insert(profile: Profile): Long { + return profileRepository.insert(profile) + } + + suspend fun update(profile: Profile) { + profileRepository.update(profile) + } + + suspend fun delete(profile: Profile) { + profileRepository.delete(profile) + } + + suspend fun updateIndex(index: Int, id: Long) { + profileRepository.updateIndex(index, id) + } + + suspend fun fixInsertIndex() { + profileRepository.fixInsertIndex() + } + + suspend fun fixDeleteIndex(index: Int) { + profileRepository.fixDeleteIndex(index) + } + + suspend fun fixMoveUpIndex(start: Int, end: Int, exclude: Long) { + profileRepository.fixMoveUpIndex(start, end, exclude) + } + + suspend fun fixMoveDownIndex(start: Int, end: Int, exclude: Long) { + profileRepository.fixMoveDownIndex(start, end, exclude) + } +}