/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai
*
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see . *
* *
******************************************************************************/
package io.nekohasekai.sagernet.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.SagerNet
import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.database.ProfileManager
import io.nekohasekai.sagernet.database.RuleEntity
import io.nekohasekai.sagernet.database.SagerDatabase
import io.nekohasekai.sagernet.databinding.LayoutEmptyRouteBinding
import io.nekohasekai.sagernet.databinding.LayoutRouteItemBinding
import io.nekohasekai.sagernet.ktx.*
import io.nekohasekai.sagernet.widget.UndoSnackbarManager
class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItemClickListener {
lateinit var ruleListView: RecyclerView
lateinit var ruleAdapter: RuleAdapter
lateinit var undoManager: UndoSnackbarManager
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
toolbar.setTitle(R.string.menu_route)
toolbar.inflateMenu(R.menu.add_route_menu)
toolbar.setOnMenuItemClickListener(this)
ruleListView = view.findViewById(R.id.route_list)
ruleListView.layoutManager = FixedLinearLayoutManager(ruleListView)
ViewCompat.setOnApplyWindowInsetsListener(ruleListView) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(
left = bars.left + dp2px(4),
right = bars.right + dp2px(4),
bottom = bars.bottom + dp2px(64),
)
insets
}
ruleAdapter = RuleAdapter()
ProfileManager.addListener(ruleAdapter)
ruleListView.adapter = ruleAdapter
undoManager = UndoSnackbarManager(requireActivity() as ThemedActivity, ruleAdapter)
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START) {
override fun getSwipeDirs(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
) = if (viewHolder is RuleAdapter.DocumentHolder) {
0
} else {
super.getSwipeDirs(recyclerView, viewHolder)
}
override fun getDragDirs(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
) = if (viewHolder is RuleAdapter.DocumentHolder) {
0
} else {
super.getDragDirs(recyclerView, viewHolder)
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val index = viewHolder.adapterPosition
ruleAdapter.remove(index)
undoManager.remove(index to (viewHolder as RuleAdapter.RuleHolder).rule)
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder,
): Boolean {
return if (target is RuleAdapter.DocumentHolder) {
false
} else {
ruleAdapter.move(viewHolder.adapterPosition, target.adapterPosition)
true
}
}
override fun clearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
) {
super.clearView(recyclerView, viewHolder)
ruleAdapter.commitMove()
}
}).attachToRecyclerView(ruleListView)
(requireActivity() as? MainActivity)?.onBackPressedCallback?.isEnabled = true
}
override fun onDestroy() {
if (::ruleAdapter.isInitialized) {
ProfileManager.removeListener(ruleAdapter)
}
super.onDestroy()
}
@SuppressLint("CheckResult")
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_new_route -> {
startActivity(Intent(context, RouteSettingsActivity::class.java))
}
R.id.action_disable_all -> {
runOnDefaultDispatcher {
SagerDatabase.rulesDao.enableAll(enabled = false)
ruleAdapter.reload()
onMainDispatcher {
needReload()
}
}
}
R.id.action_enable_all -> {
runOnDefaultDispatcher {
SagerDatabase.rulesDao.enableAll()
ruleAdapter.reload()
onMainDispatcher {
needReload()
}
}
}
R.id.action_import -> {
val text = SagerNet.getClipboardText()
if (text.isEmpty()) {
snackbar(getString(R.string.clipboard_empty)).show()
} else {
runOnDefaultDispatcher {
try {
val rawRules = parseJson(text).asJsonArray
val rules = mutableListOf()
for ((i, rawRule) in rawRules.withIndex()) {
rawRule as JsonObject
rules.add(RuleEntity().apply {
userOrder = (i + 1).toLong()
rawRule.get("locked")?.takeIf { !it.isJsonNull }?.asString
name = rawRule.get("remarks")?.takeIf { !it.isJsonNull }?.asString ?: ""
enabled = rawRule.get("enabled")?.takeIf { !it.isJsonNull }?.asBoolean ?: false
outbound = when (rawRule.get("outboundTag")?.takeIf { !it.isJsonNull }?.asString?.trim()) {
"proxy" -> 0L
"direct" -> -1L
"block" -> -2L
else -> 0L
}
rawRule.get("domain")?.takeIf { !it.isJsonNull }?.let {
domains = Gson().fromJson(it, Array::class.java).mapNotNull { it.trim().ifBlank { null } }.joinToString("\n")
}
rawRule.get("ip")?.takeIf { !it.isJsonNull }?.let {
// Fuck https://github.com/XTLS/Xray-core/pull/5951, I don't care about it.
ip = Gson().fromJson(it, Array::class.java).mapNotNull { it.trim().ifBlank { null } }.joinToString("\n")
}
port = rawRule.get("port")?.takeIf { !it.isJsonNull }?.asString?.trim() ?: ""
network = when (rawRule.get("network")?.takeIf { !it.isJsonNull }?.asString?.lowercase()?.trim()) {
"tcp" -> "tcp"
"udp" -> "udp"
"tcp,udp", "udp,tcp", "" -> ""
else -> ""
}
rawRule.get("protocol")?.takeIf { !it.isJsonNull }?.let {
protocol = Gson().fromJson(it, Array::class.java).mapNotNull { it.trim().ifBlank { null } }.joinToString("\n")
}
rawRule.get("process")?.takeIf { !it.isJsonNull }?.let {
packages = Gson().fromJson(it, Array::class.java).mapNotNull { it.trim().ifBlank { null } }
}
})
}
if (rules.isNotEmpty()) {
onMainDispatcher {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.confirm)
.setMessage(R.string.import_routing_rules_warning)
.setPositiveButton(android.R.string.ok) { _, _ ->
runOnDefaultDispatcher {
SagerDatabase.rulesDao.reset()
SagerDatabase.rulesDao.insert(rules)
ruleAdapter.reload()
needReload()
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
} else {
error(R.string.action_import_err)
}
} catch (e: Exception) {
Logs.w(e)
snackbar(e.readableMessage).show()
}
}
}
}
R.id.action_export -> {
runOnDefaultDispatcher {
try {
val rules = SagerDatabase.rulesDao.allRules()
if (rules.isNotEmpty()) {
val jsonArray = JsonArray()
for (rule in rules) {
val protocols = JsonArray().apply {
if (rule.protocol.isNotEmpty()) {
rule.protocol.listByLineOrComma().forEach {
when (it) {
"http", "tls", "quic", "bittorrent" -> add(it)
}
}
}
}
val processes = JsonArray().apply {
if (rule.customPackageNames.isNotEmpty()) {
rule.customPackageNames.forEach { if (it.toIntOrNull() == null) add(it) }
} else if (rule.packages.isNotEmpty()) {
rule.packages.forEach { add(it) }
}
}
if (rule.domains.isEmpty() && rule.ip.isEmpty() && rule.port.isEmpty() && protocols.isEmpty && processes.isEmpty) {
continue
}
jsonArray.add(JsonObject().apply {
addProperty("locked", false)
addProperty("remarks", rule.displayName())
addProperty("enabled", rule.enabled)
addProperty("outboundTag", when (rule.outbound) {
0L -> "proxy"
-1L -> "direct"
-2L -> "block"
else -> "proxy"
})
if (rule.domains.isNotEmpty()) {
add("domain", JsonArray().apply {
rule.domains.listByLineOrComma().forEach {
add(it)
}
})
}
if (rule.ip.isNotEmpty()) {
add("ip", JsonArray().apply {
rule.ip.listByLineOrComma().forEach {
add(it)
}
})
}
if (rule.port.isNotEmpty()) {
addProperty("port", rule.port)
}
when (rule.network) {
"tcp" -> addProperty("network", "tcp")
"udp" -> addProperty("network", "udp")
}
if (!protocols.isEmpty) {
add("protocol", protocols)
}
if (!processes.isEmpty) {
add("process", processes)
}
})
}
if (!jsonArray.isEmpty) {
onMainDispatcher {
if (SagerNet.trySetPrimaryClip(jsonArray.toString())) {
if (DataStore.doNotShowRuleExportWarning) {
snackbar(R.string.action_export_msg).show()
} else {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.action_export_msg)
.setMessage(R.string.export_routing_rules_warning)
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(R.string.do_not_show_again) { _, _ ->
DataStore.doNotShowRuleExportWarning = true
}
.show()
}
}
}
}
}
} catch (e: Exception) {
Logs.w(e)
snackbar(e.readableMessage).show()
}
}
}
R.id.action_reset_route -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.route_reset)
.setPositiveButton(android.R.string.ok) { _, _ ->
runOnDefaultDispatcher {
SagerDatabase.rulesDao.reset()
DataStore.rulesFirstCreate = false
ruleAdapter.reload()
onMainDispatcher {
needReload()
}
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
R.id.action_manage_assets -> {
startActivity(Intent(requireContext(), AssetsActivity::class.java))
}
}
return true
}
inner class RuleAdapter : RecyclerView.Adapter(), ProfileManager.RuleListener, UndoSnackbarManager.Interface {
val ruleList = ArrayList()
suspend fun reload() {
val rules = ProfileManager.getRules()
ruleListView.post {
ruleList.clear()
ruleList.addAll(rules)
ruleAdapter.notifyDataSetChanged()
}
}
init {
runOnDefaultDispatcher {
reload()
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): RecyclerView.ViewHolder {
return if (viewType == 0) {
DocumentHolder(LayoutEmptyRouteBinding.inflate(layoutInflater, parent, false))
} else {
RuleHolder(LayoutRouteItemBinding.inflate(layoutInflater, parent, false))
}
}
override fun getItemViewType(position: Int): Int {
if (position == 0) return 0
return 1
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is DocumentHolder) {
holder.bind()
} else if (holder is RuleHolder) {
holder.bind(ruleList[position - 1])
}
}
override fun getItemCount(): Int {
return ruleList.size + 1
}
override fun getItemId(position: Int): Long {
if (position == 0) return 0L
return ruleList[position - 1].id
}
private val updated = HashSet()
fun move(from: Int, to: Int) {
val first = ruleList[from - 1]
var previousOrder = first.userOrder
val (step, range) = if (from < to) Pair(1, from - 1 until to - 1) else Pair(-1, to downTo from - 1)
for (i in range) {
val next = ruleList[i + step]
val order = next.userOrder
next.userOrder = previousOrder
previousOrder = order
ruleList[i] = next
updated.add(next)
}
first.userOrder = previousOrder
ruleList[to - 1] = first
updated.add(first)
notifyItemMoved(from, to)
}
fun commitMove() = runOnDefaultDispatcher {
if (updated.isNotEmpty()) {
SagerDatabase.rulesDao.updateRules(updated.toList())
updated.clear()
needReload()
}
}
fun remove(index: Int) {
ruleList.removeAt(index - 1)
notifyItemRemoved(index)
}
override fun undo(actions: List>) {
for ((index, item) in actions) {
ruleList.add(index - 1, item)
notifyItemInserted(index)
}
}
override fun commit(actions: List>) {
val rules = actions.map { it.second }
runOnDefaultDispatcher {
ProfileManager.deleteRules(rules)
}
}
override suspend fun onAdd(rule: RuleEntity) {
ruleListView.post {
ruleList.add(rule)
ruleAdapter.notifyItemInserted(ruleList.size)
needReload()
}
}
override suspend fun onUpdated(rule: RuleEntity) {
val index = ruleList.indexOfFirst { it.id == rule.id }
if (index == -1) return
ruleListView.post {
ruleList[index] = rule
ruleAdapter.notifyItemChanged(index + 1)
needReload()
}
}
override suspend fun onRemoved(ruleId: Long) {
val index = ruleList.indexOfFirst { it.id == ruleId }
if (index == -1) {
onMainDispatcher {
needReload()
}
} else ruleListView.post {
ruleList.removeAt(index)
ruleAdapter.notifyItemRemoved(index + 1)
needReload()
}
}
override suspend fun onCleared() {
ruleListView.post {
ruleList.clear()
ruleAdapter.notifyDataSetChanged()
needReload()
}
}
inner class DocumentHolder(binding: LayoutEmptyRouteBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind() {
itemView.setOnClickListener {
startActivity(Intent(
Intent.ACTION_VIEW,
"https://www.v2fly.org/config/routing.html#ruleobject".toUri()
))
}
}
}
inner class RuleHolder(binding: LayoutRouteItemBinding) : RecyclerView.ViewHolder(binding.root) {
lateinit var rule: RuleEntity
val profileName = binding.profileName
val profileType = binding.profileType
val routeOutbound = binding.routeOutbound
val editButton = binding.edit
val shareLayout = binding.share
val enableSwitch = binding.enable
fun bind(ruleEntity: RuleEntity) {
rule = ruleEntity
profileName.text = rule.displayName()
profileType.text = rule.mkSummary()
routeOutbound.text = rule.displayOutbound()
itemView.setOnClickListener {
enableSwitch.performClick()
}
enableSwitch.setOnCheckedChangeListener(null)
enableSwitch.isChecked = rule.enabled
enableSwitch.setOnCheckedChangeListener { _, isChecked ->
runOnDefaultDispatcher {
rule.enabled = isChecked
SagerDatabase.rulesDao.updateRule(rule)
onMainDispatcher {
needReload()
}
}
}
editButton.setOnClickListener {
startActivity(Intent(it.context, RouteSettingsActivity::class.java).apply {
putExtra(RouteSettingsActivity.EXTRA_ROUTE_ID, rule.id)
})
}
}
}
}
}