Init commit

This commit is contained in:
世界
2022-12-02 14:17:47 +08:00
commit 7736e1e644
121 changed files with 6295 additions and 0 deletions

View File

@@ -0,0 +1,331 @@
package io.nekohasekai.sfa.ui
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Bundle
import android.text.TextUtils
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.microsoft.appcenter.AppCenter
import com.microsoft.appcenter.analytics.Analytics
import com.microsoft.appcenter.crashes.Crashes
import com.microsoft.appcenter.distribute.Distribute
import com.microsoft.appcenter.distribute.DistributeListener
import com.microsoft.appcenter.distribute.ReleaseDetails
import com.microsoft.appcenter.distribute.UpdateAction
import com.microsoft.appcenter.utils.AppNameHelper
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.bg.ServiceNotification
import io.nekohasekai.sfa.constant.Alert
import io.nekohasekai.sfa.constant.ServiceMode
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.databinding.ActivityMainBinding
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ui.shared.AbstractActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.LinkedList
class MainActivity : AbstractActivity(), ServiceConnection.Callback, DistributeListener {
companion object {
private const val TAG = "MyActivity"
}
private lateinit var binding: ActivityMainBinding
private val connection = ServiceConnection(this, this)
val logList = LinkedList<String>()
var logCallback: ((Boolean) -> Unit)? = null
val serviceStatus = MutableLiveData(Status.Stopped)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val navController = findNavController(R.id.nav_host_fragment_activity_my)
val appBarConfiguration =
AppBarConfiguration(
setOf(
R.id.navigation_dashboard,
R.id.navigation_log,
R.id.navigation_configuration,
R.id.navigation_settings,
)
)
setupActionBarWithNavController(navController, appBarConfiguration)
binding.navView.setupWithNavController(navController)
reconnect()
startAnalysis()
}
fun reconnect() {
connection.reconnect()
}
private fun startAnalysis() {
lifecycleScope.launch(Dispatchers.IO) {
when (Settings.analyticsAllowed) {
Settings.ANALYSIS_UNKNOWN -> {
withContext(Dispatchers.Main) {
showAnalysisDialog()
}
}
Settings.ANALYSIS_ALLOWED -> {
startAnalysisInternal()
}
}
}
}
private fun showAnalysisDialog() {
val builder = MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.analytics_title))
.setMessage(getString(R.string.analytics_message))
.setPositiveButton(getString(R.string.ok)) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
Settings.analyticsAllowed = Settings.ANALYSIS_ALLOWED
startAnalysisInternal()
}
}
.setNegativeButton(getString(R.string.no_thanks)) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
Settings.analyticsAllowed = Settings.ANALYSIS_DISALLOWED
}
}
runCatching { builder.show() }
}
suspend fun startAnalysisInternal() {
if (BuildConfig.APPCENTER_SECRET.isBlank()) {
return
}
Distribute.setListener(this)
runCatching {
AppCenter.start(
application,
BuildConfig.APPCENTER_SECRET,
Analytics::class.java,
Crashes::class.java,
Distribute::class.java,
)
if (!Settings.checkUpdateEnabled) {
Distribute.disableAutomaticCheckForUpdate()
}
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it).show()
}
}
}
override fun onReleaseAvailable(activity: Activity, releaseDetails: ReleaseDetails): Boolean {
lifecycleScope.launch(Dispatchers.Main) {
delay(2000L)
runCatching {
onReleaseAvailable0(releaseDetails)
}
}
return true
}
private fun onReleaseAvailable0(releaseDetails: ReleaseDetails) {
val builder = MaterialAlertDialogBuilder(this)
.setTitle(getString(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_title))
var message = if (releaseDetails.isMandatoryUpdate) {
getString(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_message_mandatory)
} else {
getString(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_message_optional)
}
message = String.format(
message,
AppNameHelper.getAppName(this),
releaseDetails.shortVersion,
releaseDetails.version
)
builder.setMessage(message)
builder.setPositiveButton(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_download) { _, _ ->
startActivity(Intent(Intent.ACTION_VIEW, releaseDetails.downloadUrl))
}
builder.setCancelable(false)
if (!releaseDetails.isMandatoryUpdate) {
builder.setNegativeButton(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_postpone) { _, _ ->
Distribute.notifyUpdateAction(UpdateAction.POSTPONE)
}
}
if (!TextUtils.isEmpty(releaseDetails.releaseNotes) && releaseDetails.releaseNotesUrl != null) {
builder.setNeutralButton(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_view_release_notes) { _, _ ->
startActivity(Intent(Intent.ACTION_VIEW, releaseDetails.releaseNotesUrl))
}
}
builder.show()
}
override fun onNoReleaseAvailable(activity: Activity) {
}
@SuppressLint("NewApi")
fun startService() {
if (!ServiceNotification.checkPermission()) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
return
}
lifecycleScope.launch(Dispatchers.IO) {
if (Settings.rebuildServiceMode()) {
reconnect()
}
if (Settings.serviceMode == ServiceMode.VPN) {
if (prepare()) {
return@launch
}
}
val intent = Intent(Application.application, Settings.serviceClass())
withContext(Dispatchers.Main) {
ContextCompat.startForegroundService(Application.application, intent)
}
}
}
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {
if (it) {
startService()
} else {
onServiceAlert(Alert.RequestNotificationPermission, null)
}
}
private val prepareLauncher = registerForActivityResult(PrepareService()) {
if (it) {
startService()
} else {
onServiceAlert(Alert.RequestVPNPermission, null)
}
}
private class PrepareService : ActivityResultContract<Intent, Boolean>() {
override fun createIntent(context: Context, input: Intent): Intent {
return input
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == RESULT_OK
}
}
private suspend fun prepare() = withContext(Dispatchers.Main) {
try {
val intent = VpnService.prepare(this@MainActivity)
if (intent != null) {
prepareLauncher.launch(intent)
true
} else {
false
}
} catch (e: Exception) {
onServiceAlert(Alert.RequestVPNPermission, e.message)
false
}
}
override fun onServiceStatusChanged(status: Status) {
serviceStatus.postValue(status)
}
override fun onServiceAlert(type: Alert, message: String?) {
val builder = MaterialAlertDialogBuilder(this)
builder.setPositiveButton(resources.getString(android.R.string.ok), null)
when (type) {
Alert.RequestVPNPermission -> {
builder.setMessage(getString(R.string.service_error_missing_permission))
}
Alert.RequestNotificationPermission -> {
builder.setMessage(getString(R.string.service_error_missing_notification_permission))
}
Alert.EmptyConfiguration -> {
builder.setMessage(getString(R.string.service_error_empty_configuration))
}
Alert.StartCommandServer -> {
builder.setTitle(getString(R.string.service_error_title_start_command_server))
builder.setMessage(message)
}
Alert.CreateService -> {
builder.setTitle(getString(R.string.service_error_title_create_service))
builder.setMessage(message)
}
Alert.StartService -> {
builder.setTitle(getString(R.string.service_error_title_start_service))
builder.setMessage(message)
}
}
builder.show()
}
private var paused = false
override fun onPause() {
super.onPause()
paused = true
}
override fun onResume() {
super.onResume()
paused = false
logCallback?.invoke(true)
}
override fun onServiceWriteLog(message: String?) {
if (paused) {
if (logList.size > 300) {
logList.removeFirst()
}
}
logList.addLast(message)
if (!paused) {
logCallback?.invoke(false)
}
}
override fun onServiceResetLogs(messages: MutableList<String>) {
logList.clear()
logList.addAll(messages)
if (!paused) logCallback?.invoke(true)
}
override fun onDestroy() {
connection.disconnect()
super.onDestroy()
}
}

