Improve dashboard
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package io.nekohasekai.sfa.ktx
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.TypedValue
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
@@ -15,3 +16,16 @@ fun Context.getAttrColor(
|
||||
theme.resolveAttribute(attrColor, typedValue, resolveRefs)
|
||||
return typedValue.data
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun colorForURLTestDelay(urlTestDelay: Int): Int {
|
||||
return if (urlTestDelay <= 0) {
|
||||
Color.GRAY
|
||||
} else if (urlTestDelay <= 800) {
|
||||
Color.GREEN
|
||||
} else if (urlTestDelay <= 1500) {
|
||||
Color.YELLOW
|
||||
} else {
|
||||
Color.RED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package io.nekohasekai.sfa.ui.dashboard
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
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.OutboundGroup
|
||||
import io.nekohasekai.libbox.OutboundGroupItem
|
||||
import io.nekohasekai.libbox.OutboundGroupIterator
|
||||
import io.nekohasekai.libbox.StatusMessage
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.databinding.FragmentDashboardGroupsBinding
|
||||
import io.nekohasekai.sfa.databinding.ViewDashboardGroupBinding
|
||||
import io.nekohasekai.sfa.databinding.ViewDashboardGroupItemBinding
|
||||
import io.nekohasekai.sfa.ktx.colorForURLTestDelay
|
||||
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
||||
import io.nekohasekai.sfa.ui.MainActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
class GroupsFragment : Fragment(), CommandClientHandler {
|
||||
|
||||
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
|
||||
private var _binding: FragmentDashboardGroupsBinding? = 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 = FragmentDashboardGroupsBinding.inflate(inflater, container, false)
|
||||
onCreate()
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun onCreate() {
|
||||
val activity = activity ?: return
|
||||
_adapter = Adapter()
|
||||
binding.container.adapter = adapter
|
||||
binding.container.layoutManager = LinearLayoutManager(requireContext())
|
||||
activity.serviceStatus.observe(viewLifecycleOwner) {
|
||||
if (it == Status.Started) {
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconnect() {
|
||||
disconnect()
|
||||
val options = CommandClientOptions()
|
||||
options.command = Libbox.CommandGroup
|
||||
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 connected() {
|
||||
val binding = _binding ?: return
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
binding.statusText.isVisible = false
|
||||
binding.container.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnected(message: String?) {
|
||||
val binding = _binding ?: return
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
binding.statusText.isVisible = true
|
||||
binding.container.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun writeGroups(message: OutboundGroupIterator) {
|
||||
val groups = mutableListOf<OutboundGroup>()
|
||||
while (message.hasNext()) {
|
||||
groups.add(message.next())
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
adapter.groups = groups
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun writeLog(message: String?) {
|
||||
}
|
||||
|
||||
override fun writeStatus(message: StatusMessage?) {
|
||||
}
|
||||
|
||||
private class Adapter : RecyclerView.Adapter<GroupView>() {
|
||||
|
||||
lateinit var groups: List<OutboundGroup>
|
||||
private val expandStatus: MutableMap<String, Boolean> = mutableMapOf()
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupView {
|
||||
return GroupView(
|
||||
ViewDashboardGroupBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
),
|
||||
expandStatus
|
||||
)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
if (!::groups.isInitialized) {
|
||||
return 0
|
||||
}
|
||||
return groups.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: GroupView, position: Int) {
|
||||
holder.bind(groups[position])
|
||||
}
|
||||
}
|
||||
|
||||
private class GroupView(
|
||||
val binding: ViewDashboardGroupBinding,
|
||||
val expandStatus: MutableMap<String, Boolean>
|
||||
) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
lateinit var group: OutboundGroup
|
||||
lateinit var items: MutableList<OutboundGroupItem>
|
||||
lateinit var adapter: ItemAdapter
|
||||
fun bind(group: OutboundGroup) {
|
||||
this.group = group
|
||||
binding.groupName.text = group.tag
|
||||
binding.groupType.text = Libbox.proxyDisplayType(group.type)
|
||||
binding.urlTestButton.setOnClickListener {
|
||||
GlobalScope.launch {
|
||||
runCatching {
|
||||
Libbox.newStandaloneCommandClient().urlTest(group.tag)
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.root.context.errorDialogBuilder(it).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items = mutableListOf()
|
||||
val itemIterator = group.items
|
||||
while (itemIterator.hasNext()) {
|
||||
items.add(itemIterator.next())
|
||||
}
|
||||
adapter = ItemAdapter(this, group, items)
|
||||
binding.itemList.adapter = adapter
|
||||
(binding.itemList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
binding.itemList.layoutManager = GridLayoutManager(binding.root.context, 2)
|
||||
updateExpand()
|
||||
}
|
||||
|
||||
private fun updateExpand(isExpand: Boolean? = null) {
|
||||
val newExpandStatus: Boolean
|
||||
if (isExpand == null) {
|
||||
newExpandStatus = expandStatus[group.tag] ?: group.selectable
|
||||
} else {
|
||||
expandStatus[group.tag] = isExpand
|
||||
newExpandStatus = isExpand
|
||||
}
|
||||
binding.itemList.isVisible = newExpandStatus
|
||||
binding.itemText.isVisible = !newExpandStatus
|
||||
if (!newExpandStatus) {
|
||||
val builder = SpannableStringBuilder()
|
||||
items.forEach {
|
||||
builder.append("■")
|
||||
builder.setSpan(
|
||||
ForegroundColorSpan(colorForURLTestDelay(it.urlTestDelay)),
|
||||
builder.length - 1,
|
||||
builder.length,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
builder.append(" ")
|
||||
}
|
||||
binding.itemText.text = builder
|
||||
}
|
||||
if (newExpandStatus) {
|
||||
binding.expandButton.setImageResource(R.drawable.ic_expand_less_24)
|
||||
} else {
|
||||
binding.expandButton.setImageResource(R.drawable.ic_expand_more_24)
|
||||
}
|
||||
binding.expandButton.setOnClickListener {
|
||||
updateExpand(!binding.itemList.isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSelected(group: OutboundGroup, item: OutboundGroupItem) {
|
||||
val oldSelected = items.indexOfFirst { it.tag == group.selected }
|
||||
group.selected = item.tag
|
||||
if (oldSelected != -1) {
|
||||
adapter.notifyItemChanged(oldSelected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ItemAdapter(
|
||||
val groupView: GroupView,
|
||||
val group: OutboundGroup,
|
||||
val items: List<OutboundGroupItem>
|
||||
) :
|
||||
RecyclerView.Adapter<ItemGroupView>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemGroupView {
|
||||
return ItemGroupView(
|
||||
ViewDashboardGroupItemBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return items.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ItemGroupView, position: Int) {
|
||||
holder.bind(groupView, group, items[position])
|
||||
}
|
||||
}
|
||||
|
||||
private class ItemGroupView(val binding: ViewDashboardGroupItemBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(groupView: GroupView, group: OutboundGroup, item: OutboundGroupItem) {
|
||||
binding.itemCard.setOnClickListener {
|
||||
binding.selectedView.isVisible = true
|
||||
groupView.updateSelected(group, item)
|
||||
GlobalScope.launch {
|
||||
runCatching {
|
||||
Libbox.newStandaloneCommandClient().selectOutbound(group.tag, item.tag)
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.root.context.errorDialogBuilder(it).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.selectedView.isInvisible = group.selected != item.tag
|
||||
binding.itemName.text = item.tag
|
||||
binding.itemType.text = Libbox.proxyDisplayType(item.type)
|
||||
binding.itemStatus.isVisible = item.urlTestTime > 0
|
||||
if (item.urlTestTime > 0) {
|
||||
binding.itemStatus.text = "${item.urlTestDelay}ms"
|
||||
binding.itemStatus.setTextColor(colorForURLTestDelay(item.urlTestDelay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package io.nekohasekai.sfa.ui.dashboard
|
||||
|
||||
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.OutboundGroupIterator
|
||||
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.ProfileManager
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.databinding.FragmentDashboardOverviewBinding
|
||||
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 OverviewFragment : Fragment(), CommandClientHandler {
|
||||
|
||||
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
|
||||
private var _binding: FragmentDashboardOverviewBinding? = 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 = FragmentDashboardOverviewBinding.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.statusContainer.isVisible = it == Status.Starting || it == Status.Started
|
||||
if (it == Status.Started) {
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
ProfileManager.registerCallback(this::updateProfiles)
|
||||
}
|
||||
|
||||
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()
|
||||
ProfileManager.unregisterCallback(this::updateProfiles)
|
||||
}
|
||||
|
||||
private fun updateProfiles() {
|
||||
_adapter?.reload()
|
||||
}
|
||||
|
||||
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()
|
||||
val trafficAvailable = message.trafficAvailable
|
||||
binding.trafficContainer.isVisible = trafficAvailable
|
||||
if (trafficAvailable) {
|
||||
binding.inboundConnectionsText.text = message.connectionsIn.toString()
|
||||
binding.outboundConnectionsText.text = message.connectionsOut.toString()
|
||||
binding.uplinkText.text = Libbox.formatBytes(message.uplink) + "/s"
|
||||
binding.downlinkText.text = Libbox.formatBytes(message.downlink) + "/s"
|
||||
binding.uplinkTotalText.text = Libbox.formatBytes(message.uplinkTotal)
|
||||
binding.downlinkTotalText.text = Libbox.formatBytes(message.downlinkTotal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun writeGroups(message: OutboundGroupIterator?) {
|
||||
}
|
||||
|
||||
class Adapter(
|
||||
internal val scope: CoroutineScope,
|
||||
private val parent: FragmentDashboardOverviewBinding
|
||||
) :
|
||||
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 = ProfileManager.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.newStandaloneCommandClient().serviceReload()
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
mainActivity.errorDialogBuilder(it).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,44 +4,24 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
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.OutboundGroupIterator
|
||||
import io.nekohasekai.libbox.StatusMessage
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
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.ProfileManager
|
||||
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
|
||||
import io.nekohasekai.sfa.ui.dashboard.GroupsFragment
|
||||
import io.nekohasekai.sfa.ui.dashboard.OverviewFragment
|
||||
|
||||
class DashboardFragment : Fragment(), CommandClientHandler {
|
||||
class DashboardFragment : Fragment(R.layout.fragment_dashboard) {
|
||||
|
||||
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?
|
||||
@@ -53,22 +33,17 @@ class DashboardFragment : Fragment(), CommandClientHandler {
|
||||
|
||||
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)
|
||||
|
||||
binding.dashboardPager.adapter = Adapter(this)
|
||||
TabLayoutMediator(binding.dashboardTabLayout, binding.dashboardPager) { tab, position ->
|
||||
tab.setText(Page.values()[position].titleRes)
|
||||
}.attach()
|
||||
activity.serviceStatus.observe(viewLifecycleOwner) {
|
||||
binding.statusContainer.isVisible = it == Status.Starting || it == Status.Started
|
||||
when (it) {
|
||||
Status.Stopped -> {
|
||||
binding.fab.setImageResource(R.drawable.ic_play_arrow_24)
|
||||
binding.fab.show()
|
||||
binding.dashboardTabLayout.isVisible = false
|
||||
binding.dashboardPager.isUserInputEnabled = false
|
||||
}
|
||||
|
||||
Status.Starting -> {
|
||||
@@ -78,7 +53,8 @@ class DashboardFragment : Fragment(), CommandClientHandler {
|
||||
Status.Started -> {
|
||||
binding.fab.setImageResource(R.drawable.ic_stop_24)
|
||||
binding.fab.show()
|
||||
reconnect()
|
||||
binding.dashboardTabLayout.isVisible = true
|
||||
binding.dashboardPager.isUserInputEnabled = true
|
||||
}
|
||||
|
||||
Status.Stopping -> {
|
||||
@@ -101,196 +77,20 @@ class DashboardFragment : Fragment(), CommandClientHandler {
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
ProfileManager.registerCallback(this::updateProfiles)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
enum class Page(@StringRes val titleRes: Int, val fragmentClass: Class<out Fragment>) {
|
||||
Overview(R.string.title_overview, OverviewFragment::class.java),
|
||||
Groups(R.string.title_groups, GroupsFragment::class.java);
|
||||
}
|
||||
|
||||
private fun disconnect() {
|
||||
commandClient?.apply {
|
||||
runCatching {
|
||||
disconnect()
|
||||
}
|
||||
Seq.destroyRef(refnum)
|
||||
}
|
||||
commandClient = null
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_adapter = null
|
||||
_binding = null
|
||||
disconnect()
|
||||
ProfileManager.unregisterCallback(this::updateProfiles)
|
||||
}
|
||||
|
||||
private fun updateProfiles() {
|
||||
_adapter?.reload()
|
||||
}
|
||||
|
||||
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()
|
||||
val trafficAvailable = message.trafficAvailable
|
||||
binding.trafficContainer.isVisible = trafficAvailable
|
||||
if (trafficAvailable) {
|
||||
binding.inboundConnectionsText.text = message.connectionsIn.toString()
|
||||
binding.outboundConnectionsText.text = message.connectionsOut.toString()
|
||||
binding.uplinkText.text = Libbox.formatBytes(message.uplink) + "/s"
|
||||
binding.downlinkText.text = Libbox.formatBytes(message.downlink) + "/s"
|
||||
binding.uplinkTotalText.text = Libbox.formatBytes(message.uplinkTotal)
|
||||
binding.downlinkTotalText.text = Libbox.formatBytes(message.downlinkTotal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun writeGroups(message: OutboundGroupIterator?) {
|
||||
}
|
||||
|
||||
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 = ProfileManager.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])
|
||||
}
|
||||
|
||||
class Adapter(parent: Fragment) : FragmentStateAdapter(parent) {
|
||||
override fun getItemCount(): Int {
|
||||
return items.size
|
||||
return Page.values().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.newStandaloneCommandClient().serviceReload()
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
mainActivity.errorDialogBuilder(it).show()
|
||||
}
|
||||
}
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return Page.values()[position].fragmentClass.newInstance()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user