mirror of https://github.com/readrops/Readrops.git
Add OPML export in AccountTab
This commit is contained in:
parent
8a5c22d144
commit
0ccb4aa9c8
|
@ -10,7 +10,6 @@ import java.io.OutputStream
|
|||
|
||||
object OPMLParser {
|
||||
|
||||
@JvmStatic
|
||||
suspend fun read(stream: InputStream): Map<Folder?, List<Feed>> {
|
||||
try {
|
||||
val adapter = OPMLAdapter()
|
||||
|
@ -23,7 +22,6 @@ object OPMLParser {
|
|||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
suspend fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream) {
|
||||
val opml = xml("opml") {
|
||||
attribute("version", "2.0")
|
||||
|
@ -64,5 +62,6 @@ object OPMLParser {
|
|||
|
||||
outputStream.write(opml.toString().toByteArray())
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
|
@ -34,6 +34,8 @@ android {
|
|||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
@ -87,4 +89,6 @@ dependencies {
|
|||
androidTestImplementation(libs.bundles.kointest)
|
||||
|
||||
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
|
||||
|
||||
coreLibraryDesugaring(libs.jdk.desugar)
|
||||
}
|
|
@ -2,19 +2,23 @@ package com.readrops.app.compose.account
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.net.toFile
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.readrops.api.opml.OPMLParser
|
||||
import com.readrops.app.compose.base.TabScreenModel
|
||||
import com.readrops.app.compose.repositories.ErrorResult
|
||||
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
import com.readrops.db.filters.MainFilter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
@ -46,7 +50,7 @@ class AccountScreenModel(
|
|||
if (dialog is DialogState.ErrorList) {
|
||||
_accountState.update { it.copy(synchronizationErrors = null) }
|
||||
} else if (dialog is DialogState.Error) {
|
||||
_accountState.update { it.copy(opmlImportError = null) }
|
||||
_accountState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
_accountState.update { it.copy(dialog = null) }
|
||||
|
@ -61,6 +65,28 @@ class AccountScreenModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun exportOPMLFile(uri: Uri, context: Context) {
|
||||
screenModelScope.launch {
|
||||
val stream = context.contentResolver.openOutputStream(uri)
|
||||
if (stream == null) {
|
||||
_accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val foldersAndFeeds =
|
||||
GetFoldersWithFeeds(database).get(currentAccount!!.id, MainFilter.ALL).first()
|
||||
|
||||
OPMLParser.write(foldersAndFeeds, stream)
|
||||
|
||||
_accountState.update {
|
||||
it.copy(
|
||||
opmlExportSuccess = true,
|
||||
opmlExportUri = uri
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseOPMLFile(uri: Uri, context: Context) {
|
||||
screenModelScope.launch(Dispatchers.IO) {
|
||||
val foldersAndFeeds: Map<Folder?, List<Feed>>
|
||||
|
@ -68,13 +94,13 @@ class AccountScreenModel(
|
|||
try {
|
||||
val stream = context.contentResolver.openInputStream(uri)
|
||||
if (stream == null) {
|
||||
_accountState.update { it.copy(opmlImportError = NoSuchFileException(uri.toFile())) }
|
||||
_accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
foldersAndFeeds = OPMLParser.read(stream)
|
||||
} catch (e: Exception) {
|
||||
_accountState.update { it.copy(opmlImportError = e) }
|
||||
_accountState.update { it.copy(error = e) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
@ -109,13 +135,19 @@ class AccountScreenModel(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetOPMLState() =
|
||||
_accountState.update { it.copy(opmlExportUri = null, opmlExportSuccess = false) }
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class AccountState(
|
||||
val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL),
|
||||
val dialog: DialogState? = null,
|
||||
val synchronizationErrors: ErrorResult? = null,
|
||||
val opmlImportError: Exception? = null
|
||||
val error: Exception? = null,
|
||||
val opmlExportSuccess: Boolean = false,
|
||||
val opmlExportUri: Uri? = null,
|
||||
)
|
||||
|
||||
sealed interface DialogState {
|
||||
|
@ -126,4 +158,6 @@ sealed interface DialogState {
|
|||
|
||||
data class ErrorList(val errorResult: ErrorResult) : DialogState
|
||||
data class Error(val exception: Exception) : DialogState
|
||||
|
||||
object OPMLChoice : DialogState
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package com.readrops.app.compose.account
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
|
@ -69,10 +70,10 @@ object AccountTab : Tab {
|
|||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val context = LocalContext.current
|
||||
val viewModel = getScreenModel<AccountScreenModel>()
|
||||
val screenModel = getScreenModel<AccountScreenModel>()
|
||||
|
||||
val closeHome by viewModel.closeHome.collectAsStateWithLifecycle()
|
||||
val state by viewModel.accountState.collectAsStateWithLifecycle()
|
||||
val closeHome by screenModel.closeHome.collectAsStateWithLifecycle()
|
||||
val state by screenModel.accountState.collectAsStateWithLifecycle()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
|
@ -80,13 +81,18 @@ object AccountTab : Tab {
|
|||
navigator.replaceAll(AccountSelectionScreen())
|
||||
}
|
||||
|
||||
val launcher =
|
||||
val opmlImportLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri?.let { viewModel.parseOPMLFile(uri, context) }
|
||||
uri?.let { screenModel.parseOPMLFile(uri, context) }
|
||||
}
|
||||
|
||||
LaunchedEffect(state.opmlImportError) {
|
||||
if (state.opmlImportError != null) {
|
||||
val opmlExportLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/xml")) { uri ->
|
||||
uri?.let { screenModel.exportOPMLFile(uri, context) }
|
||||
}
|
||||
|
||||
LaunchedEffect(state.error) {
|
||||
if (state.error != null) {
|
||||
val action = snackbarHostState.showSnackbar(
|
||||
message = context.resources.getQuantityString(
|
||||
R.plurals.error_occurred,
|
||||
|
@ -97,9 +103,9 @@ object AccountTab : Tab {
|
|||
)
|
||||
|
||||
if (action == SnackbarResult.ActionPerformed) {
|
||||
viewModel.openDialog(DialogState.Error(state.opmlImportError!!))
|
||||
screenModel.openDialog(DialogState.Error(state.error!!))
|
||||
} else {
|
||||
viewModel.closeDialog(DialogState.Error(state.opmlImportError!!))
|
||||
screenModel.closeDialog(DialogState.Error(state.error!!))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -116,9 +122,31 @@ object AccountTab : Tab {
|
|||
)
|
||||
|
||||
if (action == SnackbarResult.ActionPerformed) {
|
||||
viewModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
|
||||
screenModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
|
||||
} else {
|
||||
viewModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
|
||||
screenModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.opmlExportSuccess) {
|
||||
if (state.opmlExportSuccess) {
|
||||
val action = snackbarHostState.showSnackbar(
|
||||
message = "OPML export success",
|
||||
actionLabel = "Open file"
|
||||
)
|
||||
|
||||
if (action == SnackbarResult.ActionPerformed) {
|
||||
Intent().apply {
|
||||
this.action = Intent.ACTION_VIEW
|
||||
setDataAndType(state.opmlExportUri, "text/xml")
|
||||
}.also {
|
||||
context.startActivity(Intent.createChooser(it, null))
|
||||
}
|
||||
|
||||
screenModel.resetOPMLState()
|
||||
} else {
|
||||
screenModel.resetOPMLState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -131,19 +159,19 @@ object AccountTab : Tab {
|
|||
icon = rememberVectorPainter(image = Icons.Default.Delete),
|
||||
confirmText = stringResource(R.string.delete),
|
||||
dismissText = stringResource(R.string.cancel),
|
||||
onDismiss = { viewModel.closeDialog() },
|
||||
onDismiss = { screenModel.closeDialog() },
|
||||
onConfirm = {
|
||||
viewModel.closeDialog()
|
||||
viewModel.deleteAccount()
|
||||
screenModel.closeDialog()
|
||||
screenModel.deleteAccount()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.NewAccount -> {
|
||||
AccountSelectionDialog(
|
||||
onDismiss = { viewModel.closeDialog() },
|
||||
onDismiss = { screenModel.closeDialog() },
|
||||
onValidate = { accountType ->
|
||||
viewModel.closeDialog()
|
||||
screenModel.closeDialog()
|
||||
navigator.push(AccountCredentialsScreen(accountType, state.account))
|
||||
}
|
||||
)
|
||||
|
@ -160,17 +188,33 @@ object AccountTab : Tab {
|
|||
is DialogState.ErrorList -> {
|
||||
ErrorListDialog(
|
||||
errorResult = dialog.errorResult,
|
||||
onDismiss = { viewModel.closeDialog(dialog) }
|
||||
onDismiss = { screenModel.closeDialog(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.Error -> {
|
||||
ErrorDialog(
|
||||
exception = dialog.exception,
|
||||
onDismiss = { viewModel.closeDialog(dialog) }
|
||||
onDismiss = { screenModel.closeDialog(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.OPMLChoice -> {
|
||||
OPMLChoiceDialog(
|
||||
onChoice = {
|
||||
if (it == OPML.IMPORT) {
|
||||
opmlImportLauncher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray())
|
||||
} else {
|
||||
opmlExportLauncher.launch("subscriptions.opml")
|
||||
}
|
||||
|
||||
screenModel.closeDialog()
|
||||
},
|
||||
onDismiss = { screenModel.closeDialog() }
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
|
@ -192,7 +236,7 @@ object AccountTab : Tab {
|
|||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { viewModel.openDialog(DialogState.NewAccount) }
|
||||
onClick = { screenModel.openDialog(DialogState.NewAccount) }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_add_account),
|
||||
|
@ -251,7 +295,7 @@ object AccountTab : Tab {
|
|||
style = MaterialTheme.typography.titleMedium,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
onClick = { launcher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) }
|
||||
onClick = { screenModel.openDialog(DialogState.OPMLChoice) }
|
||||
)
|
||||
|
||||
SelectableIconText(
|
||||
|
@ -262,7 +306,7 @@ object AccountTab : Tab {
|
|||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = { viewModel.openDialog(DialogState.DeleteAccount) }
|
||||
onClick = { screenModel.openDialog(DialogState.DeleteAccount) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
package com.readrops.app.compose.account
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.readrops.app.compose.R
|
||||
import com.readrops.app.compose.util.components.BaseDialog
|
||||
import com.readrops.app.compose.util.theme.spacing
|
||||
|
||||
enum class OPML {
|
||||
IMPORT,
|
||||
EXPORT
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OPMLChoiceDialog(
|
||||
onChoice: (OPML) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
BaseDialog(
|
||||
title = stringResource(id = R.string.opml_import_export),
|
||||
icon = painterResource(id = R.drawable.ic_import_export),
|
||||
onDismiss = onDismiss
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onChoice(OPML.IMPORT) }
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.opml_import),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onChoice(OPML.EXPORT) }
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.opml_export),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -63,6 +63,7 @@ okhttp-mockserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref
|
|||
konsumexml = "com.gitlab.mvysny.konsume-xml:konsume-xml:1.1"
|
||||
kotlinxmlbuilder = "org.redundent:kotlin-xml-builder:1.7.3" #TODO update this
|
||||
|
||||
jdk-desugar = "com.android.tools:desugar_jdk_libs:2.0.4"
|
||||
|
||||
[bundles]
|
||||
compose = ["bom", "compose-foundation", "compose-runtime", "compose-animation",
|
||||
|
|
Loading…
Reference in New Issue