View File

@@ -0,0 +1,67 @@
package io.nekohasekai.sfa.ui
import android.app.Activity
import android.content.Intent
import android.content.pm.ShortcutManager
import android.os.Build
import android.os.Bundle
import androidx.core.content.getSystemService
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.constant.Status
class ShortcutActivity : Activity(), ServiceConnection.Callback {
private val connection = ServiceConnection(this, this, false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.action == Intent.ACTION_CREATE_SHORTCUT) {
setResult(
RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(
this,
ShortcutInfoCompat.Builder(this, "toggle")
.setIntent(
Intent(
this,
ShortcutActivity::class.java
).setAction(Intent.ACTION_MAIN)
)
.setIcon(
IconCompat.createWithResource(
this,
R.mipmap.ic_launcher
)
)
.setShortLabel(getString(R.string.quick_toggle))
.build()
)
)
finish()
} else {
connection.connect()
if (Build.VERSION.SDK_INT >= 25) {
getSystemService<ShortcutManager>()?.reportShortcutUsed("toggle")
}
}
}
override fun onServiceStatusChanged(status: Status) {
when (status) {
Status.Started -> BoxService.stop()
Status.Stopped -> BoxService.start()
else -> {}
}
finish()
}
override fun onDestroy() {
connection.disconnect()
super.onDestroy()
}
}

View File

