update share link

This commit is contained in:
arm64v8a 2023-03-21 16:11:29 +09:00
parent 6051efd5aa
commit 810058ccd4
10 changed files with 146 additions and 167 deletions

View File

@ -97,6 +97,7 @@ class TrafficLooper
if (proxy.config.selectorGroupId >= 0L) { if (proxy.config.selectorGroupId >= 0L) {
itemMain = TrafficUpdater.TrafficLooperData(tag = TAG_PROXY) itemMain = TrafficUpdater.TrafficLooperData(tag = TAG_PROXY)
itemMainBase = TrafficUpdater.TrafficLooperData(tag = TAG_PROXY) itemMainBase = TrafficUpdater.TrafficLooperData(tag = TAG_PROXY)
tags.add(TAG_PROXY)
} }
// //
trafficUpdater = TrafficUpdater( trafficUpdater = TrafficUpdater(

View File

@ -20,7 +20,6 @@ import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
import io.nekohasekai.sagernet.fmt.socks.toUri import io.nekohasekai.sagernet.fmt.socks.toUri
import io.nekohasekai.sagernet.fmt.ssh.SSHBean import io.nekohasekai.sagernet.fmt.ssh.SSHBean
import io.nekohasekai.sagernet.fmt.trojan.TrojanBean 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.TrojanGoBean
import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig
import io.nekohasekai.sagernet.fmt.trojan_go.toUri 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) { when (this) {
is SOCKSBean -> toUri() is SOCKSBean -> toUri()
is HttpBean -> toUri() is HttpBean -> toUri()
is ShadowsocksBean -> toUri() is ShadowsocksBean -> toUri()
is VMessBean -> if (compact) toV2rayN() else toUri() is VMessBean -> toUriVMessVLESSTrojan(false)
is TrojanBean -> toUri() is TrojanBean -> toUriVMessVLESSTrojan(true)
is TrojanGoBean -> toUri() is TrojanGoBean -> toUri()
is NaiveBean -> toUri() is NaiveBean -> toUri()
is HysteriaBean -> toUri() is HysteriaBean -> toUri()
is SSHBean -> toUniversalLink()
is WireGuardBean -> toUniversalLink()
is TuicBean -> toUniversalLink()
is ShadowTLSBean -> toUniversalLink()
is ConfigBean -> toUniversalLink()
is NekoBean -> shareLink() is NekoBean -> shareLink()
else -> null else -> toUniversalLink()
} }
} }

View File

@ -1,7 +1,6 @@
package io.nekohasekai.sagernet.fmt.trojan package io.nekohasekai.sagernet.fmt.trojan
import io.nekohasekai.sagernet.fmt.v2ray.parseDuckSoft import io.nekohasekai.sagernet.fmt.v2ray.parseDuckSoft
import io.nekohasekai.sagernet.fmt.v2ray.toUri
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
fun parseTrojan(server: String): TrojanBean { fun parseTrojan(server: String): TrojanBean {
@ -17,7 +16,3 @@ fun parseTrojan(server: String): TrojanBean {
} }
} }
fun TrojanBean.toUri(): String {
return toUri(true).replace("vmess://", "trojan://")
}

View File

