feat: support multi-select

This commit is contained in:
lisonge 2024-06-09 17:09:10 +08:00
parent 9f0f026459
commit f34b1ca1ea
13 changed files with 303 additions and 138 deletions

View File

@ -114,6 +114,7 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
resValue("string", "app_name", "GKD-debug") resValue("string", "app_name", "GKD-debug")
resValue("string", "capture_label", "捕获快照-debug") resValue("string", "capture_label", "捕获快照-debug")
resValue("string", "import_desc", "GKD-debug-导入数据")
} }
} }
compileOptions { compileOptions {

View File

@ -13,6 +13,7 @@ import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem import li.songe.gkd.data.SubsItem
import li.songe.gkd.db.DbSet import li.songe.gkd.db.DbSet
import li.songe.gkd.permission.authReasonFlow import li.songe.gkd.permission.authReasonFlow
import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.checkUpdate import li.songe.gkd.util.checkUpdate
import li.songe.gkd.util.clearCache import li.songe.gkd.util.clearCache
import li.songe.gkd.util.launchTry import li.songe.gkd.util.launchTry
@ -24,7 +25,7 @@ class MainViewModel : ViewModel() {
init { init {
val localSubsItem = SubsItem( val localSubsItem = SubsItem(
id = -2, order = -2, mtime = System.currentTimeMillis() id = LOCAL_SUBS_ID, order = -2, mtime = System.currentTimeMillis()
) )
viewModelScope.launchTry(Dispatchers.IO) { viewModelScope.launchTry(Dispatchers.IO) {
val subsItems = DbSet.subsItemDao.queryAll() val subsItems = DbSet.subsItemDao.queryAll()

View File

@ -40,6 +40,9 @@ data class CategoryConfig(
@Query("DELETE FROM category_config WHERE subs_item_id=:subsItemId") @Query("DELETE FROM category_config WHERE subs_item_id=:subsItemId")
suspend fun deleteBySubsItemId(subsItemId: Long): Int suspend fun deleteBySubsItemId(subsItemId: Long): Int
@Query("DELETE FROM category_config WHERE subs_item_id IN (:subsIds)")
suspend fun deleteBySubsId(vararg subsIds: Long): Int
@Query("DELETE FROM category_config WHERE subs_item_id=:subsItemId AND category_key=:categoryKey") @Query("DELETE FROM category_config WHERE subs_item_id=:subsItemId AND category_key=:categoryKey")
suspend fun deleteByCategoryKey(subsItemId: Long, categoryKey: Int): Int suspend fun deleteByCategoryKey(subsItemId: Long, categoryKey: Int): Int

View File

@ -58,8 +58,8 @@ data class ClickLog(
suspend fun delete(vararg objects: ClickLog): Int suspend fun delete(vararg objects: ClickLog): Int
@Query("DELETE FROM click_log WHERE subs_id=:subsItemId") @Query("DELETE FROM click_log WHERE subs_id IN (:subsIds)")
suspend fun deleteBySubsId(subsItemId: Long): Int suspend fun deleteBySubsId(vararg subsIds: Long): Int
@Query("DELETE FROM click_log") @Query("DELETE FROM click_log")
suspend fun deleteAll() suspend fun deleteAll()

View File

@ -53,6 +53,9 @@ data class SubsConfig(
@Query("DELETE FROM subs_config WHERE subs_item_id=:subsItemId") @Query("DELETE FROM subs_config WHERE subs_item_id=:subsItemId")
suspend fun delete(subsItemId: Long): Int suspend fun delete(subsItemId: Long): Int
@Query("DELETE FROM subs_config WHERE subs_item_id IN (:subsIds)")
suspend fun deleteBySubsId(vararg subsIds: Long): Int
@Query("DELETE FROM subs_config WHERE subs_item_id=:subsItemId AND app_id=:appId") @Query("DELETE FROM subs_config WHERE subs_item_id=:subsItemId AND app_id=:appId")
suspend fun delete(subsItemId: Long, appId: String): Int suspend fun delete(subsItemId: Long, appId: String): Int

View File

@ -10,11 +10,16 @@ import androidx.room.PrimaryKey
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Update import androidx.room.Update
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import li.songe.gkd.appScope
import li.songe.gkd.db.DbSet import li.songe.gkd.db.DbSet
import li.songe.gkd.util.deleteSubscription
import li.songe.gkd.util.isSafeUrl import li.songe.gkd.util.isSafeUrl
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.subsFolder
import li.songe.gkd.util.subsIdToRawFlow
@Serializable @Serializable
@Entity( @Entity(
@ -50,14 +55,6 @@ data class SubsItem(
} }
} }
suspend fun removeAssets() {
deleteSubscription(id)
DbSet.subsItemDao.delete(this)
DbSet.subsConfigDao.delete(id)
DbSet.clickLogDao.deleteBySubsId(id)
DbSet.categoryConfigDao.deleteBySubsItemId(id)
}
@Dao @Dao
interface SubsItemDao { interface SubsItemDao {
@Update @Update
@ -94,8 +91,28 @@ data class SubsItem(
@Query("SELECT * FROM subs_item ORDER BY `order`") @Query("SELECT * FROM subs_item ORDER BY `order`")
fun queryAll(): List<SubsItem> fun queryAll(): List<SubsItem>
@Query("SELECT * FROM subs_item WHERE id=:id") @Query("DELETE FROM subs_item WHERE id IN (:ids)")
fun queryById(id: Long): Flow<SubsItem?> suspend fun deleteById(vararg ids: Long)
} }
} }
fun deleteSubscription(vararg subsIds: Long) {
appScope.launchTry(Dispatchers.IO) {
DbSet.subsItemDao.deleteById(*subsIds)
DbSet.subsConfigDao.deleteBySubsId(*subsIds)
DbSet.clickLogDao.deleteBySubsId(*subsIds)
DbSet.categoryConfigDao.deleteBySubsId(*subsIds)
val newMap = subsIdToRawFlow.value.toMutableMap()
subsIds.forEach { id ->
newMap.remove(id)
subsFolder.resolve("$id.json").apply {
if (exists()) {
delete()
}
}
}
subsIdToRawFlow.value = newMap.toImmutableMap()
}
}

View File

@ -2,6 +2,7 @@ package li.songe.gkd.data
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import li.songe.gkd.db.DbSet import li.songe.gkd.db.DbSet
import li.songe.gkd.util.LOCAL_SUBS_IDS
import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.subsItemsFlow
import li.songe.gkd.util.updateSubscription import li.songe.gkd.util.updateSubscription
@ -21,30 +22,30 @@ data class TransferData(
} }
} }
suspend fun exportTransferData(subsItemIds: List<Long>): TransferData { suspend fun exportTransferData(subsItemIds: Collection<Long>): TransferData {
return TransferData( return TransferData(
subsItems = subsItemsFlow.value.filter { subsItemIds.contains(it.id) }, subsItems = subsItemsFlow.value.filter { subsItemIds.contains(it.id) },
subscriptions = subsIdToRawFlow.value.values.filter { it.id < 0 && subsItemIds.contains(it.id) }, subscriptions = subsIdToRawFlow.value.values.filter { it.id < 0 && subsItemIds.contains(it.id) },
subsConfigs = DbSet.subsConfigDao.querySubsItemConfig(subsItemIds), subsConfigs = DbSet.subsConfigDao.querySubsItemConfig(subsItemIds.toList()),
categoryConfigs = DbSet.categoryConfigDao.querySubsItemConfig(subsItemIds), categoryConfigs = DbSet.categoryConfigDao.querySubsItemConfig(subsItemIds.toList()),
) )
} }
suspend fun importTransferData(transferData: TransferData): Boolean { suspend fun importTransferData(transferData: TransferData): Boolean {
// TODO transaction // TODO transaction
val localIds = arrayOf(-1L, -2L)
val maxOrder = (subsItemsFlow.value.maxOfOrNull { it.order } ?: -1) + 1 val maxOrder = (subsItemsFlow.value.maxOfOrNull { it.order } ?: -1) + 1
val subsItems = transferData.subsItems.filter { s -> s.id >= 0 || localIds.contains(s.id) } val subsItems =
.mapIndexed { i, s -> transferData.subsItems.filter { s -> s.id >= 0 || LOCAL_SUBS_IDS.contains(s.id) }
s.copy(order = maxOrder + i) .mapIndexed { i, s ->
} s.copy(order = maxOrder + i)
}
val hasNewSubsItem = val hasNewSubsItem =
subsItems.any { newSubs -> newSubs.id >= 0 && subsItemsFlow.value.all { oldSubs -> oldSubs.id != newSubs.id } } subsItems.any { newSubs -> newSubs.id >= 0 && subsItemsFlow.value.all { oldSubs -> oldSubs.id != newSubs.id } }
DbSet.subsItemDao.insertOrIgnore(*subsItems.toTypedArray()) DbSet.subsItemDao.insertOrIgnore(*subsItems.toTypedArray())
DbSet.subsConfigDao.insertOrIgnore(*transferData.subsConfigs.toTypedArray()) DbSet.subsConfigDao.insertOrIgnore(*transferData.subsConfigs.toTypedArray())
DbSet.categoryConfigDao.insertOrIgnore(*transferData.categoryConfigs.toTypedArray()) DbSet.categoryConfigDao.insertOrIgnore(*transferData.categoryConfigs.toTypedArray())
transferData.subscriptions.forEach { subscription -> transferData.subscriptions.forEach { subscription ->
if (localIds.contains(subscription.id)) { if (LOCAL_SUBS_IDS.contains(subscription.id)) {
updateSubscription(subscription) updateSubscription(subscription)
} }
} }

View File

@ -35,12 +35,14 @@ import li.songe.gkd.data.GkdAction
import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.RpcError import li.songe.gkd.data.RpcError
import li.songe.gkd.data.SubsItem import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.deleteSubscription
import li.songe.gkd.db.DbSet import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt.captureSnapshot import li.songe.gkd.debug.SnapshotExt.captureSnapshot
import li.songe.gkd.notif.createNotif import li.songe.gkd.notif.createNotif
import li.songe.gkd.notif.httpChannel import li.songe.gkd.notif.httpChannel
import li.songe.gkd.notif.httpNotif import li.songe.gkd.notif.httpNotif
import li.songe.gkd.service.GkdAbService import li.songe.gkd.service.GkdAbService
import li.songe.gkd.util.LOCAL_HTTP_SUBS_ID
import li.songe.gkd.util.SERVER_SCRIPT_URL import li.songe.gkd.util.SERVER_SCRIPT_URL
import li.songe.gkd.util.getIpAddressInLocalNetwork import li.songe.gkd.util.getIpAddressInLocalNetwork
import li.songe.gkd.util.keepNullJson import li.songe.gkd.util.keepNullJson
@ -58,7 +60,7 @@ class HttpService : CompositionService({
val scope = CoroutineScope(Dispatchers.IO) val scope = CoroutineScope(Dispatchers.IO)
val httpSubsItem = SubsItem( val httpSubsItem = SubsItem(
id = -1L, id = LOCAL_HTTP_SUBS_ID,
order = -1, order = -1,
enableUpdate = false, enableUpdate = false,
) )
@ -101,7 +103,7 @@ class HttpService : CompositionService({
val subscription = val subscription =
RawSubscription.parse(call.receiveText(), json5 = false) RawSubscription.parse(call.receiveText(), json5 = false)
.copy( .copy(
id = -1, id = LOCAL_HTTP_SUBS_ID,
name = "内存订阅", name = "内存订阅",
version = 0, version = 0,
author = "@gkd-kit/inspect" author = "@gkd-kit/inspect"
@ -155,7 +157,7 @@ class HttpService : CompositionService({
scope.launchTry(Dispatchers.IO) { scope.launchTry(Dispatchers.IO) {
server?.stop() server?.stop()
if (storeFlow.value.autoClearMemorySubs) { if (storeFlow.value.autoClearMemorySubs) {
httpSubsItem.removeAssets() deleteSubscription(LOCAL_HTTP_SUBS_ID)
} }
delay(3000) delay(3000)
scope.cancel() scope.cancel()
@ -194,11 +196,7 @@ fun clearHttpSubs() {
appScope.launchTry(Dispatchers.IO) { appScope.launchTry(Dispatchers.IO) {
delay(1000) delay(1000)
if (storeFlow.value.autoClearMemorySubs) { if (storeFlow.value.autoClearMemorySubs) {
SubsItem( deleteSubscription(LOCAL_HTTP_SUBS_ID)
id = -1L,
order = -1,
enableUpdate = false,
).removeAssets()
} }
} }
} }

View File

@ -58,6 +58,7 @@ import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.destinations.AppItemPageDestination import li.songe.gkd.ui.destinations.AppItemPageDestination
import li.songe.gkd.ui.destinations.GlobalRulePageDestination import li.songe.gkd.ui.destinations.GlobalRulePageDestination
import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.itemPadding
import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.RuleSortOption import li.songe.gkd.util.RuleSortOption
@ -150,7 +151,7 @@ fun AppConfigPage(appId: String) {
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
navController.navigate(AppItemPageDestination(-2, appId)) navController.navigate(AppItemPageDestination(LOCAL_SUBS_ID, appId))
}, },
content = { content = {
Icon( Icon(

View File

@ -1,6 +1,8 @@
package li.songe.gkd.ui.component package li.songe.gkd.ui.component
import android.content.Context
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -8,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
@ -36,10 +39,12 @@ import kotlinx.serialization.encodeToString
import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.TransferData import li.songe.gkd.data.TransferData
import li.songe.gkd.data.deleteSubscription
import li.songe.gkd.data.exportTransferData import li.songe.gkd.data.exportTransferData
import li.songe.gkd.ui.destinations.CategoryPageDestination import li.songe.gkd.ui.destinations.CategoryPageDestination
import li.songe.gkd.ui.destinations.GlobalRulePageDestination import li.songe.gkd.ui.destinations.GlobalRulePageDestination
import li.songe.gkd.ui.destinations.SubsPageDestination import li.songe.gkd.ui.destinations.SubsPageDestination
import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.exportZipDir import li.songe.gkd.util.exportZipDir
import li.songe.gkd.util.formatTimeAgo import li.songe.gkd.util.formatTimeAgo
@ -63,7 +68,10 @@ fun SubsItemCard(
subscription: RawSubscription?, subscription: RawSubscription?,
index: Int, index: Int,
vm: ViewModel, vm: ViewModel,
isSelectedMode: Boolean,
isSelected: Boolean,
onCheckedChange: ((Boolean) -> Unit)? = null, onCheckedChange: ((Boolean) -> Unit)? = null,
onSelectedChange: (() -> Unit)? = null,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val subsLoadError by remember(subsItem.id) { val subsLoadError by remember(subsItem.id) {
@ -74,15 +82,28 @@ fun SubsItemCard(
}.collectAsState() }.collectAsState()
val subsRefreshing by subsRefreshingFlow.collectAsState() val subsRefreshing by subsRefreshingFlow.collectAsState()
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Card( val dragged by interactionSource.collectIsDraggedAsState()
onClick = { val onClick = {
if (!subsRefreshingFlow.value) { if (!dragged) {
if (isSelectedMode) {
onSelectedChange?.invoke()
} else if (!subsRefreshingFlow.value) {
expanded = true expanded = true
} }
}, }
}
Card(
onClick = onClick,
modifier = modifier.padding(16.dp, 2.dp), modifier = modifier.padding(16.dp, 2.dp),
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
interactionSource = interactionSource, interactionSource = interactionSource,
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
Color.Unspecified
}
),
) { ) {
SubsMenuItem( SubsMenuItem(
expanded = expanded, expanded = expanded,
@ -165,7 +186,8 @@ fun SubsItemCard(
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Switch( Switch(
checked = subsItem.enable, checked = subsItem.enable,
onCheckedChange = onCheckedChange, enabled = !isSelectedMode,
onCheckedChange = if (isSelectedMode) null else onCheckedChange,
) )
} }
} }
@ -222,28 +244,12 @@ private fun SubsMenuItem(
} }
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text(text = "导出数据") Text(text = "分享数据")
}, },
onClick = { onClick = {
onExpandedChange(false) onExpandedChange(false)
vm.viewModelScope.launchTry(Dispatchers.IO) { vm.viewModelScope.launchTry(Dispatchers.IO) {
val transferDataFile = exportZipDir.resolve("${TransferData.TYPE}.json") context.shareSubs(subItem.id)
transferDataFile.writeText(
json.encodeToString(
exportTransferData(
listOf(
subItem.id
)
)
)
)
val file = exportZipDir.resolve("backup-${subItem.id}.zip")
if (file.exists()) {
file.delete()
}
ZipUtils.zipFiles(listOf(transferDataFile), file)
transferDataFile.delete()
context.shareFile(file, "分享数据文件")
} }
} }
) )
@ -270,7 +276,7 @@ private fun SubsMenuItem(
} }
) )
} }
if (subItem.id != -2L) { if (subItem.id != LOCAL_SUBS_ID) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text(text = "删除订阅", color = MaterialTheme.colorScheme.error) Text(text = "删除订阅", color = MaterialTheme.colorScheme.error)
@ -283,10 +289,21 @@ private fun SubsMenuItem(
"是否删除订阅 ${subscription?.name ?: subItem.id} ?", "是否删除订阅 ${subscription?.name ?: subItem.id} ?",
) )
if (!result) return@launchTry if (!result) return@launchTry
subItem.removeAssets() deleteSubscription(subItem.id)
} }
} }
) )
} }
} }
} }
suspend fun Context.shareSubs(vararg subsIds: Long) {
val transferDataFile = exportZipDir.resolve("${TransferData.TYPE}.json")
transferDataFile.writeText(
json.encodeToString(exportTransferData(subsIds.toList()))
)
val file = exportZipDir.resolve("backup-${System.currentTimeMillis()}.zip")
ZipUtils.zipFiles(listOf(transferDataFile), file)
transferDataFile.delete()
this.shareFile(file, "分享数据文件")
}