@@ -0,0 +1,180 @@
package io.nekohasekai.sfa.ui.main
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.Profiles
import io.nekohasekai.sfa.databinding.FragmentConfigurationBinding
import io.nekohasekai.sfa.databinding.ViewConfigutationItemBinding
import io.nekohasekai.sfa.ui.profile.EditProfileActivity
import io.nekohasekai.sfa.ui.profile.NewProfileActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ConfigurationFragment : Fragment() {
private var _adapter: Adapter? = null
private var adapter: Adapter
get() = _adapter as Adapter
set(value) {
_adapter = value
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
val binding = FragmentConfigurationBinding.inflate(inflater, container, false)
adapter = Adapter(lifecycleScope, binding)
binding.profileList.also {
it.layoutManager = LinearLayoutManager(requireContext())
it.adapter = adapter
ItemTouchHelper(object :
ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return adapter.move(viewHolder.adapterPosition, target.adapterPosition)
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
}
}).attachToRecyclerView(it)
}
adapter.reload()
binding.fab.setOnClickListener {
startActivity(Intent(requireContext(), NewProfileActivity::class.java))
}
return binding.root
}
override fun onResume() {
super.onResume()
_adapter?.reload()
}
override fun onDestroyView() {
super.onDestroyView()
_adapter = null
}
class Adapter(
internal val scope: CoroutineScope,
private val parent: FragmentConfigurationBinding
) :
RecyclerView.Adapter<Holder>() {
internal var items: MutableList<Profile> = mutableListOf()
internal fun reload() {
scope.launch(Dispatchers.IO) {
items = Profiles.list().toMutableList()
withContext(Dispatchers.Main) {
if (items.isEmpty()) {
parent.statusText.isVisible = true
parent.profileList.isVisible = false
} else if (parent.statusText.isVisible) {
parent.statusText.isVisible = false
parent.profileList.isVisible = true
}
notifyDataSetChanged()
}
}
}
internal fun move(from: Int, to: Int): Boolean {
val first = items.getOrNull(from) ?: return false
var previousOrder = first.userOrder
val (step, range) = if (from < to) Pair(1, from until to) else Pair(
-1, to + 1 downTo from
)
val updated = mutableListOf<Profile>()
for (i in range) {
val next = items.getOrNull(i + step) ?: return false
val order = next.userOrder
next.userOrder = previousOrder
previousOrder = order
updated.add(next)
}
first.userOrder = previousOrder
updated.add(first)
notifyItemMoved(from, to)
GlobalScope.launch(Dispatchers.IO) {
Profiles.update(updated)
}
return true
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
this,
ViewConfigutationItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int {
return items.size
}
}
class Holder(private val adapter: Adapter, private val binding: ViewConfigutationItemBinding) :
RecyclerView.ViewHolder(binding.root) {
internal fun bind(profile: Profile) {
binding.profileName.text = profile.name
binding.root.setOnClickListener {
val intent = Intent(binding.root.context, EditProfileActivity::class.java)
intent.putExtra("profile_id", profile.id)
it.context.startActivity(intent)
}
binding.moreButton.setOnClickListener { it ->
val popup = PopupMenu(it.context, it)
popup.setForceShowIcon(true)
popup.menuInflater.inflate(R.menu.profile_menu, popup.menu)
popup.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_delete -> {
adapter.items.remove(profile)
adapter.notifyItemRemoved(adapterPosition)
adapter.scope.launch(Dispatchers.IO) {
runCatching {
Profiles.delete(profile)
}
}
true
}
else -> false
}
}
popup.show()
}
}
}
}

View File

