perf: share save file
Some checks failed
Build-Apk / build (push) Has been cancelled

This commit is contained in:
lisonge 2024-08-02 23:07:25 +08:00
parent 36621c31b3
commit 8c12ee172f
9 changed files with 141 additions and 31 deletions

View File

@ -23,7 +23,7 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- save image to album --> <!-- save image to album, save file to Downloads -->
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />

View File

@ -1,6 +1,5 @@
package li.songe.gkd.data package li.songe.gkd.data
import android.content.Context
import android.net.Uri import android.net.Uri
import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.UriUtils import com.blankj.utilcode.util.UriUtils
@ -17,11 +16,11 @@ import li.songe.gkd.util.exportZipDir
import li.songe.gkd.util.importZipDir import li.songe.gkd.util.importZipDir
import li.songe.gkd.util.json import li.songe.gkd.util.json
import li.songe.gkd.util.resetDirectory import li.songe.gkd.util.resetDirectory
import li.songe.gkd.util.shareFile
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.toast import li.songe.gkd.util.toast
import li.songe.gkd.util.updateSubscription import li.songe.gkd.util.updateSubscription
import java.io.File
@Serializable @Serializable
private data class TransferData( private data class TransferData(
@ -52,8 +51,7 @@ private suspend fun importTransferData(transferData: TransferData): Boolean {
return hasNewSubsItem return hasNewSubsItem
} }
suspend fun exportData(context: Context, subsIds: Collection<Long>) { suspend fun exportData(subsIds: Collection<Long>):File {
if (subsIds.isEmpty()) return
exportZipDir.resetDirectory() exportZipDir.resetDirectory()
val dataFile = exportZipDir.resolve("${TransferData.TYPE}.json") val dataFile = exportZipDir.resolve("${TransferData.TYPE}.json")
dataFile.writeText( dataFile.writeText(
@ -74,7 +72,7 @@ suspend fun exportData(context: Context, subsIds: Collection<Long>) {
ZipUtils.zipFiles(listOf(dataFile, files), file) ZipUtils.zipFiles(listOf(dataFile, files), file)
dataFile.delete() dataFile.delete()
files.deleteRecursively() files.deleteRecursively()
context.shareFile(file, "分享数据文件") return file
} }
suspend fun importData(uri: Uri) { suspend fun importData(uri: Uri) {

View File

@ -125,7 +125,7 @@ val canDrawOverlaysState by lazy {
) )
} }
val canSaveToAlbumState by lazy { val canWriteExternalStorage by lazy {
PermissionState( PermissionState(
check = { check = {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@ -170,7 +170,7 @@ suspend fun updatePermissionState() {
arrayOf( arrayOf(
notificationState, notificationState,
canDrawOverlaysState, canDrawOverlaysState,
canSaveToAlbumState, canWriteExternalStorage,
shizukuOkState shizukuOkState
).forEach { it.updateAndGet() } ).forEach { it.updateAndGet() }
if (canQueryPkgState.stateFlow.value != canQueryPkgState.updateAndGet()) { if (canQueryPkgState.stateFlow.value != canQueryPkgState.updateAndGet()) {

View File

@ -55,7 +55,7 @@ import li.songe.gkd.MainActivity
import li.songe.gkd.data.Snapshot import li.songe.gkd.data.Snapshot
import li.songe.gkd.db.DbSet import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt import li.songe.gkd.debug.SnapshotExt
import li.songe.gkd.permission.canSaveToAlbumState import li.songe.gkd.permission.canWriteExternalStorage
import li.songe.gkd.permission.requiredPermission import li.songe.gkd.permission.requiredPermission
import li.songe.gkd.ui.component.StartEllipsisText import li.songe.gkd.ui.component.StartEllipsisText
import li.songe.gkd.ui.destinations.ImagePreviewPageDestination import li.songe.gkd.ui.destinations.ImagePreviewPageDestination
@ -66,6 +66,7 @@ import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.LocalPickContentLauncher import li.songe.gkd.util.LocalPickContentLauncher
import li.songe.gkd.util.ProfileTransitions import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.saveFileToDownloads
import li.songe.gkd.util.shareFile import li.songe.gkd.util.shareFile
import li.songe.gkd.util.snapshotZipDir import li.songe.gkd.util.snapshotZipDir
import li.songe.gkd.util.throttle import li.songe.gkd.util.throttle
@ -195,17 +196,31 @@ fun SnapshotPage() {
) )
HorizontalDivider() HorizontalDivider()
Text( Text(
text = "分享", text = "分享数据",
modifier = Modifier modifier = Modifier
.clickable(onClick = vm.viewModelScope.launchAsFn { .clickable(onClick = vm.viewModelScope.launchAsFn {
val zipFile =
SnapshotExt.getSnapshotZipFile(
snapshotVal.id,
snapshotVal.appId,
snapshotVal.activityId
)
context.shareFile(zipFile, "分享快照文件")
selectedSnapshot = null selectedSnapshot = null
val zipFile = SnapshotExt.getSnapshotZipFile(
snapshotVal.id,
snapshotVal.appId,
snapshotVal.activityId
)
context.shareFile(zipFile, "分享快照文件")
})
.then(modifier)
)
HorizontalDivider()
Text(
text = "保存到下载",
modifier = Modifier
.clickable(onClick = vm.viewModelScope.launchAsFn {
selectedSnapshot = null
val zipFile = SnapshotExt.getSnapshotZipFile(
snapshotVal.id,
snapshotVal.appId,
snapshotVal.activityId
)
context.saveFileToDownloads(zipFile)
}) })
.then(modifier) .then(modifier)
) )
@ -236,7 +251,7 @@ fun SnapshotPage() {
text = "保存截图到相册", text = "保存截图到相册",
modifier = Modifier modifier = Modifier
.clickable(onClick = vm.viewModelScope.launchAsFn { .clickable(onClick = vm.viewModelScope.launchAsFn {
requiredPermission(context, canSaveToAlbumState) requiredPermission(context, canWriteExternalStorage)
ImageUtils.save2Album( ImageUtils.save2Album(
ImageUtils.getBitmap(snapshotVal.screenshotFile), ImageUtils.getBitmap(snapshotVal.screenshotFile),
Bitmap.CompressFormat.PNG, Bitmap.CompressFormat.PNG,

View File

@ -28,7 +28,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.ClipboardUtils import com.blankj.utilcode.util.ClipboardUtils
import com.ramcosta.composedestinations.navigation.navigate import com.ramcosta.composedestinations.navigation.navigate
@ -36,10 +35,10 @@ import kotlinx.coroutines.Dispatchers
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.deleteSubscription import li.songe.gkd.data.deleteSubscription
import li.songe.gkd.data.exportData
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.ui.home.HomeVm
import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalMainViewModel import li.songe.gkd.util.LocalMainViewModel
import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.LocalNavController
@ -61,7 +60,7 @@ fun SubsItemCard(
subsItem: SubsItem, subsItem: SubsItem,
subscription: RawSubscription?, subscription: RawSubscription?,
index: Int, index: Int,
vm: ViewModel, vm: HomeVm,
isSelectedMode: Boolean, isSelectedMode: Boolean,
isSelected: Boolean, isSelected: Boolean,
onCheckedChange: ((Boolean) -> Unit)? = null, onCheckedChange: ((Boolean) -> Unit)? = null,
@ -192,7 +191,7 @@ private fun SubsMenuItem(
onExpandedChange: ((Boolean) -> Unit), onExpandedChange: ((Boolean) -> Unit),
subItem: SubsItem, subItem: SubsItem,
subscription: RawSubscription?, subscription: RawSubscription?,
vm: ViewModel vm: HomeVm
) { ) {
val navController = LocalNavController.current val navController = LocalNavController.current
val context = LocalContext.current val context = LocalContext.current
@ -243,7 +242,7 @@ private fun SubsMenuItem(
onClick = { onClick = {
onExpandedChange(false) onExpandedChange(false)
vm.viewModelScope.launchTry(Dispatchers.IO) { vm.viewModelScope.launchTry(Dispatchers.IO) {
exportData(context, listOf(subItem.id)) vm.showShareDataIdsFlow.value = setOf(subItem.id)
} }
} }
) )

View File

@ -218,4 +218,6 @@ class HomeVm @Inject constructor() : ViewModel() {
val clickLogCountFlow = val clickLogCountFlow =
DbSet.clickLogDao.count().stateIn(viewModelScope, SharingStarted.Eagerly, 0) DbSet.clickLogDao.count().stateIn(viewModelScope, SharingStarted.Eagerly, 0)
val showShareDataIdsFlow = MutableStateFlow<Set<Long>?>(null)
} }

View File

@ -66,6 +66,7 @@ import li.songe.gkd.util.checkUpdate
import li.songe.gkd.util.findOption import li.songe.gkd.util.findOption
import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry import li.songe.gkd.util.launchTry
import li.songe.gkd.util.saveFileToDownloads
import li.songe.gkd.util.shareFile import li.songe.gkd.util.shareFile
import li.songe.gkd.util.storeFlow import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.throttle import li.songe.gkd.util.throttle
@ -149,7 +150,7 @@ fun useSettingsPage(): ScaffoldExt {
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp)
Text( Text(
text = "调用系统分享", modifier = Modifier text = "分享数据", modifier = Modifier
.clickable(onClick = throttle { .clickable(onClick = throttle {
showShareLogDlg = false showShareLogDlg = false
vm.viewModelScope.launchTry(Dispatchers.IO) { vm.viewModelScope.launchTry(Dispatchers.IO) {
@ -159,6 +160,17 @@ fun useSettingsPage(): ScaffoldExt {
}) })
.then(modifier) .then(modifier)
) )
Text(
text = "保存到下载", modifier = Modifier
.clickable(onClick = throttle {
showShareLogDlg = false
vm.viewModelScope.launchTry(Dispatchers.IO) {
val logZipFile = buildLogFile()
context.saveFileToDownloads(logZipFile)
}
})
.then(modifier)
)
Text( Text(
text = "生成链接(需科学上网)", text = "生成链接(需科学上网)",
modifier = Modifier modifier = Modifier

View File

@ -3,6 +3,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.activity.compose.BackHandler
import androidx.compose.foundation.clickable
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
@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
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
@ -24,6 +26,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@ -51,19 +54,20 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext 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.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.dylanc.activityresult.launcher.launchForResult import com.dylanc.activityresult.launcher.launchForResult
import kotlinx.collections.immutable.toImmutableList 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.MainActivity
import li.songe.gkd.data.Value import li.songe.gkd.data.Value
import li.songe.gkd.data.deleteSubscription import li.songe.gkd.data.deleteSubscription
import li.songe.gkd.data.exportData import li.songe.gkd.data.exportData
import li.songe.gkd.data.importData import li.songe.gkd.data.importData
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.getResult
import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.component.waitResult
import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalLauncher import li.songe.gkd.util.LocalLauncher
@ -72,9 +76,12 @@ import li.songe.gkd.util.checkSubsUpdate
import li.songe.gkd.util.isSafeUrl import li.songe.gkd.util.isSafeUrl
import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry import li.songe.gkd.util.launchTry
import li.songe.gkd.util.saveFileToDownloads
import li.songe.gkd.util.shareFile
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.subsRefreshingFlow import li.songe.gkd.util.subsRefreshingFlow
import li.songe.gkd.util.throttle
import li.songe.gkd.util.toast import li.songe.gkd.util.toast
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@ -86,7 +93,6 @@ val subsNav = BottomNavItem(
@Composable @Composable
fun useSubsManagePage(): ScaffoldExt { fun useSubsManagePage(): ScaffoldExt {
val launcher = LocalLauncher.current val launcher = LocalLauncher.current
val context = LocalContext.current
val mainVm = LocalMainViewModel.current val mainVm = LocalMainViewModel.current
val vm = hiltViewModel<HomeVm>() val vm = hiltViewModel<HomeVm>()
@ -171,6 +177,8 @@ fun useSubsManagePage(): ScaffoldExt {
}) })
} }
ShareDataDialog(vm)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
return ScaffoldExt( return ScaffoldExt(
navItem = subsNav, navItem = subsNav,
@ -205,11 +213,10 @@ fun useSubsManagePage(): ScaffoldExt {
} }
if (canDeleteIds.isNotEmpty()) { if (canDeleteIds.isNotEmpty()) {
IconButton(onClick = vm.viewModelScope.launchAsFn { IconButton(onClick = vm.viewModelScope.launchAsFn {
val result = mainVm.dialogFlow.getResult( mainVm.dialogFlow.waitResult(
title = "删除订阅", title = "删除订阅",
text = "是否删除所选 ${canDeleteIds.size} 个订阅?\n\n注: 不包含本地订阅", text = "是否删除所选 ${canDeleteIds.size} 个订阅?\n\n注: 不包含本地订阅",
) )
if (!result) return@launchAsFn
deleteSubscription(*canDeleteIds.toLongArray()) deleteSubscription(*canDeleteIds.toLongArray())
selectedIds = selectedIds - canDeleteIds selectedIds = selectedIds - canDeleteIds
if (selectedIds.size == canDeleteIds.size) { if (selectedIds.size == canDeleteIds.size) {
@ -223,7 +230,7 @@ fun useSubsManagePage(): ScaffoldExt {
} }
} }
IconButton(onClick = vm.viewModelScope.launchAsFn(Dispatchers.IO) { IconButton(onClick = vm.viewModelScope.launchAsFn(Dispatchers.IO) {
exportData(context, selectedIds) vm.showShareDataIdsFlow.value = selectedIds
}) { }) {
Icon( Icon(
imageVector = Icons.Default.Share, imageVector = Icons.Default.Share,
@ -425,4 +432,47 @@ fun useSubsManagePage(): ScaffoldExt {
) )
} }
} }
}
@Composable
private fun ShareDataDialog(vm: HomeVm) {
val context = LocalContext.current as MainActivity
val showShareDataIds = vm.showShareDataIdsFlow.collectAsState().value
if (showShareDataIds != null) {
Dialog(onDismissRequest = { vm.showShareDataIdsFlow.value = null }) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
) {
val modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
Text(
text = "分享数据", modifier = Modifier
.clickable(onClick = throttle {
vm.showShareDataIdsFlow.value = null
vm.viewModelScope.launchTry(Dispatchers.IO) {
val file = exportData(showShareDataIds)
context.shareFile(file, "分享数据文件")
}
})
.then(modifier)
)
Text(
text = "保存到下载",
modifier = Modifier
.clickable(onClick = throttle {
vm.showShareDataIdsFlow.value = null
vm.viewModelScope.launchTry(Dispatchers.IO) {
val file = exportData(showShareDataIds)
context.saveFileToDownloads(file)
}
})
.then(modifier)
)
}
}
}
} }

View File

@ -1,14 +1,23 @@
package li.songe.gkd.util package li.songe.gkd.util
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.LogUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import li.songe.gkd.MainActivity
import li.songe.gkd.permission.canWriteExternalStorage
import li.songe.gkd.permission.requiredPermission
import java.io.File import java.io.File
fun Context.shareFile(file: File, tile: String) { fun Context.shareFile(file: File, title: String) {
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
this, "${packageName}.provider", file this, "${packageName}.provider", file
) )
@ -21,11 +30,36 @@ fun Context.shareFile(file: File, tile: String) {
} }
tryStartActivity( tryStartActivity(
Intent.createChooser( Intent.createChooser(
intent, tile intent, title
) )
) )
} }
suspend fun MainActivity.saveFileToDownloads(file: File) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
requiredPermission(this, canWriteExternalStorage)
val targetFile = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
file.name
)
targetFile.writeBytes(file.readBytes())
} else {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, file.name)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
withContext(Dispatchers.IO) {
val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
?: error("创建URI失败")
contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(file.readBytes())
outputStream.flush()
}
}
}
toast("已保存 ${file.name} 到下载")
}
fun Context.tryStartActivity(intent: Intent) { fun Context.tryStartActivity(intent: Intent) {
try { try {
startActivity(intent) startActivity(intent)