View File

@ -2,6 +2,7 @@ package li.songe.gkd.ui.home
import android.content.Intent import android.content.Intent
import android.webkit.URLUtil import android.webkit.URLUtil
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -18,7 +19,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@ -44,6 +48,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -54,10 +59,14 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import li.songe.gkd.data.TransferData import li.songe.gkd.data.TransferData
import li.songe.gkd.data.Value
import li.songe.gkd.data.deleteSubscription
import li.songe.gkd.data.importTransferData import li.songe.gkd.data.importTransferData
import li.songe.gkd.db.DbSet import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.SubsItemCard import li.songe.gkd.ui.component.SubsItemCard
import li.songe.gkd.ui.component.getDialogResult import li.songe.gkd.ui.component.getDialogResult
import li.songe.gkd.ui.component.shareSubs
import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalLauncher import li.songe.gkd.util.LocalLauncher
import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.checkSubsUpdate
import li.songe.gkd.util.isSafeUrl import li.songe.gkd.util.isSafeUrl
@ -79,6 +88,7 @@ val subsNav = BottomNavItem(
@Composable @Composable
fun useSubsManagePage(): ScaffoldExt { fun useSubsManagePage(): ScaffoldExt {
val launcher = LocalLauncher.current val launcher = LocalLauncher.current
val context = LocalContext.current
val vm = hiltViewModel<HomeVm>() val vm = hiltViewModel<HomeVm>()
val subItems by subsItemsFlow.collectAsState() val subItems by subsItemsFlow.collectAsState()
@ -96,6 +106,24 @@ fun useSubsManagePage(): ScaffoldExt {
val refreshing by subsRefreshingFlow.collectAsState() val refreshing by subsRefreshingFlow.collectAsState()
val pullRefreshState = rememberPullRefreshState(refreshing, { checkSubsUpdate(true) }) val pullRefreshState = rememberPullRefreshState(refreshing, { checkSubsUpdate(true) })
var isSelectedMode by remember { mutableStateOf(false) }
var selectedIds by remember { mutableStateOf(emptySet<Long>()) }
val draggedFlag = remember { Value(false) }
LaunchedEffect(key1 = isSelectedMode) {
if (!isSelectedMode && selectedIds.isNotEmpty()) {
selectedIds = emptySet()
}
}
if (isSelectedMode) {
BackHandler {
isSelectedMode = false
}
}
LaunchedEffect(key1 = subItems.size) {
if (subItems.size <= 1) {
isSelectedMode = false
}
}
LaunchedEffect(showAddLinkDialog) { LaunchedEffect(showAddLinkDialog) {
if (!showAddLinkDialog) { if (!showAddLinkDialog) {
@ -119,53 +147,92 @@ fun useSubsManagePage(): ScaffoldExt {
isError = link.isNotEmpty() && !URLUtil.isNetworkUrl(link), isError = link.isNotEmpty() && !URLUtil.isNetworkUrl(link),
) )
}, onDismissRequest = { showAddLinkDialog = false }, confirmButton = { }, onDismissRequest = { showAddLinkDialog = false }, confirmButton = {
TextButton( TextButton(enabled = link.isNotBlank(), onClick = {
enabled = link.isNotBlank(), if (!URLUtil.isNetworkUrl(link)) {
onClick = { toast("非法链接")
if (!URLUtil.isNetworkUrl(link)) { return@TextButton
toast("非法链接") }
return@TextButton if (subItems.any { s -> s.updateUrl == link }) {
toast("链接已存在")
return@TextButton
}
vm.viewModelScope.launchTry {
if (!isSafeUrl(link)) {
val result = getDialogResult(
"未知来源",
"你正在添加一个未验证的远程订阅\n\n这可能含有恶意的规则\n\n是否仍然确认添加?"
)
if (!result) return@launchTry
} }
if (subItems.any { s -> s.updateUrl == link }) { showAddLinkDialog = false
toast("链接已存在") vm.addSubsFromUrl(url = link)
return@TextButton }
} }) {
vm.viewModelScope.launchTry {
if (!isSafeUrl(link)) {
val result = getDialogResult(
"未知来源",
"你正在添加一个未验证的远程订阅\n\n这可能含有恶意的规则\n\n是否仍然确认添加?"
)
if (!result) return@launchTry
}
showAddLinkDialog = false
vm.addSubsFromUrl(url = link)
}
}) {
Text(text = "添加") Text(text = "添加")
} }
}) })
} }
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
return ScaffoldExt( return ScaffoldExt(
navItem = subsNav, navItem = subsNav,
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
TopAppBar( TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = {
scrollBehavior = scrollBehavior, if (isSelectedMode) {
title = { IconButton(onClick = { isSelectedMode = false }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
)
}
}
}, title = {
if (isSelectedMode) {
Text(
text = if (selectedIds.isNotEmpty()) selectedIds.size.toString() else "",
)
} else {
Text( Text(
text = subsNav.label, text = subsNav.label,
) )
}, }
actions = { }, actions = {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { if (isSelectedMode) {
if (subsRefreshingFlow.value) { val canDeleteIds = if (selectedIds.contains(LOCAL_SUBS_ID)) {
toast("正在刷新订阅,请稍后操作") selectedIds - LOCAL_SUBS_ID
return@IconButton } else {
selectedIds
}
if (canDeleteIds.isNotEmpty()) {
IconButton(onClick = vm.viewModelScope.launchAsFn {
val result = getDialogResult(
"删除订阅",
"是否删除所选 ${canDeleteIds.size} 个订阅?\n\n注: 不包含本地订阅",
)
if (!result) return@launchAsFn
deleteSubscription(*canDeleteIds.toLongArray())
selectedIds = selectedIds - canDeleteIds
if (selectedIds.size == canDeleteIds.size) {
isSelectedMode = false
}
}) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = null,
)
} }
}
IconButton(onClick = vm.viewModelScope.launchAsFn(Dispatchers.IO) {
context.shareSubs(*selectedIds.toLongArray())
}) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
)
}
IconButton(onClick = {
expanded = true expanded = true
}) { }) {
Icon( Icon(
@ -173,14 +240,49 @@ fun useSubsManagePage(): ScaffoldExt {
contentDescription = null, contentDescription = null,
) )
} }
Box( } else {
modifier = Modifier IconButton(onClick = {
.wrapContentSize(Alignment.TopStart) if (subsRefreshingFlow.value) {
) { toast("正在刷新订阅,请稍后操作")
DropdownMenu( } else {
expanded = expanded, expanded = true
onDismissRequest = { expanded = false } }
) { }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null,
)
}
}
Box(
modifier = Modifier.wrapContentSize(Alignment.TopStart)
) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
if (isSelectedMode) {
DropdownMenuItem(
text = {
Text(text = "全选")
},
onClick = {
expanded = false
selectedIds = subItems.map { it.id }.toSet()
}
)
DropdownMenuItem(
text = {
Text(text = "反选")
},
onClick = {
expanded = false
val newSelectedIds =
subItems.map { it.id }.toSet() - selectedIds
if (newSelectedIds.isEmpty()) {
isSelectedMode = false
}
selectedIds = newSelectedIds
}
)
} else {
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { leadingIcon = {
Icon( Icon(
@ -204,8 +306,7 @@ fun useSubsManagePage(): ScaffoldExt {
return@launchAsFn return@launchAsFn
} }
val string = readFileZipByteArray( val string = readFileZipByteArray(
UriUtils.uri2Bytes(uri), UriUtils.uri2Bytes(uri), "${TransferData.TYPE}.json"
"${TransferData.TYPE}.json"
) )
if (string != null) { if (string != null) {
val transferData = val transferData =
@ -220,20 +321,22 @@ fun useSubsManagePage(): ScaffoldExt {
} }
} }
} }
) })
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = { if (!isSelectedMode) {
if (subsRefreshingFlow.value) { FloatingActionButton(onClick = {
toast("正在刷新订阅,请稍后操作") if (subsRefreshingFlow.value) {
return@FloatingActionButton toast("正在刷新订阅,请稍后操作")
return@FloatingActionButton
}
showAddLinkDialog = true
}) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "info",
)
} }
showAddLinkDialog = true
}) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "info",
)
} }
}, },
) { padding -> ) { padding ->
@ -248,6 +351,7 @@ fun useSubsManagePage(): ScaffoldExt {
} }
} }
}.toImmutableList() }.toImmutableList()
draggedFlag.value = true
} }
Box( Box(
modifier = Modifier modifier = Modifier
@ -261,38 +365,64 @@ fun useSubsManagePage(): ScaffoldExt {
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
itemsIndexed(orderSubItems, { _, subItem -> subItem.id }) { index, subItem -> itemsIndexed(orderSubItems, { _, subItem -> subItem.id }) { index, subItem ->
val canDrag = !refreshing && orderSubItems.size > 1
ReorderableItem( ReorderableItem(
reorderableLazyColumnState, reorderableLazyColumnState,
key = subItem.id, key = subItem.id,
enabled = !refreshing, enabled = canDrag,
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
SubsItemCard( SubsItemCard(
modifier = Modifier modifier = Modifier.longPressDraggableHandle(
.longPressDraggableHandle( enabled = canDrag,
enabled = !refreshing, interactionSource = interactionSource,
interactionSource = interactionSource, onDragStarted = {
onDragStopped = { if (orderSubItems.size > 1 && !isSelectedMode) {
val changeItems = orderSubItems.filter { newItem -> isSelectedMode = true
subItems.find { oldItem -> oldItem.id == newItem.id }?.order != newItem.order selectedIds = setOf(subItem.id)
}
},
onDragStopped = {
if (draggedFlag.value) {
draggedFlag.value = false
isSelectedMode = false
selectedIds = emptySet()
}
val changeItems = orderSubItems.filter { newItem ->
subItems.find { oldItem -> oldItem.id == newItem.id }?.order != newItem.order
}
if (changeItems.isNotEmpty()) {
vm.viewModelScope.launchTry {
DbSet.subsItemDao.batchUpdateOrder(changeItems)
} }
if (changeItems.isNotEmpty()) { }
vm.viewModelScope.launchTry { },
DbSet.subsItemDao.batchUpdateOrder(changeItems) ),
}
}
},
),
interactionSource = interactionSource, interactionSource = interactionSource,
subsItem = subItem, subsItem = subItem,
subscription = subsIdToRaw[subItem.id], subscription = subsIdToRaw[subItem.id],
index = index + 1, index = index + 1,
vm = vm, vm = vm,
isSelectedMode = isSelectedMode,
isSelected = selectedIds.contains(subItem.id),
onCheckedChange = { checked -> onCheckedChange = { checked ->
vm.viewModelScope.launch { vm.viewModelScope.launch {
DbSet.subsItemDao.updateEnable(subItem.id, checked) DbSet.subsItemDao.updateEnable(subItem.id, checked)
} }
}, },
onSelectedChange = {
val newSelectedIds = if (selectedIds.contains(subItem.id)) {
selectedIds.toMutableSet().apply {
remove(subItem.id)
}
} else {
selectedIds + subItem.id
}
selectedIds = newSelectedIds
if (newSelectedIds.isEmpty()) {
isSelectedMode = false
}
},
) )
} }
} }

View File

@ -38,4 +38,8 @@ private val safeRemoteBaseUrls = arrayOf(
fun isSafeUrl(url: String): Boolean { fun isSafeUrl(url: String): Boolean {
if (!URLUtil.isHttpsUrl(url)) return false if (!URLUtil.isHttpsUrl(url)) return false
return safeRemoteBaseUrls.any { u -> url.startsWith(u) } return safeRemoteBaseUrls.any { u -> url.startsWith(u) }
} }
const val LOCAL_SUBS_ID = -2L
const val LOCAL_HTTP_SUBS_ID = -1L
val LOCAL_SUBS_IDS = arrayOf(LOCAL_SUBS_ID, LOCAL_HTTP_SUBS_ID)

View File

@ -102,17 +102,6 @@ fun updateSubscription(subscription: RawSubscription) {
} }
} }
fun deleteSubscription(subsId: Long) {
val newMap = subsIdToRawFlow.value.toMutableMap()
newMap.remove(subsId)
subsIdToRawFlow.value = newMap.toImmutableMap()
subsFolder.resolve("$subsId.json").apply {
if (exists()) {
delete()
}
}
}
fun getGroupRawEnable( fun getGroupRawEnable(
group: RawSubscription.RawGroupProps, group: RawSubscription.RawGroupProps,
subsConfig: SubsConfig?, subsConfig: SubsConfig?,