@@ -0,0 +1,276 @@
package io.nekohasekai.sfa.ui.main
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDividerItemDecoration
import go.Seq
import io.nekohasekai.libbox.CommandClient
import io.nekohasekai.libbox.CommandClientHandler
import io.nekohasekai.libbox.CommandClientOptions
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.Profiles
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.databinding.FragmentDashboardBinding
import io.nekohasekai.sfa.databinding.ViewProfileItemBinding
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ui.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DashboardFragment : Fragment(), CommandClientHandler {
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
private var _binding: FragmentDashboardBinding? = null
private val binding get() = _binding!!
private var commandClient: CommandClient? = null
private var _adapter: Adapter? = null
private val adapter get() = _adapter!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
onCreate()
return binding.root
}
private fun onCreate() {
val activity = activity ?: return
binding.profileList.adapter = Adapter(lifecycleScope, binding).apply {
_adapter = this
reload()
}
binding.profileList.layoutManager = LinearLayoutManager(requireContext())
val divider = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)
divider.isLastItemDecorated = false
binding.profileList.addItemDecoration(divider)
activity.serviceStatus.observe(viewLifecycleOwner) {
binding.statusCard.isVisible = it == Status.Starting || it == Status.Started
when (it) {
Status.Stopped -> {
binding.fab.setImageResource(R.drawable.ic_play_arrow_24)
binding.fab.show()
}
Status.Starting -> {
binding.fab.hide()
}
Status.Started -> {
binding.fab.setImageResource(R.drawable.ic_stop_24)
binding.fab.show()
reconnect()
}
Status.Stopping -> {
binding.fab.hide()
}
else -> {}
}
}
binding.fab.setOnClickListener {
when (activity.serviceStatus.value) {
Status.Stopped -> {
activity.startService()
}
Status.Started -> {
BoxService.stop()
}
else -> {}
}
}
}
private fun reconnect() {
disconnect()
val options = CommandClientOptions()
options.command = Libbox.CommandStatus
options.statusInterval = 2 * 1000 * 1000 * 1000
val commandClient = CommandClient(requireContext().filesDir.absolutePath, this, options)
this.commandClient = commandClient
lifecycleScope.launch(Dispatchers.IO) {
for (i in 1..3) {
delay(100)
try {
commandClient.connect()
break
} catch (e: Exception) {
break
}
}
}
}
private fun disconnect() {
commandClient?.apply {
runCatching {
disconnect()
}
Seq.destroyRef(refnum)
}
commandClient = null
}
override fun onDestroyView() {
super.onDestroyView()
_adapter = null
_binding = null
disconnect()
}
override fun connected() {
val binding = _binding ?: return
lifecycleScope.launch(Dispatchers.Main) {
binding.memoryText.text = getString(R.string.loading)
binding.goroutinesText.text = getString(R.string.loading)
}
}
override fun disconnected(message: String?) {
val binding = _binding ?: return
lifecycleScope.launch(Dispatchers.Main) {
binding.memoryText.text = getString(R.string.loading)
binding.goroutinesText.text = getString(R.string.loading)
}
}
override fun writeLog(message: String) {
}
override fun writeStatus(message: StatusMessage) {
val binding = _binding ?: return
lifecycleScope.launch(Dispatchers.Main) {
binding.memoryText.text = Libbox.formatBytes(message.memory)
binding.goroutinesText.text = message.goroutines.toString()
}
}
class Adapter(
internal val scope: CoroutineScope,
private val parent: FragmentDashboardBinding
) :
RecyclerView.Adapter<Holder>() {
internal var items: MutableList<Profile> = mutableListOf()
internal var selectedProfileID = -1L
internal var lastSelectedIndex: Int? = null
internal fun reload() {
scope.launch(Dispatchers.IO) {
items = Profiles.list().toMutableList()
if (items.isNotEmpty()) {
selectedProfileID = Settings.selectedProfile
for ((index, profile) in items.withIndex()) {
if (profile.id == selectedProfileID) {
lastSelectedIndex = index
break
}
}
if (lastSelectedIndex == null) {
lastSelectedIndex = 0
selectedProfileID = items[0].id
Settings.selectedProfile = selectedProfileID
}
}
withContext(Dispatchers.Main) {
parent.statusText.isVisible = items.isEmpty()
parent.container.isVisible = items.isNotEmpty()
notifyDataSetChanged()
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
this,
ViewProfileItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int {
return items.size
}
}
class Holder(
private val adapter: Adapter,
private val binding: ViewProfileItemBinding
) :
RecyclerView.ViewHolder(binding.root) {
internal fun bind(profile: Profile) {
binding.profileName.text = profile.name
binding.profileSelected.setOnCheckedChangeListener(null)
binding.profileSelected.isChecked = profile.id == adapter.selectedProfileID
binding.profileSelected.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
adapter.selectedProfileID = profile.id
adapter.lastSelectedIndex?.let { index ->
adapter.notifyItemChanged(index)
}
adapter.lastSelectedIndex = adapterPosition
adapter.scope.launch(Dispatchers.IO) {
switchProfile(profile)
}
}
}
binding.root.setOnClickListener {
binding.profileSelected.toggle()
}
}
private suspend fun switchProfile(profile: Profile) {
Settings.selectedProfile = profile.id
val mainActivity = (binding.root.context as? MainActivity) ?: return
val started = mainActivity.serviceStatus.value == Status.Started
if (!started) {
return
}
val restart = Settings.rebuildServiceMode()
if (restart) {
mainActivity.reconnect()
BoxService.stop()
delay(200)
mainActivity.startService()
return
}
runCatching {
Libbox.clientServiceReload(mainActivity.filesDir.absolutePath)
}.onFailure {
withContext(Dispatchers.Main) {
mainActivity.errorDialogBuilder(it).show()
}
}
}
}
}

View File

