Initial draft for porting this project to Gradle + Kotlin
This is one of my stupidest idea ever.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/*******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
|
||||
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
||||
* *
|
||||
* 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 <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
*******************************************************************************/
|
||||
|
||||
package com.github.shadowsocks.acl
|
||||
|
||||
import android.support.v7.util.SortedList
|
||||
import android.util.Log
|
||||
import com.github.shadowsocks.App.Companion.app
|
||||
import com.github.shadowsocks.utils.Subnet
|
||||
import com.github.shadowsocks.utils.asIterable
|
||||
import com.j256.ormlite.field.DatabaseField
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.Reader
|
||||
import java.net.URL
|
||||
|
||||
class Acl {
|
||||
companion object {
|
||||
const val TAG = "Acl"
|
||||
const val ALL = "all"
|
||||
const val BYPASS_LAN = "bypass-lan"
|
||||
const val BYPASS_CHN = "bypass-china"
|
||||
const val BYPASS_LAN_CHN = "bypass-lan-china"
|
||||
const val GFWLIST = "gfwlist"
|
||||
const val CHINALIST = "china-list"
|
||||
const val CUSTOM_RULES = "custom-rules"
|
||||
const val CUSTOM_RULES_FLATTENED = "custom-rules-flattened"
|
||||
|
||||
val networkAclParser = "^IMPORT_URL\\s*<(.+)>\\s*$".toRegex()
|
||||
|
||||
fun getFile(id: String) = File(app.filesDir, id + ".acl")
|
||||
val customRules: Acl get() {
|
||||
val acl = Acl()
|
||||
try {
|
||||
acl.fromId(CUSTOM_RULES)
|
||||
} catch (_: FileNotFoundException) { }
|
||||
acl.bypass = true
|
||||
acl.bypassHostnames.clear() // everything is bypassed
|
||||
return acl
|
||||
}
|
||||
fun save(id: String, acl: Acl): Unit = getFile(id).bufferedWriter().use { it.write(acl.toString()) }
|
||||
}
|
||||
|
||||
private abstract class BaseSorter<T> : SortedList.Callback<T>() {
|
||||
override fun onInserted(position: Int, count: Int) { }
|
||||
override fun areContentsTheSame(oldItem: T?, newItem: T?): Boolean = oldItem == newItem
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) { }
|
||||
override fun onChanged(position: Int, count: Int) { }
|
||||
override fun onRemoved(position: Int, count: Int) { }
|
||||
override fun areItemsTheSame(item1: T?, item2: T?): Boolean = item1 == item2
|
||||
override fun compare(o1: T?, o2: T?): Int =
|
||||
if (o1 == null) if (o2 == null) 0 else 1 else if (o2 == null) -1 else compareNonNull(o1, o2)
|
||||
abstract fun compareNonNull(o1: T, o2: T): Int
|
||||
}
|
||||
private open class DefaultSorter<T : Comparable<T>> : BaseSorter<T>() {
|
||||
override fun compareNonNull(o1: T, o2: T): Int = o1.compareTo(o2)
|
||||
}
|
||||
private object StringSorter : DefaultSorter<String>()
|
||||
private object SubnetSorter : DefaultSorter<Subnet>()
|
||||
private object URLSorter : BaseSorter<URL>() {
|
||||
private val ordering = compareBy<URL>({ it.host }, { it.port }, { it.file }, { it.protocol })
|
||||
override fun compareNonNull(o1: URL, o2: URL): Int = ordering.compare(o1, o2)
|
||||
}
|
||||
|
||||
@DatabaseField(generatedId = true)
|
||||
var id = 0
|
||||
val bypassHostnames = SortedList(String::class.java, StringSorter)
|
||||
val proxyHostnames = SortedList(String::class.java, StringSorter)
|
||||
val subnets = SortedList(Subnet::class.java, SubnetSorter)
|
||||
val urls = SortedList(URL::class.java, URLSorter)
|
||||
|
||||
@DatabaseField
|
||||
var bypass = false
|
||||
|
||||
fun fromAcl(other: Acl): Acl {
|
||||
bypassHostnames.clear()
|
||||
for (item in other.bypassHostnames.asIterable()) bypassHostnames.add(item)
|
||||
proxyHostnames.clear()
|
||||
for (item in other.proxyHostnames.asIterable()) proxyHostnames.add(item)
|
||||
subnets.clear()
|
||||
for (item in other.subnets.asIterable()) subnets.add(item)
|
||||
urls.clear()
|
||||
for (item in other.urls.asIterable()) urls.add(item)
|
||||
bypass = other.bypass
|
||||
return this
|
||||
}
|
||||
fun fromReader(reader: Reader, defaultBypass: Boolean = false): Acl {
|
||||
bypassHostnames.clear()
|
||||
proxyHostnames.clear()
|
||||
subnets.clear()
|
||||
urls.clear()
|
||||
bypass = defaultBypass
|
||||
val bypassSubnets by lazy { SortedList(Subnet::class.java, SubnetSorter) }
|
||||
val proxySubnets by lazy { SortedList(Subnet::class.java, SubnetSorter) }
|
||||
var hostnames: SortedList<String>? = if (defaultBypass) proxyHostnames else bypassHostnames
|
||||
var subnets: SortedList<Subnet>? = if (defaultBypass) proxySubnets else bypassSubnets
|
||||
reader.useLines {
|
||||
for (line in it) {
|
||||
val blocks = line.split('#', limit = 2)
|
||||
val url = networkAclParser.matchEntire(blocks.getOrElse(1, {""}))?.groupValues?.getOrNull(1)
|
||||
if (url != null) urls.add(URL(url))
|
||||
val input = blocks[0].trim()
|
||||
when (input) {
|
||||
"[outbound_block_list]" -> {
|
||||
hostnames = null
|
||||
subnets = null
|
||||
}
|
||||
"[black_list]", "[bypass_list]" -> {
|
||||
hostnames = bypassHostnames
|
||||
subnets = bypassSubnets
|
||||
}
|
||||
"[white_list]", "[proxy_list]" -> {
|
||||
hostnames = proxyHostnames
|
||||
subnets = proxySubnets
|
||||
}
|
||||
"[reject_all]", "[bypass_all]" -> bypass = true
|
||||
"[accept_all]", "[proxy_all]" -> bypass = false
|
||||
else -> if (subnets != null && input.isNotEmpty()) try {
|
||||
subnets!!.add(Subnet.fromString(input))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
hostnames!!.add(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (item in (if (bypass) proxySubnets else bypassSubnets).asIterable()) this.subnets.add(item)
|
||||
return this
|
||||
}
|
||||
fun fromId(id: String): Acl = fromReader(Acl.getFile(id).bufferedReader())
|
||||
|
||||
fun flatten(depth: Int): Acl {
|
||||
if (depth > 0) for (url in urls.asIterable()) {
|
||||
val child = Acl().fromReader(url.openStream().bufferedReader(), bypass).flatten(depth - 1)
|
||||
if (bypass != child.bypass) {
|
||||
Log.w(TAG, "Imported network ACL has a conflicting mode set. " +
|
||||
"This will probably not work as intended. URL: $url")
|
||||
child.subnets.clear() // subnets for the different mode are discarded
|
||||
child.bypass = bypass
|
||||
}
|
||||
for (item in child.bypassHostnames.asIterable()) bypassHostnames.add(item)
|
||||
for (item in child.proxyHostnames.asIterable()) proxyHostnames.add(item)
|
||||
for (item in child.subnets.asIterable()) subnets.add(item)
|
||||
}
|
||||
urls.clear()
|
||||
return this
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val result = StringBuilder()
|
||||
result.append(if (bypass) "[bypass_all]\n" else "[proxy_all]\n")
|
||||
val bypassList = (if (bypass) bypassHostnames.asIterable().asSequence() else
|
||||
subnets.asIterable().asSequence().map(Subnet::toString) + proxyHostnames.asIterable().asSequence()).toList()
|
||||
val proxyList = (if (bypass) subnets.asIterable().asSequence().map(Subnet::toString) +
|
||||
proxyHostnames.asIterable().asSequence() else bypassHostnames.asIterable().asSequence()).toList()
|
||||
if (bypassList.isNotEmpty()) {
|
||||
result.append("[bypass_list]\n")
|
||||
result.append(bypassList.joinToString("\n"))
|
||||
result.append('\n')
|
||||
}
|
||||
if (proxyList.isNotEmpty()) {
|
||||
result.append("[proxy_list]\n")
|
||||
result.append(proxyList.joinToString("\n"))
|
||||
result.append('\n')
|
||||
}
|
||||
result.append(urls.asIterable().joinToString("") { "#IMPORT_URL <$it>\n" })
|
||||
return result.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
|
||||
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
||||
* *
|
||||
* 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 <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
*******************************************************************************/
|
||||
|
||||
package com.github.shadowsocks.acl
|
||||
|
||||
import android.util.Log
|
||||
import com.evernote.android.job.Job
|
||||
import com.evernote.android.job.JobCreator
|
||||
import com.evernote.android.job.JobRequest
|
||||
import java.io.IOException
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AclSyncJob(private val route: String) : Job() {
|
||||
companion object : JobCreator {
|
||||
private const val TAG = "AclSyncJob"
|
||||
private const val MAX_RESCHEDULE = 3
|
||||
|
||||
override fun create(tag: String): AclSyncJob? {
|
||||
val parts = tag.split(':', limit = 2)
|
||||
return when (parts[0]) {
|
||||
TAG -> AclSyncJob(parts[1])
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown job tag: " + tag)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun schedule(route: String): Int = JobRequest.Builder(TAG + ':' + route)
|
||||
.setExecutionWindow(TimeUnit.SECONDS.toMillis(10), TimeUnit.DAYS.toMillis(28))
|
||||
.setRequirementsEnforced(true)
|
||||
.setRequiredNetworkType(JobRequest.NetworkType.UNMETERED)
|
||||
.setRequiresCharging(true)
|
||||
.setUpdateCurrent(true)
|
||||
.build().schedule()
|
||||
}
|
||||
|
||||
override fun onRunJob(params: Params): Result = try {
|
||||
val acl = URL("https://shadowsocks.org/acl/android/v1/$route.acl").openStream().bufferedReader()
|
||||
.use { it.readText() }
|
||||
Acl.getFile(route).printWriter().use { it.write(acl) }
|
||||
Result.SUCCESS
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
if (params.failureCount < AclSyncJob.MAX_RESCHEDULE) Result.RESCHEDULE else Result.FAILURE
|
||||
} catch (e: Exception) { // unknown failures, probably shouldn't retry
|
||||
e.printStackTrace()
|
||||
Result.FAILURE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/*******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
|
||||
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
||||
* *
|
||||
* 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 <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
*******************************************************************************/
|
||||
|
||||
package com.github.shadowsocks.acl
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v7.app.AlertDialog
|
||||
import android.support.v7.widget.DefaultItemAnimator
|
||||
import android.support.v7.widget.LinearLayoutManager
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.support.v7.widget.Toolbar
|
||||
import android.support.v7.widget.helper.ItemTouchHelper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import com.futuremind.recyclerviewfastscroll.FastScroller
|
||||
import com.futuremind.recyclerviewfastscroll.SectionTitleProvider
|
||||
import com.github.shadowsocks.App.Companion.app
|
||||
import com.github.shadowsocks.MainActivity
|
||||
import com.github.shadowsocks.R
|
||||
import com.github.shadowsocks.ToolbarFragment
|
||||
import com.github.shadowsocks.bg.BaseService
|
||||
import com.github.shadowsocks.utils.Subnet
|
||||
import com.github.shadowsocks.utils.asIterable
|
||||
import com.github.shadowsocks.widget.UndoSnackbarManager
|
||||
import java.net.IDN
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
|
||||
class CustomRulesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
|
||||
companion object {
|
||||
private const val TEMPLATE_REGEX_DOMAIN = "(^|\\.)%s$"
|
||||
|
||||
private const val SELECTED_SUBNETS = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_SUBNETS"
|
||||
private const val SELECTED_HOSTNAMES = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_HOSTNAMES"
|
||||
private const val SELECTED_URLS = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_URLS"
|
||||
|
||||
private val PATTERN_DOMAIN = """(?<=^\(\^\|\\\.\)).*(?=\$$)""".toRegex()
|
||||
}
|
||||
|
||||
private enum class Template {
|
||||
Generic,
|
||||
Domain,
|
||||
Url;
|
||||
}
|
||||
|
||||
private inner class AclRuleViewHolder(view: View) : RecyclerView.ViewHolder(view),
|
||||
View.OnClickListener, View.OnLongClickListener {
|
||||
lateinit var item: Any
|
||||
private val text = view.findViewById<TextView>(android.R.id.text1)
|
||||
|
||||
init {
|
||||
view.setOnClickListener(this)
|
||||
view.setOnLongClickListener(this)
|
||||
view.setBackgroundResource(R.drawable.background_selectable)
|
||||
}
|
||||
|
||||
fun bind(hostname: String) {
|
||||
item = hostname
|
||||
text.text = hostname
|
||||
itemView.isSelected = selectedItems.contains(hostname)
|
||||
}
|
||||
fun bind(subnet: Subnet) {
|
||||
item = subnet
|
||||
text.text = subnet.toString()
|
||||
itemView.isSelected = selectedItems.contains(subnet)
|
||||
}
|
||||
fun bind(url: URL) {
|
||||
item = url
|
||||
text.text = url.toString()
|
||||
itemView.isSelected = selectedItems.contains(url)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
if (selectedItems.isNotEmpty()) onLongClick(v) else {
|
||||
val (templateSelector, editText, dialog) = createAclRuleDialog(item)
|
||||
dialog
|
||||
.setNeutralButton(R.string.delete, { _, _ ->
|
||||
adapter.remove(item)
|
||||
undoManager.remove(Pair(-1, item))
|
||||
})
|
||||
.setPositiveButton(android.R.string.ok, { _, _ ->
|
||||
adapter.remove(item)
|
||||
val index = adapter.addFromTemplate(templateSelector.selectedItemPosition,
|
||||
editText.text.toString()) ?: adapter.add(item)
|
||||
if (index != null) list.post { list.scrollToPosition(index) }
|
||||
})
|
||||
.create().show()
|
||||
}
|
||||
}
|
||||
override fun onLongClick(p0: View?): Boolean {
|
||||
if (!selectedItems.add(item)) selectedItems.remove(item) // toggle
|
||||
onSelectedItemsUpdated()
|
||||
itemView.isSelected = !itemView.isSelected
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private inner class AclRulesAdapter : RecyclerView.Adapter<AclRuleViewHolder>(), SectionTitleProvider {
|
||||
private val acl = Acl.customRules
|
||||
private var savePending = false
|
||||
|
||||
override fun onBindViewHolder(holder: AclRuleViewHolder, i: Int) {
|
||||
val j = i - acl.subnets.size()
|
||||
if (j < 0) holder.bind(acl.subnets[i]) else {
|
||||
val k = j - acl.proxyHostnames.size()
|
||||
if (k < 0) holder.bind(acl.proxyHostnames[j]) else holder.bind(acl.urls[k])
|
||||
}
|
||||
}
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AclRuleViewHolder(LayoutInflater
|
||||
.from(parent.context).inflate(android.R.layout.simple_list_item_1, parent, false))
|
||||
override fun getItemCount(): Int = acl.subnets.size() + acl.proxyHostnames.size() + acl.urls.size()
|
||||
override fun getSectionTitle(i: Int): String {
|
||||
val j = i - acl.subnets.size()
|
||||
return try {
|
||||
(if (j < 0) acl.subnets[i].address.hostAddress.substring(0, 1) else {
|
||||
val k = j - acl.proxyHostnames.size()
|
||||
if (k < 0) {
|
||||
val hostname = acl.proxyHostnames[j]
|
||||
// don't convert IDN yet
|
||||
PATTERN_DOMAIN.find(hostname)?.value?.replace("\\.", ".") ?: hostname
|
||||
} else acl.urls[k].host
|
||||
}).substring(0, 1)
|
||||
} catch (_: IndexOutOfBoundsException) { " " }
|
||||
}
|
||||
|
||||
private fun apply() {
|
||||
if (!savePending) {
|
||||
savePending = true
|
||||
list.post {
|
||||
Acl.save(Acl.CUSTOM_RULES, acl)
|
||||
savePending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun add(item: Any): Int? = when (item) {
|
||||
is Subnet -> addSubnet(item)
|
||||
is String -> addHostname(item)
|
||||
is URL -> addURL(item)
|
||||
else -> null
|
||||
}
|
||||
fun addSubnet(subnet: Subnet): Int {
|
||||
val old = acl.subnets.size()
|
||||
val index = acl.subnets.add(subnet)
|
||||
if (old != acl.subnets.size()) {
|
||||
notifyItemInserted(index)
|
||||
apply()
|
||||
}
|
||||
return index
|
||||
}
|
||||
fun addHostname(hostname: String): Int {
|
||||
val old = acl.proxyHostnames.size()
|
||||
val index = acl.subnets.size() + acl.proxyHostnames.add(hostname)
|
||||
if (old != acl.proxyHostnames.size()) {
|
||||
notifyItemInserted(index)
|
||||
apply()
|
||||
}
|
||||
return index
|
||||
}
|
||||
fun addURL(url: URL): Int {
|
||||
val old = acl.urls.size()
|
||||
val index = acl.subnets.size() + acl.proxyHostnames.size() + acl.urls.add(url)
|
||||
if (old != acl.urls.size()) {
|
||||
notifyItemInserted(index)
|
||||
apply()
|
||||
}
|
||||
return index
|
||||
}
|
||||
fun addToProxy(input: String): Int? {
|
||||
val acl = Acl().fromReader(input.reader(), true)
|
||||
var result: Int? = null
|
||||
if (acl.bypass) acl.subnets.asIterable().asSequence().map { addSubnet(it) }
|
||||
.forEach { if (result == null) result = it }
|
||||
(acl.proxyHostnames.asIterable().asSequence().map { addHostname(it) } +
|
||||
acl.urls.asIterable().asSequence().map { addURL(it) })
|
||||
.forEach { if (result == null) result = it }
|
||||
return result
|
||||
}
|
||||
fun addFromTemplate(templateValue: Int, text: String): Int? {
|
||||
val template = Template.values()[templateValue]
|
||||
return when (template) {
|
||||
Template.Generic -> addToProxy(text)
|
||||
Template.Domain -> try {
|
||||
addHostname(TEMPLATE_REGEX_DOMAIN.format(Locale.ENGLISH, IDN.toASCII(text,
|
||||
IDN.ALLOW_UNASSIGNED or IDN.USE_STD3_ASCII_RULES).replace(".", "\\.")))
|
||||
} catch (exc: IllegalArgumentException) {
|
||||
Toast.makeText(activity, exc.message, Toast.LENGTH_SHORT).show()
|
||||
null
|
||||
}
|
||||
Template.Url -> addURL(URL(text))
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(i: Int) {
|
||||
val j = i - acl.subnets.size()
|
||||
if (j < 0) {
|
||||
undoManager.remove(Pair(i, acl.subnets[i]))
|
||||
acl.subnets.removeItemAt(i)
|
||||
} else {
|
||||
val k = j - acl.proxyHostnames.size()
|
||||
if (k < 0) {
|
||||
undoManager.remove(Pair(j, acl.proxyHostnames[j]))
|
||||
acl.proxyHostnames.removeItemAt(j)
|
||||
} else {
|
||||
undoManager.remove(Pair(k, acl.urls[k]))
|
||||
acl.urls.removeItemAt(k)
|
||||
}
|
||||
}
|
||||
notifyItemRemoved(i)
|
||||
apply()
|
||||
}
|
||||
fun remove(item: Any) {
|
||||
when (item) {
|
||||
is Subnet -> {
|
||||
notifyItemRemoved(acl.subnets.indexOf(item))
|
||||
acl.subnets.remove(item)
|
||||
apply()
|
||||
}
|
||||
is String -> {
|
||||
notifyItemRemoved(acl.subnets.size() + acl.proxyHostnames.indexOf(item))
|
||||
acl.proxyHostnames.remove(item)
|
||||
apply()
|
||||
}
|
||||
is URL -> {
|
||||
notifyItemRemoved(acl.subnets.size() + acl.proxyHostnames.size() + acl.urls.indexOf(item))
|
||||
acl.urls.remove(item)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun removeSelected() {
|
||||
undoManager.remove(selectedItems.map { Pair(0, it) })
|
||||
selectedItems.forEach { remove(it) }
|
||||
selectedItems.clear()
|
||||
onSelectedItemsUpdated()
|
||||
}
|
||||
fun undo(actions: List<Pair<Int, Any>>) {
|
||||
for ((_, item) in actions) add(item)
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
selectedItems.clear()
|
||||
selectedItems.addAll(acl.subnets.asIterable())
|
||||
selectedItems.addAll(acl.proxyHostnames.asIterable())
|
||||
selectedItems.addAll(acl.urls.asIterable())
|
||||
onSelectedItemsUpdated()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private val isEnabled get() = when ((activity as MainActivity).state) {
|
||||
BaseService.CONNECTED -> app.currentProfile?.route != Acl.CUSTOM_RULES
|
||||
BaseService.STOPPED -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
private val selectedItems = HashSet<Any>()
|
||||
private val adapter by lazy { AclRulesAdapter() }
|
||||
private lateinit var list: RecyclerView
|
||||
private var selectionItem: MenuItem? = null
|
||||
private lateinit var undoManager: UndoSnackbarManager<Any>
|
||||
private val clipboard by lazy { activity!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }
|
||||
|
||||
private fun onSelectedItemsUpdated() {
|
||||
val selectionItem = selectionItem
|
||||
if (selectionItem != null) selectionItem.isVisible = selectedItems.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun createAclRuleDialog(item: Any = ""): Triple<Spinner, EditText, AlertDialog.Builder> {
|
||||
val view = activity!!.layoutInflater.inflate(R.layout.dialog_acl_rule, null)
|
||||
val templateSelector = view.findViewById<Spinner>(R.id.template_selector)
|
||||
val editText = view.findViewById<EditText>(R.id.content)
|
||||
when (item) {
|
||||
is String -> {
|
||||
val match = PATTERN_DOMAIN.find(item)
|
||||
if (match != null) {
|
||||
templateSelector.setSelection(Template.Domain.ordinal)
|
||||
editText.setText(IDN.toUnicode(match.value.replace("\\.", "."),
|
||||
IDN.ALLOW_UNASSIGNED or IDN.USE_STD3_ASCII_RULES))
|
||||
} else {
|
||||
templateSelector.setSelection(Template.Generic.ordinal)
|
||||
editText.setText(item)
|
||||
}
|
||||
}
|
||||
is URL -> {
|
||||
templateSelector.setSelection(Template.Url.ordinal)
|
||||
editText.setText(item.toString())
|
||||
}
|
||||
else -> {
|
||||
templateSelector.setSelection(Template.Generic.ordinal)
|
||||
editText.setText(item.toString())
|
||||
}
|
||||
}
|
||||
return Triple(templateSelector, editText, AlertDialog.Builder(activity!!)
|
||||
.setTitle(R.string.edit_rule)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setView(view))
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||
inflater.inflate(R.layout.layout_custom_rules, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
if (savedInstanceState != null) {
|
||||
selectedItems.addAll(savedInstanceState.getStringArray(SELECTED_SUBNETS)?.map(Subnet.Companion::fromString)
|
||||
?: listOf())
|
||||
selectedItems.addAll(savedInstanceState.getStringArray(SELECTED_HOSTNAMES)
|
||||
?: arrayOf())
|
||||
selectedItems.addAll(savedInstanceState.getStringArray(SELECTED_URLS)?.map { URL(it) }
|
||||
?: listOf())
|
||||
onSelectedItemsUpdated()
|
||||
}
|
||||
toolbar.setTitle(R.string.custom_rules)
|
||||
toolbar.inflateMenu(R.menu.custom_rules_menu)
|
||||
toolbar.setOnMenuItemClickListener(this)
|
||||
val selectionItem = toolbar.menu.findItem(R.id.selection)
|
||||
selectionItem.isVisible = selectedItems.isNotEmpty()
|
||||
this.selectionItem = selectionItem
|
||||
list = view.findViewById(R.id.list)
|
||||
list.layoutManager = LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
|
||||
list.itemAnimator = DefaultItemAnimator()
|
||||
list.adapter = adapter
|
||||
view.findViewById<FastScroller>(R.id.fastscroller).setRecyclerView(list)
|
||||
undoManager = UndoSnackbarManager(activity!!.findViewById(R.id.snackbar), adapter::undo)
|
||||
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.START or ItemTouchHelper.END) {
|
||||
override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int =
|
||||
if (isEnabled) super.getSwipeDirs(recyclerView, viewHolder) else 0
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) =
|
||||
adapter.remove(viewHolder.adapterPosition)
|
||||
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder): Boolean = false
|
||||
}).attachToRecyclerView(list)
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
return if (selectedItems.isNotEmpty()) {
|
||||
selectedItems.clear()
|
||||
onSelectedItemsUpdated()
|
||||
adapter.notifyDataSetChanged()
|
||||
true
|
||||
} else super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putStringArray(SELECTED_SUBNETS, selectedItems.filterIsInstance<Subnet>().map(Subnet::toString)
|
||||
.toTypedArray())
|
||||
outState.putStringArray(SELECTED_HOSTNAMES, selectedItems.filterIsInstance<String>().toTypedArray())
|
||||
outState.putStringArray(SELECTED_URLS, selectedItems.filterIsInstance<URL>().map(URL::toString).toTypedArray())
|
||||
}
|
||||
|
||||
private fun copySelected() {
|
||||
val acl = Acl()
|
||||
acl.bypass = true
|
||||
selectedItems.forEach {
|
||||
when (it) {
|
||||
is Subnet -> acl.subnets.add(it)
|
||||
is String -> acl.proxyHostnames.add(it)
|
||||
is URL -> acl.urls.add(it)
|
||||
}
|
||||
}
|
||||
clipboard.primaryClip = ClipData.newPlainText(null, acl.toString())
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) {
|
||||
R.id.action_select_all -> {
|
||||
adapter.selectAll()
|
||||
true
|
||||
}
|
||||
R.id.action_cut -> {
|
||||
copySelected()
|
||||
adapter.removeSelected()
|
||||
true
|
||||
}
|
||||
R.id.action_copy -> {
|
||||
copySelected()
|
||||
true
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
adapter.removeSelected()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_manual_settings -> {
|
||||
val (templateSelector, editText, dialog) = createAclRuleDialog()
|
||||
dialog.setPositiveButton(android.R.string.ok, { _, _ ->
|
||||
adapter.addFromTemplate(templateSelector.selectedItemPosition, editText.text.toString())
|
||||
}).create().show()
|
||||
true
|
||||
}
|
||||
R.id.action_import -> {
|
||||
try {
|
||||
adapter.addToProxy(clipboard.primaryClip.getItemAt(0).text.toString()) != null
|
||||
} catch (exc: Exception) {
|
||||
Snackbar.make(activity!!.findViewById(R.id.snackbar), R.string.action_import_err, Snackbar.LENGTH_LONG)
|
||||
.show()
|
||||
app.track(exc)
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.action_import_gfwlist -> {
|
||||
val acl = Acl().fromId(Acl.GFWLIST)
|
||||
if (!acl.bypass) acl.subnets.asIterable().forEach { adapter.addSubnet(it) }
|
||||
acl.proxyHostnames.asIterable().forEach { adapter.addHostname(it) }
|
||||
acl.urls.asIterable().forEach { adapter.addURL(it) }
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user