diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt index 69bca6c..5616674 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt @@ -97,6 +97,7 @@ class TrafficLooper if (proxy.config.selectorGroupId >= 0L) { itemMain = TrafficUpdater.TrafficLooperData(tag = TAG_PROXY) itemMainBase = TrafficUpdater.TrafficLooperData(tag = TAG_PROXY) + tags.add(TAG_PROXY) } // trafficUpdater = TrafficUpdater( diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt index 228c625..20bc19c 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt @@ -20,7 +20,6 @@ import io.nekohasekai.sagernet.fmt.socks.SOCKSBean import io.nekohasekai.sagernet.fmt.socks.toUri import io.nekohasekai.sagernet.fmt.ssh.SSHBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean -import io.nekohasekai.sagernet.fmt.trojan.toUri import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig import io.nekohasekai.sagernet.fmt.trojan_go.toUri @@ -236,23 +235,18 @@ data class ProxyEntity( } } - fun toLink(compact: Boolean = false): String? = with(requireBean()) { + fun toStdLink(compact: Boolean = false): String = with(requireBean()) { when (this) { is SOCKSBean -> toUri() is HttpBean -> toUri() is ShadowsocksBean -> toUri() - is VMessBean -> if (compact) toV2rayN() else toUri() - is TrojanBean -> toUri() + is VMessBean -> toUriVMessVLESSTrojan(false) + is TrojanBean -> toUriVMessVLESSTrojan(true) is TrojanGoBean -> toUri() is NaiveBean -> toUri() is HysteriaBean -> toUri() - is SSHBean -> toUniversalLink() - is WireGuardBean -> toUniversalLink() - is TuicBean -> toUniversalLink() - is ShadowTLSBean -> toUniversalLink() - is ConfigBean -> toUniversalLink() is NekoBean -> shareLink() - else -> null + else -> toUniversalLink() } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanFmt.kt index 0cb475e..8744696 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanFmt.kt @@ -1,7 +1,6 @@ package io.nekohasekai.sagernet.fmt.trojan import io.nekohasekai.sagernet.fmt.v2ray.parseDuckSoft -import io.nekohasekai.sagernet.fmt.v2ray.toUri import okhttp3.HttpUrl.Companion.toHttpUrlOrNull fun parseTrojan(server: String): TrojanBean { @@ -17,7 +16,3 @@ fun parseTrojan(server: String): TrojanBean { } } - -fun TrojanBean.toUri(): String { - return toUri(true).replace("vmess://", "trojan://") -} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt index 6e926de..5c0e0e5 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sagernet.fmt.v2ray +import android.text.TextUtils import com.google.gson.Gson import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean @@ -10,6 +11,24 @@ import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import org.json.JSONObject +data class VmessQRCode( + var v: String = "", + var ps: String = "", + var add: String = "", + var port: String = "", + var id: String = "", + var aid: String = "0", + var scy: String = "", + var net: String = "", + var type: String = "", + var host: String = "", + var path: String = "", + var tls: String = "", + var sni: String = "", + var alpn: String = "", + var fp: String = "", +) + fun StandardV2RayBean.isTLS(): Boolean { return security == "tls" } @@ -104,7 +123,6 @@ fun parseV2Ray(link: String): StandardV2RayBean { } // https://github.com/XTLS/Xray-core/issues/91 -// NO allowInsecure fun StandardV2RayBean.parseDuckSoft(url: HttpUrl) { serverAddress = url.host serverPort = url.port @@ -116,27 +134,26 @@ fun StandardV2RayBean.parseDuckSoft(url: HttpUrl) { uuid = url.username } + // not ducksoft fmt path if (url.pathSegments.size > 1 || url.pathSegments[0].isNotBlank()) { path = url.pathSegments.joinToString("/") } type = url.queryParameter("type") ?: "tcp" + if (type == "h2") type = "http" + security = url.queryParameter("security") if (security == null) { - security = if (this is TrojanBean) { - "tls" - } else { - "none" - } + security = if (this is TrojanBean) "tls" else "none" } when (security) { - "tls" -> { + "tls", "reality" -> { url.queryParameter("sni")?.let { sni = it } url.queryParameter("alpn")?.let { - alpn = it + alpn = it.replace(",", "\n") } url.queryParameter("cert")?.let { certificates = it @@ -144,6 +161,15 @@ fun StandardV2RayBean.parseDuckSoft(url: HttpUrl) { } } when (type) { + "tcp" -> { + // v2rayNG + if (url.queryParameter("headerType") == "http") { + url.queryParameter("host")?.let { + type = "http" + host = it + } + } + } "http" -> { url.queryParameter("host")?.let { host = it @@ -251,63 +277,43 @@ fun parseV2RayN(link: String): VMessBean { return parseCsvVMess(result) } val bean = VMessBean() - val json = JSONObject(result) + val vmessQRCode = Gson().fromJson(result, VmessQRCode::class.java) - bean.serverAddress = json.getStr("add") ?: "" - bean.serverPort = json.getIntNya("port") ?: 1080 - bean.encryption = json.getStr("scy") ?: "" - bean.uuid = json.getStr("id") ?: "" - bean.alterId = json.getIntNya("aid") ?: 0 - bean.type = json.getStr("net") ?: "" - bean.host = json.getStr("host") ?: "" - bean.path = json.getStr("path") ?: "" + // Although VmessQRCode fields are non null, looks like Gson may still create null fields + if (TextUtils.isEmpty(vmessQRCode.add) + || TextUtils.isEmpty(vmessQRCode.port) + || TextUtils.isEmpty(vmessQRCode.id) + || TextUtils.isEmpty(vmessQRCode.net) + ) { + throw Exception("invalid VmessQRCode") + } + + bean.serverAddress = vmessQRCode.add + bean.serverPort = vmessQRCode.port.toIntOrNull() + bean.encryption = vmessQRCode.scy + bean.uuid = vmessQRCode.id + bean.alterId = vmessQRCode.aid.toIntOrNull() + bean.type = vmessQRCode.type + bean.host = vmessQRCode.host + bean.path = vmessQRCode.path + val headerType = vmessQRCode.type when (bean.type) { - "grpc" -> { - bean.path = bean.path - } - } - - bean.name = json.getStr("ps") ?: "" - bean.sni = json.getStr("sni") ?: bean.host - bean.security = json.getStr("tls") ?: "none" - bean.utlsFingerprint = json.getStr("fp") ?: "" - - if (json.optInt("v", 2) < 2) { - when (bean.type) { - "ws" -> { - var path = "" - var host = "" - val lstParameter = bean.host.split(";") - if (lstParameter.isNotEmpty()) { - path = lstParameter[0].trim() - } - if (lstParameter.size > 1) { - path = lstParameter[0].trim() - host = lstParameter[1].trim() - } - bean.path = path - bean.host = host - } - "h2" -> { - var path = "" - var host = "" - val lstParameter = bean.host.split(";") - if (lstParameter.isNotEmpty()) { - path = lstParameter[0].trim() - } - if (lstParameter.size > 1) { - path = lstParameter[0].trim() - host = lstParameter[1].trim() - } - bean.path = path - bean.host = host + "tcp" -> { + if (headerType == "http") { + bean.type = "http" } } } + bean.name = vmessQRCode.ps + bean.security = vmessQRCode.tls + if (bean.security == "reality") bean.security = "tls" + bean.sni = vmessQRCode.sni + bean.alpn = vmessQRCode.alpn.replace(",", "\n") + bean.utlsFingerprint = vmessQRCode.fp + return bean - } private fun parseCsvVMess(csv: String): VMessBean { @@ -345,61 +351,61 @@ private fun parseCsvVMess(csv: String): VMessBean { } -data class VmessQRCode( - var v: String = "", - var ps: String = "", - var add: String = "", - var port: String = "", - var id: String = "", - var aid: String = "0", - var scy: String = "", - var net: String = "", - var type: String = "", - var host: String = "", - var path: String = "", - var tls: String = "", - var sni: String = "", - var alpn: String = "", - var fp: String = "", -) - fun VMessBean.toV2rayN(): String { - if (isVLESS) return toUri(true) + val bean = this return "vmess://" + VmessQRCode().apply { v = "2" - ps = this@toV2rayN.name - add = this@toV2rayN.serverAddress - port = this@toV2rayN.serverPort.toString() - id = this@toV2rayN.uuid - aid = this@toV2rayN.alterId.toString() - net = this@toV2rayN.type - host = this@toV2rayN.host - path = this@toV2rayN.path + ps = bean.name + add = bean.serverAddress + port = bean.serverPort.toString() + id = bean.uuid + aid = bean.alterId.toString() + net = bean.type + host = bean.host + path = bean.path when (net) { - "grpc" -> { - path = this@toV2rayN.path + "http" -> { + if (!isTLS()) { + type = "http" + net = "tcp" + } } } - tls = if (this@toV2rayN.security == "tls") "tls" else "" - sni = this@toV2rayN.sni - scy = this@toV2rayN.encryption - fp = this@toV2rayN.utlsFingerprint + if (isTLS()) { + tls = "tls" + if (bean.realityPubKey.isNotBlank()) { + tls = "reality" + } + } + + scy = bean.encryption + sni = bean.sni + alpn = bean.alpn.replace("\n", ",") + fp = bean.utlsFingerprint }.let { NGUtil.encode(Gson().toJson(it)) } } -fun StandardV2RayBean.toUri(standard: Boolean = true): String { - if (this is VMessBean && alterId > 0) return toV2rayN() +fun StandardV2RayBean.toUriVMessVLESSTrojan(isTrojan: Boolean): String { + // VMess + if (this is VMessBean && !isVLESS) { + return toV2rayN() + } + // VLESS & Trojan (ducksoft fmt) val builder = linkBuilder() .username(if (this is TrojanBean) password else uuid) .host(serverAddress) .port(serverPort) .addQueryParameter("type", type) - if (this !is TrojanBean) builder.addQueryParameter("encryption", encryption) + + if (isVLESS) { + builder.addQueryParameter("encryption", "none") + if (encryption != "auto") builder.addQueryParameter("flow", encryption) + } when (type) { "tcp" -> {} @@ -408,11 +414,7 @@ fun StandardV2RayBean.toUri(standard: Boolean = true): String { builder.addQueryParameter("host", host) } if (path.isNotBlank()) { - if (standard) { - builder.addQueryParameter("path", path) - } else { - builder.encodedPath(path.pathSafe()) - } + builder.addQueryParameter("path", path) } if (type == "ws") { if (wsMaxEarlyData > 0) { @@ -422,12 +424,13 @@ fun StandardV2RayBean.toUri(standard: Boolean = true): String { } } } else if (type == "http" && !isTLS()) { - return "" // no fmt? + builder.setQueryParameter("type", "tcp") + builder.addQueryParameter("headerType", "http") } } "grpc" -> { if (path.isNotBlank()) { - builder.addQueryParameter("serviceName", path) + builder.setQueryParameter("serviceName", path) } } } @@ -440,7 +443,7 @@ fun StandardV2RayBean.toUri(standard: Boolean = true): String { builder.addQueryParameter("sni", sni) } if (alpn.isNotBlank()) { - builder.addQueryParameter("alpn", alpn) + builder.addQueryParameter("alpn", alpn.replace("\n", ",")) } if (certificates.isNotBlank()) { builder.addQueryParameter("cert", certificates) @@ -468,10 +471,7 @@ fun StandardV2RayBean.toUri(standard: Boolean = true): String { builder.encodedFragment(name.urlSafe()) } - // TODO vless flow: bean.encryption != "auto" - - return builder.toLink(if (isVLESS) "vless" else "vmess") - + return builder.toLink(if (isTrojan) "trojan" else "vless") } fun buildSingBoxOutboundStreamSettings(bean: StandardV2RayBean): V2RayTransportOptions? { diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index a396644..ba55e42 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -43,7 +43,6 @@ import io.nekohasekai.sagernet.databinding.LayoutProfileListBinding import io.nekohasekai.sagernet.databinding.LayoutProgressListBinding import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.toUniversalLink -import io.nekohasekai.sagernet.fmt.v2ray.toV2rayN import io.nekohasekai.sagernet.group.RawUpdater import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager @@ -1470,11 +1469,6 @@ class ConfigurationFragment @JvmOverloads constructor( val popup = PopupMenu(requireContext(), anchor) popup.menuInflater.inflate(R.menu.profile_share_menu, popup.menu) - if (proxyEntity.vmessBean == null || proxyEntity.vmessBean!!.isVLESS) { - popup.menu.findItem(R.id.action_group_qr).subMenu?.removeItem(R.id.action_v2rayn_qr) - popup.menu.findItem(R.id.action_group_clipboard).subMenu?.removeItem(R.id.action_v2rayn_clipboard) - } - when { !proxyEntity.haveStandardLink() -> { popup.menu.findItem(R.id.action_group_qr).subMenu?.removeItem(R.id.action_standard_qr) @@ -1527,14 +1521,12 @@ class ConfigurationFragment @JvmOverloads constructor( try { currentName = entity.displayName()!! when (item.itemId) { - R.id.action_standard_qr -> showCode(entity.toLink()!!) - R.id.action_standard_clipboard -> export(entity.toLink()!!) + R.id.action_standard_qr -> showCode(entity.toStdLink()!!) + R.id.action_standard_clipboard -> export(entity.toStdLink()!!) R.id.action_universal_qr -> showCode(entity.requireBean().toUniversalLink()) R.id.action_universal_clipboard -> export( entity.requireBean().toUniversalLink() ) - R.id.action_v2rayn_qr -> showCode(entity.vmessBean!!.toV2rayN()) - R.id.action_v2rayn_clipboard -> export(entity.vmessBean!!.toV2rayN()) R.id.action_config_export_clipboard -> export(entity.exportConfig().first) R.id.action_config_export_file -> { val cfg = entity.exportConfig() diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt index 355e87f..6c063d3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt @@ -130,30 +130,31 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), private lateinit var selectedGroup: ProxyGroup - private val exportProfiles = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> - if (data != null) { - runOnDefaultDispatcher { - val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) - val links = profiles.mapNotNull { it.toLink(compact = true) }.joinToString("\n") - try { - (requireActivity() as MainActivity).contentResolver.openOutputStream( - data - )!!.bufferedWriter().use { - it.write(links) + private val exportProfiles = + registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> + if (data != null) { + runOnDefaultDispatcher { + val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) + val links = profiles.joinToString("\n") { it.toStdLink(compact = true) } + try { + (requireActivity() as MainActivity).contentResolver.openOutputStream( + data + )!!.bufferedWriter().use { + it.write(links) + } + onMainDispatcher { + snackbar(getString(R.string.action_export_msg)).show() + } + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { + snackbar(e.readableMessage).show() + } } - onMainDispatcher { - snackbar(getString(R.string.action_export_msg)).show() - } - } catch (e: Exception) { - Logs.w(e) - onMainDispatcher { - snackbar(e.readableMessage).show() - } - } + } } } - } inner class GroupAdapter : RecyclerView.Adapter(), GroupManager.Listener, @@ -310,7 +311,8 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), undoManager.flush() } - inner class GroupHolder(binding: LayoutGroupItemBinding) : RecyclerView.ViewHolder(binding.root), + inner class GroupHolder(binding: LayoutGroupItemBinding) : + RecyclerView.ViewHolder(binding.root), PopupMenu.OnMenuItemClickListener { lateinit var proxyGroup: ProxyGroup @@ -343,8 +345,7 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), R.id.action_export_clipboard -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) - val links = profiles.mapNotNull { it.toLink(compact = true) } - .joinToString("\n") + val links = profiles.joinToString("\n") { it.toStdLink(compact = true) } onMainDispatcher { SagerNet.trySetPrimaryClip(links) snackbar(getString(R.string.copy_toast_msg)).show() @@ -397,7 +398,7 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), popup.menuInflater.inflate(R.menu.group_action_menu, popup.menu) if (proxyGroup.type != GroupType.SUBSCRIPTION) { - popup.menu.removeItem(R.id.action_share) + popup.menu.removeItem(R.id.action_share_subscription) } popup.setOnMenuItemClickListener(this) popup.show() @@ -451,12 +452,12 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), } groupStatus.setPadding(0) } else if (subscription != null && !subscription.subscriptionUserinfo.isNullOrBlank()) { // Raw - var text = ""; + var text = "" fun get(regex: String): String? { return regex.toRegex().findAll(subscription.subscriptionUserinfo).mapNotNull { if (it.groupValues.size > 1) it.groupValues[1] else null - }.firstOrNull(); + }.firstOrNull() } var used: Long = 0 @@ -484,7 +485,7 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group), if (text.isNotEmpty()) { groupTraffic.isVisible = true - groupTraffic.text = text; + groupTraffic.text = text groupStatus.setPadding(0) } } else { diff --git a/app/src/main/res/menu/group_action_menu.xml b/app/src/main/res/menu/group_action_menu.xml index 85bbc7c..f749f3d 100644 --- a/app/src/main/res/menu/group_action_menu.xml +++ b/app/src/main/res/menu/group_action_menu.xml @@ -1,8 +1,8 @@ + android:id="@+id/action_share_subscription" + android:title="@string/share_subscription"> - + android:title="SN Link" /> - + android:title="SN Link" /> 前置代理 落地代理 ShadowTLS 版本 + 分享订阅 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ceef3fe..a37ae92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -507,5 +507,6 @@ Anyone can write advanced plugins, which can control NekoBox. please download an Landing Proxy ShadowTLS ShadowTLS Version + Share Subscription \ No newline at end of file