@@ -0,0 +1,147 @@
package io.nekohasekai.sfa.ui.main
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.databinding.FragmentLogBinding
import io.nekohasekai.sfa.databinding.ViewLogTextItemBinding
import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.utils.ColorUtils
import java.util.LinkedList
class LogFragment : Fragment() {
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
private var _binding: FragmentLogBinding? = null
private val binding get() = _binding!!
private var logAdapter: LogAdapter? = null
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentLogBinding.inflate(inflater, container, false)
onCreate()
return binding.root
}
private fun onCreate() {
val activity = activity ?: return
activity.logCallback = ::updateViews
binding.logView.layoutManager = LinearLayoutManager(requireContext())
binding.logView.adapter = LogAdapter(activity.logList).also { logAdapter = it }
updateViews(true)
activity.serviceStatus.observe(viewLifecycleOwner) {
when (it) {
Status.Stopped -> {
binding.fab.setImageResource(R.drawable.ic_play_arrow_24)
binding.fab.show()
binding.statusText.setText(R.string.status_default)
}
Status.Starting -> {
binding.fab.hide()
binding.statusText.setText(R.string.status_starting)
}
Status.Started -> {
binding.fab.setImageResource(R.drawable.ic_stop_24)
binding.fab.show()
binding.statusText.setText(R.string.status_started)
}
Status.Stopping -> {
binding.fab.hide()
binding.statusText.setText(R.string.status_stopping)
}
else -> {}
}
}
binding.fab.setOnClickListener {
when (activity.serviceStatus.value) {
Status.Stopped -> {
activity.startService()
}
Status.Started -> {
BoxService.stop()
}
else -> {}
}
}
}
private fun updateViews(reset: Boolean) {
val activity = activity ?: return
val logAdapter = logAdapter ?: return
if (activity.logList.isEmpty()) {
binding.logView.isVisible = false
binding.statusText.isVisible = true
} else if (!binding.logView.isVisible) {
binding.logView.isVisible = true
binding.statusText.isVisible = false
}
if (reset) {
logAdapter.notifyDataSetChanged()
binding.logView.scrollToPosition(activity.logList.size - 1)
} else {
binding.logView.scrollToPosition(logAdapter.notifyItemInserted())
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
activity?.logCallback = null
logAdapter = null
}
class LogAdapter(private val logList: LinkedList<String>) :
RecyclerView.Adapter<LogViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder {
return LogViewHolder(
ViewLogTextItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
)
}
override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
holder.bind(logList.getOrElse(position) { "" })
}
override fun getItemCount(): Int {
return logList.size
}
fun notifyItemInserted(): Int {
if (logList.size > 300) {
logList.removeFirst()
notifyItemRemoved(0)
}
val position = logList.size - 1
notifyItemInserted(position)
return position
}
}
class LogViewHolder(private val binding: ViewLogTextItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(message: String) {
binding.text.text = ColorUtils.ansiEscapeToSpannable(binding.root.context, message)
}
}
}

View File

