mirror of
https://github.com/MatsuriDayo/NekoBoxForAndroid.git
synced 2025-12-18 22:20:06 +08:00
minimize connection test
This commit is contained in:
parent
a3e529cb19
commit
d2afb9df0e
@ -180,6 +180,10 @@ class SagerNet : Application(),
|
|||||||
"service-subscription",
|
"service-subscription",
|
||||||
application.getText(R.string.service_subscription),
|
application.getText(R.string.service_subscription),
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
), NotificationChannel(
|
||||||
|
"connection-test",
|
||||||
|
application.getText(R.string.connection_test),
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import io.nekohasekai.sagernet.ktx.int
|
|||||||
import io.nekohasekai.sagernet.ktx.long
|
import io.nekohasekai.sagernet.ktx.long
|
||||||
import io.nekohasekai.sagernet.ktx.parsePort
|
import io.nekohasekai.sagernet.ktx.parsePort
|
||||||
import io.nekohasekai.sagernet.ktx.string
|
import io.nekohasekai.sagernet.ktx.string
|
||||||
import io.nekohasekai.sagernet.ktx.stringSet
|
|
||||||
import io.nekohasekai.sagernet.ktx.stringToInt
|
import io.nekohasekai.sagernet.ktx.stringToInt
|
||||||
import io.nekohasekai.sagernet.ktx.stringToIntIfExists
|
import io.nekohasekai.sagernet.ktx.stringToIntIfExists
|
||||||
import moe.matsuri.nb4a.TempDatabase
|
import moe.matsuri.nb4a.TempDatabase
|
||||||
@ -41,6 +40,10 @@ object DataStore : OnPreferenceDataStoreChangeListener {
|
|||||||
var vpnService: VpnService? = null
|
var vpnService: VpnService? = null
|
||||||
var baseService: BaseService.Interface? = null
|
var baseService: BaseService.Interface? = null
|
||||||
|
|
||||||
|
// main
|
||||||
|
|
||||||
|
var runningTest = false
|
||||||
|
|
||||||
fun currentGroupId(): Long {
|
fun currentGroupId(): Long {
|
||||||
val currentSelected = configurationStore.getLong(Key.PROFILE_GROUP, -1)
|
val currentSelected = configurationStore.getLong(Key.PROFILE_GROUP, -1)
|
||||||
if (currentSelected > 0L) return currentSelected
|
if (currentSelected > 0L) return currentSelected
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
package io.nekohasekai.sagernet.ui
|
package io.nekohasekai.sagernet.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
@ -92,12 +92,12 @@ import io.nekohasekai.sagernet.ui.profile.WireGuardSettingsActivity
|
|||||||
import io.nekohasekai.sagernet.widget.QRCodeDialog
|
import io.nekohasekai.sagernet.widget.QRCodeDialog
|
||||||
import io.nekohasekai.sagernet.widget.UndoSnackbarManager
|
import io.nekohasekai.sagernet.widget.UndoSnackbarManager
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.joinAll
|
import kotlinx.coroutines.joinAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.newFixedThreadPoolContext
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import moe.matsuri.nb4a.Protocols
|
import moe.matsuri.nb4a.Protocols
|
||||||
@ -106,7 +106,6 @@ import moe.matsuri.nb4a.proxy.anytls.AnyTLSSettingsActivity
|
|||||||
import moe.matsuri.nb4a.proxy.config.ConfigSettingActivity
|
import moe.matsuri.nb4a.proxy.config.ConfigSettingActivity
|
||||||
import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSSettingsActivity
|
import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSSettingsActivity
|
||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import java.net.InetAddress
|
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
@ -115,6 +114,8 @@ import java.util.concurrent.ConcurrentLinkedQueue
|
|||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
import kotlin.collections.set
|
import kotlin.collections.set
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import moe.matsuri.nb4a.ui.ConnectionTestNotification
|
||||||
|
|
||||||
class ConfigurationFragment @JvmOverloads constructor(
|
class ConfigurationFragment @JvmOverloads constructor(
|
||||||
val select: Boolean = false, val selectedItem: ProxyEntity? = null, val titleRes: Int = 0
|
val select: Boolean = false, val selectedItem: ProxyEntity? = null, val titleRes: Int = 0
|
||||||
@ -160,6 +161,7 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
|
|
||||||
override fun onQueryTextSubmit(query: String): Boolean = false
|
override fun onQueryTextSubmit(query: String): Boolean = false
|
||||||
|
|
||||||
|
@SuppressLint("DetachAndAttachSameFragment")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@ -317,7 +319,7 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
snackbar(getString(R.string.no_proxies_found_in_file)).show()
|
snackbar(getString(R.string.no_proxies_found_in_file)).show()
|
||||||
} else import(proxies)
|
} else import(proxies)
|
||||||
} catch (e: SubscriptionFoundException) {
|
} catch (e: SubscriptionFoundException) {
|
||||||
(requireActivity() as MainActivity).importSubscription(Uri.parse(e.link))
|
(requireActivity() as MainActivity).importSubscription(e.link.toUri())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logs.w(e)
|
Logs.w(e)
|
||||||
onMainDispatcher {
|
onMainDispatcher {
|
||||||
@ -360,7 +362,7 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
snackbar(getString(R.string.no_proxies_found_in_clipboard)).show()
|
snackbar(getString(R.string.no_proxies_found_in_clipboard)).show()
|
||||||
} else import(proxies)
|
} else import(proxies)
|
||||||
} catch (e: SubscriptionFoundException) {
|
} catch (e: SubscriptionFoundException) {
|
||||||
(requireActivity() as MainActivity).importSubscription(Uri.parse(e.link))
|
(requireActivity() as MainActivity).importSubscription(e.link.toUri())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logs.w(e)
|
Logs.w(e)
|
||||||
|
|
||||||
@ -597,15 +599,19 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
inner class TestDialog {
|
inner class TestDialog {
|
||||||
val binding = LayoutProgressListBinding.inflate(layoutInflater)
|
val binding = LayoutProgressListBinding.inflate(layoutInflater)
|
||||||
val builder = MaterialAlertDialogBuilder(requireContext()).setView(binding.root)
|
val builder = MaterialAlertDialogBuilder(requireContext()).setView(binding.root)
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
.setPositiveButton(R.string.minimize) { _, _ ->
|
||||||
cancel()
|
minimize()
|
||||||
}
|
}
|
||||||
.setOnDismissListener {
|
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
.setCancelable(false)
|
.setCancelable(false)
|
||||||
|
|
||||||
lateinit var cancel: () -> Unit
|
lateinit var cancel: () -> Unit
|
||||||
|
lateinit var minimize: () -> Unit
|
||||||
|
|
||||||
|
var notification: ConnectionTestNotification? = null
|
||||||
|
|
||||||
val fragment by lazy { getCurrentGroupFragment() }
|
val fragment by lazy { getCurrentGroupFragment() }
|
||||||
val results = Collections.synchronizedList(mutableListOf<ProxyEntity?>())
|
val results = Collections.synchronizedList(mutableListOf<ProxyEntity?>())
|
||||||
var proxyN = 0
|
var proxyN = 0
|
||||||
@ -615,10 +621,10 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
results.add(profile)
|
results.add(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun update(profile: ProxyEntity) {
|
fun update(profile: ProxyEntity) {
|
||||||
fragment?.configurationListView?.post {
|
runOnMainDispatcher {
|
||||||
val context = context ?: return@post
|
val context = context ?: return@runOnMainDispatcher
|
||||||
if (!isAdded) return@post
|
if (!isAdded) return@runOnMainDispatcher
|
||||||
|
|
||||||
var profileStatusText: String? = null
|
var profileStatusText: String? = null
|
||||||
var profileStatusColor = 0
|
var profileStatusColor = 0
|
||||||
@ -669,38 +675,31 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
append("\n")
|
append("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val progress = finishedN.addAndGet(1)
|
||||||
binding.nowTesting.text = text
|
binding.nowTesting.text = text
|
||||||
binding.progress.text = "${finishedN.addAndGet(1)} / $proxyN"
|
binding.progress.text = "$progress / $proxyN"
|
||||||
|
|
||||||
|
notification?.updateNotification(progress, proxyN, progress >= proxyN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopService() {
|
|
||||||
if (DataStore.serviceState.started) SagerNet.stopService()
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
@Suppress("EXPERIMENTAL_API_USAGE")
|
@Suppress("EXPERIMENTAL_API_USAGE")
|
||||||
fun pingTest(icmpPing: Boolean) {
|
fun pingTest(icmpPing: Boolean) {
|
||||||
|
if (DataStore.runningTest) return else DataStore.runningTest = true
|
||||||
val test = TestDialog()
|
val test = TestDialog()
|
||||||
val testJobs = mutableListOf<Job>()
|
|
||||||
val dialog = test.builder.show()
|
val dialog = test.builder.show()
|
||||||
|
val testJobs = mutableListOf<Job>()
|
||||||
|
val group = DataStore.currentGroup()
|
||||||
|
|
||||||
val mainJob = runOnDefaultDispatcher {
|
val mainJob = runOnDefaultDispatcher {
|
||||||
if (DataStore.serviceState.started) {
|
|
||||||
stopService()
|
|
||||||
delay(500) // wait for service stop
|
|
||||||
}
|
|
||||||
val group = DataStore.currentGroup()
|
|
||||||
val profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id)
|
val profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id)
|
||||||
test.proxyN = profilesUnfiltered.size
|
test.proxyN = profilesUnfiltered.size
|
||||||
val profiles = ConcurrentLinkedQueue(profilesUnfiltered)
|
val profiles = ConcurrentLinkedQueue(profilesUnfiltered)
|
||||||
val testPool = newFixedThreadPoolContext(
|
|
||||||
DataStore.connectionTestConcurrent,
|
|
||||||
"pingTest"
|
|
||||||
)
|
|
||||||
repeat(DataStore.connectionTestConcurrent) {
|
repeat(DataStore.connectionTestConcurrent) {
|
||||||
testJobs.add(launch(testPool) {
|
testJobs.add(launch(Dispatchers.IO) {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val profile = profiles.poll() ?: break
|
val profile = profiles.poll() ?: break
|
||||||
|
|
||||||
@ -727,7 +726,7 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
var address = profile.requireBean().serverAddress
|
var address = profile.requireBean().serverAddress
|
||||||
if (!address.isIpAddress()) {
|
if (!address.isIpAddress()) {
|
||||||
try {
|
try {
|
||||||
InetAddress.getAllByName(address).apply {
|
SagerNet.underlyingNetwork!!.getAllByName(address).apply {
|
||||||
if (isNotEmpty()) {
|
if (isNotEmpty()) {
|
||||||
address = this[0].hostAddress
|
address = this[0].hostAddress
|
||||||
}
|
}
|
||||||
@ -746,7 +745,9 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
if (icmpPing) {
|
if (icmpPing) {
|
||||||
// removed
|
// removed
|
||||||
} else {
|
} else {
|
||||||
val socket = Socket()
|
val socket =
|
||||||
|
SagerNet.underlyingNetwork?.socketFactory?.createSocket()
|
||||||
|
?: Socket()
|
||||||
try {
|
try {
|
||||||
socket.soTimeout = 3000
|
socket.soTimeout = 3000
|
||||||
socket.bind(InetSocketAddress(0))
|
socket.bind(InetSocketAddress(0))
|
||||||
@ -802,13 +803,13 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
testJobs.joinAll()
|
testJobs.joinAll()
|
||||||
testPool.close()
|
|
||||||
|
|
||||||
onMainDispatcher {
|
runOnMainDispatcher {
|
||||||
dialog.dismiss()
|
test.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
test.cancel = {
|
test.cancel = {
|
||||||
|
dialog.dismiss()
|
||||||
runOnDefaultDispatcher {
|
runOnDefaultDispatcher {
|
||||||
test.results.filterNotNull().forEach {
|
test.results.filterNotNull().forEach {
|
||||||
try {
|
try {
|
||||||
@ -820,27 +821,32 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
GroupManager.postReload(DataStore.currentGroupId())
|
GroupManager.postReload(DataStore.currentGroupId())
|
||||||
mainJob.cancel()
|
mainJob.cancel()
|
||||||
testJobs.forEach { it.cancel() }
|
testJobs.forEach { it.cancel() }
|
||||||
|
DataStore.runningTest = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
test.minimize = {
|
||||||
|
test.notification = ConnectionTestNotification(
|
||||||
|
dialog.context,
|
||||||
|
"[${group.displayName()}] ${getString(R.string.connection_test)}"
|
||||||
|
)
|
||||||
|
dialog.hide()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
fun urlTest() {
|
fun urlTest() {
|
||||||
|
if (DataStore.runningTest) return else DataStore.runningTest = true
|
||||||
val test = TestDialog()
|
val test = TestDialog()
|
||||||
val dialog = test.builder.show()
|
val dialog = test.builder.show()
|
||||||
val testJobs = mutableListOf<Job>()
|
val testJobs = mutableListOf<Job>()
|
||||||
|
val group = DataStore.currentGroup()
|
||||||
|
|
||||||
val mainJob = runOnDefaultDispatcher {
|
val mainJob = runOnDefaultDispatcher {
|
||||||
val group = DataStore.currentGroup()
|
|
||||||
val profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id)
|
val profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id)
|
||||||
test.proxyN = profilesUnfiltered.size
|
test.proxyN = profilesUnfiltered.size
|
||||||
val profiles = ConcurrentLinkedQueue(profilesUnfiltered)
|
val profiles = ConcurrentLinkedQueue(profilesUnfiltered)
|
||||||
val testPool = newFixedThreadPoolContext(
|
|
||||||
DataStore.connectionTestConcurrent,
|
|
||||||
"urlTest"
|
|
||||||
)
|
|
||||||
repeat(DataStore.connectionTestConcurrent) {
|
repeat(DataStore.connectionTestConcurrent) {
|
||||||
testJobs.add(launch(testPool) {
|
testJobs.add(launch(Dispatchers.IO) {
|
||||||
val urlTest = UrlTest() // note: this is NOT in bg process
|
val urlTest = UrlTest() // note: this is NOT in bg process
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val profile = profiles.poll() ?: break
|
val profile = profiles.poll() ?: break
|
||||||
@ -866,11 +872,12 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
|
|
||||||
testJobs.joinAll()
|
testJobs.joinAll()
|
||||||
|
|
||||||
onMainDispatcher {
|
runOnMainDispatcher {
|
||||||
dialog.dismiss()
|
test.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
test.cancel = {
|
test.cancel = {
|
||||||
|
dialog.dismiss()
|
||||||
runOnDefaultDispatcher {
|
runOnDefaultDispatcher {
|
||||||
test.results.filterNotNull().forEach {
|
test.results.filterNotNull().forEach {
|
||||||
try {
|
try {
|
||||||
@ -882,8 +889,16 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
GroupManager.postReload(DataStore.currentGroupId())
|
GroupManager.postReload(DataStore.currentGroupId())
|
||||||
mainJob.cancel()
|
mainJob.cancel()
|
||||||
testJobs.forEach { it.cancel() }
|
testJobs.forEach { it.cancel() }
|
||||||
|
DataStore.runningTest = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
test.minimize = {
|
||||||
|
test.notification = ConnectionTestNotification(
|
||||||
|
dialog.context,
|
||||||
|
"[${group.displayName()}] ${getString(R.string.connection_test)}"
|
||||||
|
)
|
||||||
|
dialog.hide()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class GroupPagerAdapter : FragmentStateAdapter(this),
|
inner class GroupPagerAdapter : FragmentStateAdapter(this),
|
||||||
@ -1412,7 +1427,6 @@ class ConfigurationFragment @JvmOverloads constructor(
|
|||||||
|
|
||||||
fun reloadProfiles() {
|
fun reloadProfiles() {
|
||||||
var newProfiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id)
|
var newProfiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id)
|
||||||
val subscription = proxyGroup.subscription
|
|
||||||
when (proxyGroup.order) {
|
when (proxyGroup.order) {
|
||||||
GroupOrder.BY_NAME -> {
|
GroupOrder.BY_NAME -> {
|
||||||
newProfiles = newProfiles.sortedBy { it.displayName() }
|
newProfiles = newProfiles.sortedBy { it.displayName() }
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
package moe.matsuri.nb4a.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import io.nekohasekai.sagernet.R
|
||||||
|
import io.nekohasekai.sagernet.SagerNet
|
||||||
|
import io.nekohasekai.sagernet.ktx.Logs
|
||||||
|
|
||||||
|
class ConnectionTestNotification(val context: Context, val title: String) {
|
||||||
|
private val channelId = "connection-test"
|
||||||
|
private val notificationId = 1001
|
||||||
|
|
||||||
|
fun updateNotification(progress: Int, max: Int, finished: Boolean) {
|
||||||
|
try {
|
||||||
|
if (finished) {
|
||||||
|
SagerNet.notification.cancel(notificationId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val builder = NotificationCompat.Builder(context, channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_service_active)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentText("$progress / $max").setProgress(max, progress, false)
|
||||||
|
SagerNet.notification.notify(notificationId, builder.build())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logs.w(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -494,4 +494,5 @@
|
|||||||
<string name="check_update_no">检查成功,但没有更新。</string>
|
<string name="check_update_no">检查成功,但没有更新。</string>
|
||||||
<string name="reset_settings">恢复默认设置</string>
|
<string name="reset_settings">恢复默认设置</string>
|
||||||
<string name="reset_settings_message">恢复默认设置,但节点、分组等数据将保留。如需完全清除数据,请在系统设置中直接清除应用数据。</string>
|
<string name="reset_settings_message">恢复默认设置,但节点、分组等数据将保留。如需完全清除数据,请在系统设置中直接清除应用数据。</string>
|
||||||
|
<string name="minimize">最小化</string>
|
||||||
</resources>
|
</resources>
|
||||||
@ -574,4 +574,5 @@
|
|||||||
<string name="check_update_no">Check successful, but no updates.</string>
|
<string name="check_update_no">Check successful, but no updates.</string>
|
||||||
<string name="reset_settings">Restore default settings</string>
|
<string name="reset_settings">Restore default settings</string>
|
||||||
<string name="reset_settings_message">Restore default settings, but data such as nodes and groups will be retained. To completely clear data, clear application data directly in the system settings.</string>
|
<string name="reset_settings_message">Restore default settings, but data such as nodes and groups will be retained. To completely clear data, clear application data directly in the system settings.</string>
|
||||||
|
<string name="minimize">Minimize</string>
|
||||||
</resources>
|
</resources>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
PACKAGE_NAME=moe.nb4a
|
PACKAGE_NAME=moe.nb4a
|
||||||
VERSION_NAME=1.3.9
|
VERSION_NAME=1.3.9
|
||||||
PRE_VERSION_NAME=pre-1.4.0-20250904-1
|
PRE_VERSION_NAME=pre-1.4.0-20250904-2
|
||||||
VERSION_CODE=43
|
VERSION_CODE=43
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user