Add OPML export in AccountTab

This commit is contained in:
Shinokuni 2024-04-06 22:51:56 +02:00
parent 8a5c22d144
commit 0ccb4aa9c8
6 changed files with 165 additions and 27 deletions

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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) }
)
}
}

View File

@ -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)
)
}
}
}

View File

@ -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",