@@ -0,0 +1,108 @@
package io.nekohasekai.sfa.ui.main
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.microsoft.appcenter.AppCenter
import com.microsoft.appcenter.distribute.Distribute
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.constant.EnabledType
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.databinding.FragmentSettingsBinding
import io.nekohasekai.sfa.ktx.addTextChangedListener
import io.nekohasekai.sfa.ktx.launchCustomTab
import io.nekohasekai.sfa.ktx.setSimpleItems
import io.nekohasekai.sfa.ktx.text
import io.nekohasekai.sfa.ui.MainActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SettingsFragment : Fragment() {
private var _binding: FragmentSettingsBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentSettingsBinding.inflate(inflater, container, false)
onCreate()
return binding.root
}
private fun onCreate() {
val activity = activity as MainActivity? ?: return
binding.versionText.text = Libbox.version()
binding.clearButton.setOnClickListener {
lifecycleScope.launch(Dispatchers.IO) {
activity.getExternalFilesDir(null)?.deleteRecursively()
reloadSettings()
}
}
lifecycleScope.launch(Dispatchers.IO) {
reloadSettings()
}
binding.appCenterEnabled.addTextChangedListener {
lifecycleScope.launch(Dispatchers.IO) {
val allowed = EnabledType.valueOf(it).boolValue
Settings.analyticsAllowed =
if (allowed) Settings.ANALYSIS_ALLOWED else Settings.ANALYSIS_DISALLOWED
withContext(Dispatchers.Main) {
binding.checkUpdateEnabled.isEnabled = allowed
}
if (!allowed) {
AppCenter.setEnabled(false)
} else {
if (!AppCenter.isConfigured()) {
activity.startAnalysisInternal()
}
AppCenter.setEnabled(true)
}
}
}
binding.checkUpdateEnabled.addTextChangedListener {
lifecycleScope.launch(Dispatchers.IO) {
val newValue = EnabledType.valueOf(it).boolValue
Settings.checkUpdateEnabled = newValue
if (!newValue) {
Distribute.disableAutomaticCheckForUpdate()
}
}
}
binding.communityButton.setOnClickListener {
it.context.launchCustomTab("https://community.sagernet.org/")
}
binding.documentationButton.setOnClickListener {
it.context.launchCustomTab("http://sing-box.sagernet.org/installation/clients/sfa/")
}
}
private suspend fun reloadSettings() {
val activity = activity ?: return
val dataSize = Libbox.formatBytes(
(activity.getExternalFilesDir(null) ?: activity.filesDir)
.walkTopDown().filter { it.isFile }.map { it.length() }.sum()
)
val appCenterEnabled = Settings.analyticsAllowed == Settings.ANALYSIS_ALLOWED
val checkUpdateEnabled = Settings.checkUpdateEnabled
withContext(Dispatchers.Main) {
binding.dataSizeText.text = dataSize
binding.appCenterEnabled.text = EnabledType.from(appCenterEnabled).name
binding.appCenterEnabled.setSimpleItems(R.array.enabled)
binding.checkUpdateEnabled.isEnabled = appCenterEnabled
binding.checkUpdateEnabled.text = EnabledType.from(checkUpdateEnabled).name
binding.checkUpdateEnabled.setSimpleItems(R.array.enabled)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,209 @@
package io.nekohasekai.sfa.ui.profile
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.UpdateProfileWork
import io.nekohasekai.sfa.constant.EnabledType
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.Profiles
import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.databinding.ActivityEditProfileBinding
import io.nekohasekai.sfa.ktx.addTextChangedListener
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.setSimpleItems
import io.nekohasekai.sfa.ktx.text
import io.nekohasekai.sfa.ui.shared.AbstractActivity
import io.nekohasekai.sfa.utils.HTTPClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.DateFormat
import java.util.Date
class EditProfileActivity : AbstractActivity() {
private var _binding: ActivityEditProfileBinding? = null
private val binding get() = _binding!!
private var _profile: Profile? = null
private val profile get() = _profile!!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.title_edit_profile)
_binding = ActivityEditProfileBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
loadProfile()
}.onFailure {
errorDialogBuilder(it)
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
.show()
}
}
}
private suspend fun loadProfile() {
delay(200L)
val profileId = intent.getLongExtra("profile_id", -1L)
if (profileId == -1L) error("invalid arguments")
_profile = Profiles.get(profileId) ?: error("invalid arguments")
withContext(Dispatchers.Main) {
binding.name.text = profile.name
binding.name.addTextChangedListener {
lifecycleScope.launch(Dispatchers.IO) {
try {
profile.name = it
Profiles.update(profile)
} catch (e: Exception) {
errorDialogBuilder(e).show()
}
}
}
binding.type.text = profile.typed.type.name
binding.editButton.setOnClickListener {
startActivity(
Intent(
this@EditProfileActivity,
EditProfileContentActivity::class.java
).apply {
putExtra("profile_id", profile.id)
})
}
when (profile.typed.type) {
TypedProfile.Type.Local -> {
binding.editButton.isVisible = true
binding.remoteFields.isVisible = false
}
TypedProfile.Type.Remote -> {
binding.editButton.isVisible = false
binding.remoteFields.isVisible = true
binding.remoteURL.text = profile.typed.remoteURL
binding.lastUpdated.text =
DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated)
binding.autoUpdate.text = EnabledType.from(profile.typed.autoUpdate).name
binding.autoUpdate.setSimpleItems(R.array.enabled)
binding.autoUpdateInterval.isVisible = profile.typed.autoUpdate
binding.autoUpdateInterval.text = profile.typed.autoUpdateInterval.toString()
}
}
binding.remoteURL.addTextChangedListener(this@EditProfileActivity::updateRemoteURL)
binding.autoUpdate.addTextChangedListener(this@EditProfileActivity::updateAutoUpdate)
binding.autoUpdateInterval.addTextChangedListener(this@EditProfileActivity::updateAutoUpdateInterval)
binding.updateButton.setOnClickListener(this@EditProfileActivity::updateProfile)
binding.checkButton.setOnClickListener(this@EditProfileActivity::checkProfile)
binding.profileLayout.isVisible = true
binding.progressView.isVisible = false
}
}
private fun updateRemoteURL(newValue: String) {
profile.typed.remoteURL = newValue
updateProfile()
}
private fun updateAutoUpdate(newValue: String) {
val boolValue = EnabledType.valueOf(newValue).boolValue
if (profile.typed.autoUpdate == boolValue) {
return
}
binding.autoUpdateInterval.isVisible = boolValue
profile.typed.autoUpdate = boolValue
if (boolValue) {
lifecycleScope.launch(Dispatchers.IO) {
UpdateProfileWork.reconfigureUpdater()
}
}
updateProfile()
}
private fun updateAutoUpdateInterval(newValue: String) {
if (newValue.isBlank()) {
binding.autoUpdateInterval.error = getString(R.string.profile_input_required)
return
}
val intValue = try {
newValue.toInt()
} catch (e: Exception) {
binding.autoUpdateInterval.error = e.localizedMessage
return
}
if (intValue < 15) {
binding.autoUpdateInterval.error =
getString(R.string.profile_auto_update_interval_minimum_hint)
return
}
binding.autoUpdateInterval.error = null
profile.typed.autoUpdateInterval = intValue
updateProfile()
}
private fun updateProfile() {
binding.progressView.isVisible = true
lifecycleScope.launch(Dispatchers.IO) {
delay(200)
try {
Profiles.update(profile)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
errorDialogBuilder(e).show()
}
}
withContext(Dispatchers.Main) {
binding.progressView.isVisible = false
}
}
}
private fun updateProfile(view: View) {
binding.progressView.isVisible = true
lifecycleScope.launch(Dispatchers.IO) {
try {
val content = HTTPClient().use { it.getString(profile.typed.remoteURL) }
Libbox.checkConfig(content)
File(profile.typed.path).writeText(content)
profile.typed.lastUpdated = Date()
Profiles.update(profile)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
errorDialogBuilder(e).show()
}
}
withContext(Dispatchers.Main) {
binding.lastUpdated.text =
DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated)
binding.progressView.isVisible = false
}
}
}
private fun checkProfile(button: View) {
binding.progressView.isVisible = true
lifecycleScope.launch(Dispatchers.IO) {
delay(200)
try {
Libbox.checkConfig(File(profile.typed.path).readText())
} catch (e: Exception) {
withContext(Dispatchers.Main) {
errorDialogBuilder(e).show()
}
}
withContext(Dispatchers.Main) {
binding.progressView.isVisible = false
}
}
}
}

