feat(subscriptions): add subscription grouping by category and multi-subscription support

This commit is contained in:
zarazaex69
2026-04-04 18:56:32 +03:00
parent 9518753cc2
commit ec3744d430
3 changed files with 180 additions and 41 deletions
@@ -3,4 +3,5 @@ package com.v2ray.ang.dto
data class GroupMapItem(
var id: String,
var remarks: String,
var subIds: List<String> = listOf(id)
)
@@ -75,6 +75,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun reloadServerList() {
serverList = if (subscriptionId.isEmpty()) {
MmkvManager.decodeAllServerList()
} else if (subscriptionId.startsWith("group_")) {
val allSubs = MmkvManager.decodeSubscriptions()
val groupSubs = when (subscriptionId) {
"group_white" -> allSubs.filter {
it.subscription.remarks.startsWith("БЕЛЫЕ", ignoreCase = true) ||
it.subscription.remarks.startsWith("WHITE", ignoreCase = true)
}
"group_black" -> allSubs.filter {
it.subscription.remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) ||
it.subscription.remarks.startsWith("BLACK", ignoreCase = true)
}
else -> emptyList()
}
val combined = mutableListOf<String>()
groupSubs.forEach { sub ->
combined.addAll(MmkvManager.decodeServerList(sub.guid))
}
combined
} else {
MmkvManager.decodeServerList(subscriptionId)
}
@@ -102,7 +120,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @param toPosition The target position of the server.
*/
fun swapServer(fromPosition: Int, toPosition: Int) {
if (subscriptionId.isEmpty()) {
if (subscriptionId.isEmpty() || subscriptionId.startsWith("group_")) {
return
}
@@ -152,6 +170,30 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
if (subscriptionId.isEmpty()) {
return AngConfigManager.updateConfigViaSubAll()
} else if (subscriptionId.startsWith("group_")) {
val allSubs = MmkvManager.decodeSubscriptions()
val groupSubs = when (subscriptionId) {
"group_white" -> allSubs.filter {
it.subscription.remarks.startsWith("БЕЛЫЕ", ignoreCase = true) ||
it.subscription.remarks.startsWith("WHITE", ignoreCase = true)
}
"group_black" -> allSubs.filter {
it.subscription.remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) ||
it.subscription.remarks.startsWith("BLACK", ignoreCase = true)
}
else -> emptyList()
}
var totalResult = SubscriptionUpdateResult()
groupSubs.forEach { sub ->
val result = AngConfigManager.updateConfigViaSub(SubscriptionCache(sub.guid, sub.subscription))
totalResult = SubscriptionUpdateResult(
configCount = totalResult.configCount + result.configCount,
successCount = totalResult.successCount + result.successCount,
failureCount = totalResult.failureCount + result.failureCount,
skipCount = totalResult.skipCount + result.skipCount
)
}
return totalResult
} else {
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return SubscriptionUpdateResult()
return AngConfigManager.updateConfigViaSub(SubscriptionCache(subscriptionId, subItem))
@@ -218,12 +260,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (serversCache.isEmpty()) {
return@launch
}
val actualSubId = if (subscriptionId.startsWith("group_")) {
""
} else {
subscriptionId
}
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG,
subscriptionId = subscriptionId,
serverGuids = if (keywordFilter.isNotEmpty()) serversCache.map { it.guid } else emptyList()
subscriptionId = actualSubId,
serverGuids = if (keywordFilter.isNotEmpty() || subscriptionId.startsWith("group_")) {
serversCache.map { it.guid }
} else {
emptyList()
}
)
)
}
@@ -266,18 +319,61 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
groups.add(
GroupMapItem(
id = "",
remarks = context.getString(R.string.filter_config_all)
remarks = context.getString(R.string.filter_config_all),
subIds = emptyList()
)
)
}
val whiteList = mutableListOf<String>()
val blackList = mutableListOf<String>()
val otherGroups = mutableListOf<GroupMapItem>()
subscriptions.forEach { sub ->
val remarks = sub.subscription.remarks
when {
remarks.startsWith("БЕЛЫЕ", ignoreCase = true) ||
remarks.startsWith("WHITE", ignoreCase = true) -> {
whiteList.add(sub.guid)
}
remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) ||
remarks.startsWith("BLACK", ignoreCase = true) -> {
blackList.add(sub.guid)
}
else -> {
otherGroups.add(
GroupMapItem(
id = sub.guid,
remarks = remarks,
subIds = listOf(sub.guid)
)
)
}
}
}
if (whiteList.isNotEmpty()) {
groups.add(
GroupMapItem(
id = sub.guid,
remarks = sub.subscription.remarks
id = "group_white",
remarks = "БЕЛЫЕ СПИСКИ",
subIds = whiteList
)
)
}
if (blackList.isNotEmpty()) {
groups.add(
GroupMapItem(
id = "group_black",
remarks = "ЧЕРНЫЕ СПИСКИ",
subIds = blackList
)
)
}
groups.addAll(otherGroups)
return groups
}
@@ -362,6 +458,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
MmkvManager.decodeSubsList().forEach { guid ->
sortByTestResultsForSub(guid)
}
} else if (subscriptionId.startsWith("group_")) {
val allSubs = MmkvManager.decodeSubscriptions()
val groupSubs = when (subscriptionId) {
"group_white" -> allSubs.filter {
it.subscription.remarks.startsWith("БЕЛЫЕ", ignoreCase = true) ||
it.subscription.remarks.startsWith("WHITE", ignoreCase = true)
}
"group_black" -> allSubs.filter {
it.subscription.remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) ||
it.subscription.remarks.startsWith("BLACK", ignoreCase = true)
}
else -> emptyList()
}
groupSubs.forEach { sub ->
sortByTestResultsForSub(sub.guid)
}
} else {
sortByTestResultsForSub(subscriptionId)
}
@@ -413,14 +525,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun findSubscriptionIdBySelect(): String? {
// Get the selected server GUID
val selectedGuid = MmkvManager.getSelectServer()
if (selectedGuid.isNullOrEmpty()) {
return null
}
val config = MmkvManager.decodeServerConfig(selectedGuid)
return config?.subscriptionId
val subId = config?.subscriptionId ?: return null
val subscription = MmkvManager.decodeSubscription(subId)
val remarks = subscription?.remarks ?: return subId
return when {
remarks.startsWith("БЕЛЫЕ", ignoreCase = true) ||
remarks.startsWith("WHITE", ignoreCase = true) -> "group_white"
remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) ||
remarks.startsWith("BLACK", ignoreCase = true) -> "group_black"
else -> subId
}
}
fun onTestsFinished() {
@@ -48,8 +48,10 @@
android:id="@+id/tab_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabIndicatorFullWidth="false"
app:tabMode="scrollable"
app:tabIndicatorFullWidth="true"
app:tabMode="fixed"
app:tabGravity="fill"
app:tabMaxWidth="0dp"
app:tabTextAppearance="@style/TabLayoutTextStyle" />
<androidx.viewpager2.widget.ViewPager2
@@ -71,58 +73,72 @@
android:background="@color/divider_color_light" />
<LinearLayout
android:id="@+id/layout_test"
android:layout_width="match_parent"
android:layout_height="@dimen/view_height_dp64"
android:clickable="true"
android:contentDescription="@string/connection_test_pending"
android:focusable="true"
android:nextFocusLeft="@+id/view_pager"
android:nextFocusRight="@+id/fab"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tv_test_state"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="start|center_vertical"
android:maxLines="2"
android:minLines="1"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/connection_test_pending"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
android:gravity="center"
android:paddingTop="12dp"
android:paddingBottom="8dp">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btn_summary_lite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginEnd="16dp"
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_lite_bolt"
app:tint="@color/colorWhite"
app:backgroundTint="@color/color_fab_inactive"
app:fabSize="mini"
app:maxImageSize="24dp"
app:elevation="0dp"
app:pressedTranslationZ="0dp"
app:hoveredFocusedTranslationZ="0dp" />
app:fabSize="normal"
app:maxImageSize="28dp"
app:elevation="4dp"
app:pressedTranslationZ="8dp"
app:hoveredFocusedTranslationZ="6dp" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/padding_spacing_dp16"
android:clickable="true"
android:contentDescription="@string/tasker_start_service"
android:focusable="true"
android:nextFocusLeft="@+id/layout_test"
android:src="@drawable/ic_play_24dp"
app:tint="@color/colorWhite"
app:fabSize="mini"
app:elevation="0dp"
app:pressedTranslationZ="0dp"
app:hoveredFocusedTranslationZ="0dp" />
app:fabSize="normal"
app:maxImageSize="28dp"
app:elevation="4dp"
app:pressedTranslationZ="8dp"
app:hoveredFocusedTranslationZ="6dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_test"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:contentDescription="@string/connection_test_pending"
android:focusable="true"
android:nextFocusLeft="@+id/view_pager"
android:nextFocusRight="@+id/fab"
android:orientation="vertical"
android:gravity="center"
android:paddingTop="4dp"
android:paddingBottom="12dp"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/tv_test_state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:maxLines="2"
android:minLines="1"
android:text="@string/connection_test_pending"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
</LinearLayout>