@ -1,5 +1,6 @@
package io.nekohasekai.sagernet.fmt.v2ray package io.nekohasekai.sagernet.fmt.v2ray
import android.text.TextUtils
import com.google.gson.Gson import com.google.gson.Gson
import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.http.HttpBean
import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean
@ -10,6 +11,24 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.json.JSONObject 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 { fun StandardV2RayBean.isTLS(): Boolean {
return security == "tls" return security == "tls"
} }
@ -104,7 +123,6 @@ fun parseV2Ray(link: String): StandardV2RayBean {
} }
// https://github.com/XTLS/Xray-core/issues/91 // https://github.com/XTLS/Xray-core/issues/91
// NO allowInsecure
fun StandardV2RayBean.parseDuckSoft(url: HttpUrl) { fun StandardV2RayBean.parseDuckSoft(url: HttpUrl) {
serverAddress = url.host serverAddress = url.host
serverPort = url.port serverPort = url.port
@ -116,27 +134,26 @@ fun StandardV2RayBean.parseDuckSoft(url: HttpUrl) {
uuid = url.username uuid = url.username
} }
// not ducksoft fmt path
if (url.pathSegments.size > 1 || url.pathSegments[0].isNotBlank()) { if (url.pathSegments.size > 1 || url.pathSegments[0].isNotBlank()) {
path = url.pathSegments.joinToString("/") path = url.pathSegments.joinToString("/")
} }
type = url.queryParameter("type") ?: "tcp" type = url.queryParameter("type") ?: "tcp"
if (type == "h2") type = "http"
security = url.queryParameter("security") security = url.queryParameter("security")
if (security == null) { if (security == null) {
security = if (this is TrojanBean) { security = if (this is TrojanBean) "tls" else "none"
"tls"
} else {
"none"
}
} }
when (security) { when (security) {
"tls" -> { "tls", "reality" -> {
url.queryParameter("sni")?.let { url.queryParameter("sni")?.let {
sni = it sni = it
} }
url.queryParameter("alpn")?.let { url.queryParameter("alpn")?.let {
alpn = it alpn = it.replace(",", "\n")
} }
url.queryParameter("cert")?.let { url.queryParameter("cert")?.let {
certificates = it certificates = it
@ -144,6 +161,15 @@ fun StandardV2RayBean.parseDuckSoft(url: HttpUrl) {
} }
} }
when (type) { when (type) {
"tcp" -> {
// v2rayNG
if (url.queryParameter("headerType") == "http") {
url.queryParameter("host")?.let {
type = "http"
host = it
}
}
}
"http" -> { "http" -> {
url.queryParameter("host")?.let { url.queryParameter("host")?.let {
host = it host = it
@ -251,63 +277,43 @@ fun parseV2RayN(link: String): VMessBean {
return parseCsvVMess(result) return parseCsvVMess(result)
} }
val bean = VMessBean() val bean = VMessBean()
val json = JSONObject(result) val vmessQRCode = Gson().fromJson(result, VmessQRCode::class.java)
bean.serverAddress = json.getStr("add") ?: "" // Although VmessQRCode fields are non null, looks like Gson may still create null fields
bean.serverPort = json.getIntNya("port") ?: 1080 if (TextUtils.isEmpty(vmessQRCode.add)
bean.encryption = json.getStr("scy") ?: "" || TextUtils.isEmpty(vmessQRCode.port)
bean.uuid = json.getStr("id") ?: "" || TextUtils.isEmpty(vmessQRCode.id)
bean.alterId = json.getIntNya("aid") ?: 0 || TextUtils.isEmpty(vmessQRCode.net)
bean.type = json.getStr("net") ?: "" ) {
bean.host = json.getStr("host") ?: "" throw Exception("invalid VmessQRCode")
bean.path = json.getStr("path") ?: "" }
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) { when (bean.type) {
"grpc" -> { "tcp" -> {
bean.path = bean.path if (headerType == "http") {
} bean.type = "http"
}
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
} }
} }
} }
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 return bean
} }
private fun parseCsvVMess(csv: String): VMessBean { 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 { fun VMessBean.toV2rayN(): String {
if (isVLESS) return toUri(true) val bean = this
return "vmess://" + VmessQRCode().apply { return "vmess://" + VmessQRCode().apply {
v = "2" v = "2"
ps = this@toV2rayN.name ps = bean.name
add = this@toV2rayN.serverAddress add = bean.serverAddress
port = this@toV2rayN.serverPort.toString() port = bean.serverPort.toString()
id = this@toV2rayN.uuid id = bean.uuid
aid = this@toV2rayN.alterId.toString() aid = bean.alterId.toString()
net = this@toV2rayN.type net = bean.type
host = this@toV2rayN.host host = bean.host
path = this@toV2rayN.path path = bean.path
when (net) { when (net) {
"grpc" -> { "http" -> {
path = this@toV2rayN.path if (!isTLS()) {
type = "http"
net = "tcp"
}
} }
} }
tls = if (this@toV2rayN.security == "tls") "tls" else "" if (isTLS()) {
sni = this@toV2rayN.sni tls = "tls"
scy = this@toV2rayN.encryption if (bean.realityPubKey.isNotBlank()) {
fp = this@toV2rayN.utlsFingerprint tls = "reality"
}
}
scy = bean.encryption
sni = bean.sni
alpn = bean.alpn.replace("\n", ",")
fp = bean.utlsFingerprint
}.let { }.let {
NGUtil.encode(Gson().toJson(it)) NGUtil.encode(Gson().toJson(it))
} }
} }
fun StandardV2RayBean.toUri(standard: Boolean = true): String { fun StandardV2RayBean.toUriVMessVLESSTrojan(isTrojan: Boolean): String {
if (this is VMessBean && alterId > 0) return toV2rayN() // VMess
if (this is VMessBean && !isVLESS) {
return toV2rayN()
}
// VLESS & Trojan (ducksoft fmt)
val builder = linkBuilder() val builder = linkBuilder()
.username(if (this is TrojanBean) password else uuid) .username(if (this is TrojanBean) password else uuid)
.host(serverAddress) .host(serverAddress)
.port(serverPort) .port(serverPort)
.addQueryParameter("type", type) .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) { when (type) {
"tcp" -> {} "tcp" -> {}
@ -408,11 +414,7 @@ fun StandardV2RayBean.toUri(standard: Boolean = true): String {
builder.addQueryParameter("host", host) builder.addQueryParameter("host", host)
} }
if (path.isNotBlank()) { if (path.isNotBlank()) {
if (standard) { builder.addQueryParameter("path", path)
builder.addQueryParameter("path", path)
} else {
builder.encodedPath(path.pathSafe())
}
} }
if (type == "ws") { if (type == "ws") {
if (wsMaxEarlyData > 0) { if (wsMaxEarlyData > 0) {
@ -422,12 +424,13 @@ fun StandardV2RayBean.toUri(standard: Boolean = true): String {
} }
} }
} else if (type == "http" && !isTLS()) { } else if (type == "http" && !isTLS()) {
return "" // no fmt? builder.setQueryParameter("type", "tcp")
builder.addQueryParameter("headerType", "http")
} }
} }
"grpc" -> { "grpc" -> {
if (path.isNotBlank()) { 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) builder.addQueryParameter("sni", sni)
} }
if (alpn.isNotBlank()) { if (alpn.isNotBlank()) {
builder.addQueryParameter("alpn", alpn) builder.addQueryParameter("alpn", alpn.replace("\n", ","))
} }
if (certificates.isNotBlank()) { if (certificates.isNotBlank()) {
builder.addQueryParameter("cert", certificates) builder.addQueryParameter("cert", certificates)
@ -468,10 +471,7 @@ fun StandardV2RayBean.toUri(standard: Boolean = true): String {
builder.encodedFragment(name.urlSafe()) builder.encodedFragment(name.urlSafe())
} }
// TODO vless flow: bean.encryption != "auto" return builder.toLink(if (isTrojan) "trojan" else "vless")
return builder.toLink(if (isVLESS) "vless" else "vmess")
} }
fun buildSingBoxOutboundStreamSettings(bean: StandardV2RayBean): V2RayTransportOptions? { fun buildSingBoxOutboundStreamSettings(bean: StandardV2RayBean): V2RayTransportOptions? {

View File

@ -43,7 +43,6 @@ import io.nekohasekai.sagernet.databinding.LayoutProfileListBinding
import io.nekohasekai.sagernet.databinding.LayoutProgressListBinding import io.nekohasekai.sagernet.databinding.LayoutProgressListBinding
import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.AbstractBean
import io.nekohasekai.sagernet.fmt.toUniversalLink import io.nekohasekai.sagernet.fmt.toUniversalLink
import io.nekohasekai.sagernet.fmt.v2ray.toV2rayN
import io.nekohasekai.sagernet.group.RawUpdater import io.nekohasekai.sagernet.group.RawUpdater
import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.ktx.*
import io.nekohasekai.sagernet.plugin.PluginManager import io.nekohasekai.sagernet.plugin.PluginManager
@ -1470,11 +1469,6 @@ class ConfigurationFragment @JvmOverloads constructor(
val popup = PopupMenu(requireContext(), anchor) val popup = PopupMenu(requireContext(), anchor)
popup.menuInflater.inflate(R.menu.profile_share_menu, popup.menu) 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 { when {
!proxyEntity.haveStandardLink() -> { !proxyEntity.haveStandardLink() -> {
popup.menu.findItem(R.id.action_group_qr).subMenu?.removeItem(R.id.action_standard_qr) popup.menu.findItem(R.id.action_group_qr).subMenu?.removeItem(R.id.action_standard_qr)
@ -1527,14 +1521,12 @@ class ConfigurationFragment @JvmOverloads constructor(
try { try {
currentName = entity.displayName()!! currentName = entity.displayName()!!
when (item.itemId) { when (item.itemId) {
R.id.action_standard_qr -> showCode(entity.toLink()!!) R.id.action_standard_qr -> showCode(entity.toStdLink()!!)
R.id.action_standard_clipboard -> export(entity.toLink()!!) R.id.action_standard_clipboard -> export(entity.toStdLink()!!)
R.id.action_universal_qr -> showCode(entity.requireBean().toUniversalLink()) R.id.action_universal_qr -> showCode(entity.requireBean().toUniversalLink())
R.id.action_universal_clipboard -> export( R.id.action_universal_clipboard -> export(
entity.requireBean().toUniversalLink() 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_clipboard -> export(entity.exportConfig().first)
R.id.action_config_export_file -> { R.id.action_config_export_file -> {
val cfg = entity.exportConfig() val cfg = entity.exportConfig()

View File

@ -130,30 +130,31 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group),
private lateinit var selectedGroup: ProxyGroup private lateinit var selectedGroup: ProxyGroup
private val exportProfiles = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> private val exportProfiles =
if (data != null) { registerForActivityResult(ActivityResultContracts.CreateDocument()) { data ->
runOnDefaultDispatcher { if (data != null) {
val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) runOnDefaultDispatcher {
val links = profiles.mapNotNull { it.toLink(compact = true) }.joinToString("\n") val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id)
try { val links = profiles.joinToString("\n") { it.toStdLink(compact = true) }
(requireActivity() as MainActivity).contentResolver.openOutputStream( try {
data (requireActivity() as MainActivity).contentResolver.openOutputStream(
)!!.bufferedWriter().use { data
it.write(links) )!!.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<GroupHolder>(), inner class GroupAdapter : RecyclerView.Adapter<GroupHolder>(),
GroupManager.Listener, GroupManager.Listener,
@ -310,7 +311,8 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group),
undoManager.flush() undoManager.flush()
} }
inner class GroupHolder(binding: LayoutGroupItemBinding) : RecyclerView.ViewHolder(binding.root), inner class GroupHolder(binding: LayoutGroupItemBinding) :
RecyclerView.ViewHolder(binding.root),
PopupMenu.OnMenuItemClickListener { PopupMenu.OnMenuItemClickListener {
lateinit var proxyGroup: ProxyGroup lateinit var proxyGroup: ProxyGroup
@ -343,8 +345,7 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group),
R.id.action_export_clipboard -> { R.id.action_export_clipboard -> {
runOnDefaultDispatcher { runOnDefaultDispatcher {
val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id)
val links = profiles.mapNotNull { it.toLink(compact = true) } val links = profiles.joinToString("\n") { it.toStdLink(compact = true) }
.joinToString("\n")
onMainDispatcher { onMainDispatcher {
SagerNet.trySetPrimaryClip(links) SagerNet.trySetPrimaryClip(links)
snackbar(getString(R.string.copy_toast_msg)).show() 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) popup.menuInflater.inflate(R.menu.group_action_menu, popup.menu)
if (proxyGroup.type != GroupType.SUBSCRIPTION) { if (proxyGroup.type != GroupType.SUBSCRIPTION) {
popup.menu.removeItem(R.id.action_share) popup.menu.removeItem(R.id.action_share_subscription)
} }
popup.setOnMenuItemClickListener(this) popup.setOnMenuItemClickListener(this)
popup.show() popup.show()
@ -451,12 +452,12 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group),
} }
groupStatus.setPadding(0) groupStatus.setPadding(0)
} else if (subscription != null && !subscription.subscriptionUserinfo.isNullOrBlank()) { // Raw } else if (subscription != null && !subscription.subscriptionUserinfo.isNullOrBlank()) { // Raw
var text = ""; var text = ""
fun get(regex: String): String? { fun get(regex: String): String? {
return regex.toRegex().findAll(subscription.subscriptionUserinfo).mapNotNull { return regex.toRegex().findAll(subscription.subscriptionUserinfo).mapNotNull {
if (it.groupValues.size > 1) it.groupValues[1] else null if (it.groupValues.size > 1) it.groupValues[1] else null
}.firstOrNull(); }.firstOrNull()
} }
var used: Long = 0 var used: Long = 0
@ -484,7 +485,7 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group),
if (text.isNotEmpty()) { if (text.isNotEmpty()) {
groupTraffic.isVisible = true groupTraffic.isVisible = true
groupTraffic.text = text; groupTraffic.text = text
groupStatus.setPadding(0) groupStatus.setPadding(0)
} }
} else { } else {

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"> <menu xmlns:android="http://schemas.android.com/apk/res/android">
<item <item
android:id="@+id/action_share" android:id="@+id/action_share_subscription"
android:title="@string/share"> android:title="@string/share_subscription">
<menu> <menu>
<item <item
android:id="@+id/action_universal_clipboard" android:id="@+id/action_universal_clipboard"

View File

@ -9,10 +9,7 @@
android:title="@string/standard" /> android:title="@string/standard" />
<item <item
android:id="@+id/action_universal_qr" android:id="@+id/action_universal_qr"
android:title="@string/app_name" /> android:title="SN Link" />
<item
android:id="@+id/action_v2rayn_qr"
android:title="@string/v2rayn" />
</menu> </menu>
</item> </item>
<item <item
@ -24,10 +21,7 @@
android:title="@string/standard" /> android:title="@string/standard" />
<item <item
android:id="@+id/action_universal_clipboard" android:id="@+id/action_universal_clipboard"
android:title="@string/app_name" /> android:title="SN Link" />
<item
android:id="@+id/action_v2rayn_clipboard"
android:title="@string/v2rayn" />
</menu> </menu>
</item> </item>
<item <item

View File

@ -466,4 +466,5 @@
<string name="front_proxy">前置代理</string> <string name="front_proxy">前置代理</string>
<string name="landing_proxy">落地代理</string> <string name="landing_proxy">落地代理</string>
<string name="shadowtls_version">ShadowTLS 版本</string> <string name="shadowtls_version">ShadowTLS 版本</string>
<string name="share_subscription">分享订阅</string>
</resources> </resources>

View File

@ -507,5 +507,6 @@ Anyone can write advanced plugins, which can control NekoBox. please download an
<string name="landing_proxy">Landing Proxy</string> <string name="landing_proxy">Landing Proxy</string>
<string name="action_shadowtls" translatable="false">ShadowTLS</string> <string name="action_shadowtls" translatable="false">ShadowTLS</string>
<string name="shadowtls_version">ShadowTLS Version</string> <string name="shadowtls_version">ShadowTLS Version</string>
<string name="share_subscription">Share Subscription</string>
</resources> </resources>