View File

@@ -0,0 +1,144 @@
package io.nekohasekai.sfa.ui.profile
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import com.blacksquircle.ui.language.json.JsonLanguage
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.Profiles
import io.nekohasekai.sfa.databinding.ActivityEditProfileContentBinding
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ui.shared.AbstractActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
class EditProfileContentActivity : AbstractActivity() {
private var _binding: ActivityEditProfileContentBinding? = null
private val binding get() = _binding!!
private var _profile: Profile? = null
private val profile get() = _profile!!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.title_edit_configuration)
_binding = ActivityEditProfileContentBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.editor.language = JsonLanguage()
loadConfiguration()
}
private fun loadConfiguration() {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
loadConfiguration0()
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it)
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
.show()
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.edit_configutation_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_undo -> {
if (binding.editor.canUndo()) binding.editor.undo()
return true
}
R.id.action_redo -> {
if (binding.editor.canRedo()) binding.editor.redo()
return true
}
R.id.action_check -> {
binding.progressView.isVisible = true
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
Libbox.checkConfig(binding.editor.text.toString())
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it).show()
}
}
withContext(Dispatchers.Main) {
delay(200)
binding.progressView.isInvisible = true
}
}
return true
}
R.id.action_format -> {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
val content = Libbox.formatConfig(binding.editor.text.toString())
if (binding.editor.text.toString() != content) {
withContext(Dispatchers.Main) {
binding.editor.setTextContent(content)
}
}
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it).show()
}
}
}
return true
}
}
return super.onOptionsItemSelected(item)
}
private suspend fun loadConfiguration0() {
delay(200L)
val profileId = intent.getLongExtra("profile_id", -1L)
if (profileId == -1L) error("invalid arguments")
_profile = Profiles.get(profileId) ?: error("invalid arguments")
val content = File(profile.typed.path).readText()
withContext(Dispatchers.Main) {
binding.editor.setTextContent(content)
binding.editor.addTextChangedListener {
binding.progressView.isVisible = true
val newContent = it.toString()
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
File(profile.typed.path).writeText(newContent)
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it)
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
.show()
}
}
withContext(Dispatchers.Main) {
delay(200)
binding.progressView.isInvisible = true
}
}
}
binding.progressView.isInvisible = true
}
}
}

View File

@@ -0,0 +1,177 @@
package io.nekohasekai.sfa.ui.profile
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.Profiles
import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.databinding.ActivityAddProfileBinding
import io.nekohasekai.sfa.ktx.addTextChangedListener
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.removeErrorIfNotEmpty
import io.nekohasekai.sfa.ktx.showErrorIfEmpty
import io.nekohasekai.sfa.ktx.startFilesForResult
import io.nekohasekai.sfa.ktx.text
import io.nekohasekai.sfa.ui.shared.AbstractActivity
import io.nekohasekai.sfa.utils.HTTPClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import java.util.Date
class NewProfileActivity : AbstractActivity() {
enum class FileSource(val formatted: String) {
CreateNew("Create New"),
Import("Import");
}
private var _binding: ActivityAddProfileBinding? = null
private val binding get() = _binding!!
private val importFile =
registerForActivityResult(ActivityResultContracts.GetContent()) { fileURI ->
if (fileURI != null) {
binding.sourceURL.editText?.setText(fileURI.toString())
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.title_new_profile)
_binding = ActivityAddProfileBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.name.removeErrorIfNotEmpty()
binding.type.addTextChangedListener {
when (it) {
TypedProfile.Type.Local.name -> {
binding.localFields.isVisible = true
binding.remoteFields.isVisible = false
}
TypedProfile.Type.Remote.name -> {
binding.localFields.isVisible = false
binding.remoteFields.isVisible = true
}
}
}
binding.fileSourceMenu.addTextChangedListener {
when (it) {
FileSource.CreateNew.formatted -> {
binding.importFileButton.isVisible = false
binding.sourceURL.isVisible = false
}
FileSource.Import.formatted -> {
binding.importFileButton.isVisible = true
binding.sourceURL.isVisible = true
}
}
}
binding.importFileButton.setOnClickListener {
startFilesForResult(importFile, "application/json")
}
binding.createProfile.setOnClickListener(this::createProfile)
}
private fun createProfile(view: View) {
if (binding.name.showErrorIfEmpty()) {
return
}
when (binding.type.text) {
TypedProfile.Type.Local.name -> {
when (binding.fileSourceMenu.text) {
FileSource.Import.formatted -> {
if (binding.sourceURL.showErrorIfEmpty()) {
return
}
}
}
}
TypedProfile.Type.Remote.name -> {
if (binding.remoteURL.showErrorIfEmpty()) {
return
}
}
}
binding.progressView.isVisible = true
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
createProfile0()
}.onFailure { e ->
withContext(Dispatchers.Main) {
binding.progressView.isVisible = false
errorDialogBuilder(e).show()
}
}
}
}
private suspend fun createProfile0() {
val typedProfile = TypedProfile()
val profile = Profile(name = binding.name.text, typed = typedProfile)
profile.userOrder = Profiles.nextOrder()
when (binding.type.text) {
TypedProfile.Type.Local.name -> {
typedProfile.type = TypedProfile.Type.Local
val configDirectory = File(filesDir, "configs").also { it.mkdirs() }
val configFile = File(configDirectory, "${profile.userOrder}.json")
when (binding.fileSourceMenu.text) {
FileSource.CreateNew.formatted -> {
configFile.writeText("{}")
}
FileSource.Import.formatted -> {
val sourceURL = binding.sourceURL.text
val content = if (sourceURL.startsWith("content://")) {
val inputStream =
contentResolver.openInputStream(Uri.parse(sourceURL)) as InputStream
inputStream.use { it.bufferedReader().readText() }
} else if (sourceURL.startsWith("file://")) {
File(sourceURL).readText()
} else if (sourceURL.startsWith("http://") || sourceURL.startsWith("https://")) {
HTTPClient().use { it.getString(sourceURL) }
} else {
error("unsupported source: $sourceURL")
}
Libbox.checkConfig(content)
configFile.writeText(content)
}
}
typedProfile.path = configFile.path
}
TypedProfile.Type.Remote.name -> {
typedProfile.type = TypedProfile.Type.Remote
val configDirectory = File(filesDir, "configs").also { it.mkdirs() }
val configFile = File(configDirectory, "${profile.userOrder}.json")
val remoteURL = binding.remoteURL.text
val content = HTTPClient().use { it.getString(remoteURL) }
Libbox.checkConfig(content)
configFile.writeText(content)
typedProfile.path = configFile.path
typedProfile.remoteURL = remoteURL
typedProfile.lastUpdated = Date()
}
}
Profiles.create(profile)
withContext(Dispatchers.Main) {
binding.progressView.isVisible = false
finish()
}
}
}

View File

@@ -0,0 +1,41 @@
package io.nekohasekai.sfa.ui.shared
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import com.google.android.material.color.DynamicColors
import com.google.android.material.elevation.SurfaceColors
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.ktx.getAttrColor
abstract class AbstractActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DynamicColors.applyToActivityIfAvailable(this)
val color = SurfaceColors.SURFACE_2.getColor(this)
window.statusBarColor = color
window.navigationBarColor = color
supportActionBar?.setHomeAsUpIndicator(AppCompatResources.getDrawable(
this@AbstractActivity,
R.drawable.ic_arrow_back_24
)!!.apply {
setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface))
})
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressedDispatcher.onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
}