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 { object OPMLParser {
@JvmStatic
suspend fun read(stream: InputStream): Map<Folder?, List<Feed>> { suspend fun read(stream: InputStream): Map<Folder?, List<Feed>> {
try { try {
val adapter = OPMLAdapter() val adapter = OPMLAdapter()
@ -23,7 +22,6 @@ object OPMLParser {
} }
} }
@JvmStatic
suspend fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream) { suspend fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream) {
val opml = xml("opml") { val opml = xml("opml") {
attribute("version", "2.0") attribute("version", "2.0")
@ -64,5 +62,6 @@ object OPMLParser {
outputStream.write(opml.toString().toByteArray()) outputStream.write(opml.toString().toByteArray())
outputStream.flush() outputStream.flush()
outputStream.close()
} }
} }

View File

@ -34,6 +34,8 @@ android {
} }
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
@ -87,4 +89,6 @@ dependencies {
androidTestImplementation(libs.bundles.kointest) androidTestImplementation(libs.bundles.kointest)
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0' 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.content.Context
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.net.toFile import androidx.core.net.toFile
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.api.opml.OPMLParser import com.readrops.api.opml.OPMLParser
import com.readrops.app.compose.base.TabScreenModel import com.readrops.app.compose.base.TabScreenModel
import com.readrops.app.compose.repositories.ErrorResult import com.readrops.app.compose.repositories.ErrorResult
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.db.Database import com.readrops.db.Database
import com.readrops.db.entities.Feed import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder import com.readrops.db.entities.Folder
import com.readrops.db.entities.account.Account import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType import com.readrops.db.entities.account.AccountType
import com.readrops.db.filters.MainFilter
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -46,7 +50,7 @@ class AccountScreenModel(
if (dialog is DialogState.ErrorList) { if (dialog is DialogState.ErrorList) {
_accountState.update { it.copy(synchronizationErrors = null) } _accountState.update { it.copy(synchronizationErrors = null) }
} else if (dialog is DialogState.Error) { } else if (dialog is DialogState.Error) {
_accountState.update { it.copy(opmlImportError = null) } _accountState.update { it.copy(error = null) }
} }
_accountState.update { it.copy(dialog = 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) { fun parseOPMLFile(uri: Uri, context: Context) {
screenModelScope.launch(Dispatchers.IO) { screenModelScope.launch(Dispatchers.IO) {
val foldersAndFeeds: Map<Folder?, List<Feed>> val foldersAndFeeds: Map<Folder?, List<Feed>>
@ -68,13 +94,13 @@ class AccountScreenModel(
try { try {
val stream = context.contentResolver.openInputStream(uri) val stream = context.contentResolver.openInputStream(uri)
if (stream == null) { if (stream == null) {
_accountState.update { it.copy(opmlImportError = NoSuchFileException(uri.toFile())) } _accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
return@launch return@launch
} }
foldersAndFeeds = OPMLParser.read(stream) foldersAndFeeds = OPMLParser.read(stream)
} catch (e: Exception) { } catch (e: Exception) {
_accountState.update { it.copy(opmlImportError = e) } _accountState.update { it.copy(error = e) }
return@launch return@launch
} }
@ -109,13 +135,19 @@ class AccountScreenModel(
} }
} }
} }
fun resetOPMLState() =
_accountState.update { it.copy(opmlExportUri = null, opmlExportSuccess = false) }
} }
@Stable
data class AccountState( data class AccountState(
val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL), val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL),
val dialog: DialogState? = null, val dialog: DialogState? = null,
val synchronizationErrors: ErrorResult? = null, val synchronizationErrors: ErrorResult? = null,
val opmlImportError: Exception? = null val error: Exception? = null,
val opmlExportSuccess: Boolean = false,
val opmlExportUri: Uri? = null,
) )
sealed interface DialogState { sealed interface DialogState {
@ -126,4 +158,6 @@ sealed interface DialogState {
data class ErrorList(val errorResult: ErrorResult) : DialogState data class ErrorList(val errorResult: ErrorResult) : DialogState
data class Error(val exception: Exception) : DialogState data class Error(val exception: Exception) : DialogState
object OPMLChoice : DialogState
} }

View File

@ -1,5 +1,6 @@
package com.readrops.app.compose.account package com.readrops.app.compose.account
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@ -69,10 +70,10 @@ object AccountTab : Tab {
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current val context = LocalContext.current
val viewModel = getScreenModel<AccountScreenModel>() val screenModel = getScreenModel<AccountScreenModel>()
val closeHome by viewModel.closeHome.collectAsStateWithLifecycle() val closeHome by screenModel.closeHome.collectAsStateWithLifecycle()
val state by viewModel.accountState.collectAsStateWithLifecycle() val state by screenModel.accountState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@ -80,13 +81,18 @@ object AccountTab : Tab {
navigator.replaceAll(AccountSelectionScreen()) navigator.replaceAll(AccountSelectionScreen())
} }
val launcher = val opmlImportLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let { viewModel.parseOPMLFile(uri, context) } uri?.let { screenModel.parseOPMLFile(uri, context) }
} }
LaunchedEffect(state.opmlImportError) { val opmlExportLauncher =
if (state.opmlImportError != null) { rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/xml")) { uri ->
uri?.let { screenModel.exportOPMLFile(uri, context) }
}
LaunchedEffect(state.error) {
if (state.error != null) {
val action = snackbarHostState.showSnackbar( val action = snackbarHostState.showSnackbar(
message = context.resources.getQuantityString( message = context.resources.getQuantityString(
R.plurals.error_occurred, R.plurals.error_occurred,
@ -97,9 +103,9 @@ object AccountTab : Tab {
) )
if (action == SnackbarResult.ActionPerformed) { if (action == SnackbarResult.ActionPerformed) {
viewModel.openDialog(DialogState.Error(state.opmlImportError!!)) screenModel.openDialog(DialogState.Error(state.error!!))
} else { } else {
viewModel.closeDialog(DialogState.Error(state.opmlImportError!!)) screenModel.closeDialog(DialogState.Error(state.error!!))
} }
} }
} }
@ -116,9 +122,31 @@ object AccountTab : Tab {
) )
if (action == SnackbarResult.ActionPerformed) { if (action == SnackbarResult.ActionPerformed) {
viewModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!)) screenModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
} else { } 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), icon = rememberVectorPainter(image = Icons.Default.Delete),
confirmText = stringResource(R.string.delete), confirmText = stringResource(R.string.delete),
dismissText = stringResource(R.string.cancel), dismissText = stringResource(R.string.cancel),
onDismiss = { viewModel.closeDialog() }, onDismiss = { screenModel.closeDialog() },
onConfirm = { onConfirm = {
viewModel.closeDialog() screenModel.closeDialog()
viewModel.deleteAccount() screenModel.deleteAccount()
} }
) )
} }
is DialogState.NewAccount -> { is DialogState.NewAccount -> {
AccountSelectionDialog( AccountSelectionDialog(
onDismiss = { viewModel.closeDialog() }, onDismiss = { screenModel.closeDialog() },
onValidate = { accountType -> onValidate = { accountType ->
viewModel.closeDialog() screenModel.closeDialog()
navigator.push(AccountCredentialsScreen(accountType, state.account)) navigator.push(AccountCredentialsScreen(accountType, state.account))
} }
) )
@ -160,17 +188,33 @@ object AccountTab : Tab {
is DialogState.ErrorList -> { is DialogState.ErrorList -> {
ErrorListDialog( ErrorListDialog(
errorResult = dialog.errorResult, errorResult = dialog.errorResult,
onDismiss = { viewModel.closeDialog(dialog) } onDismiss = { screenModel.closeDialog(dialog) }
) )
} }
is DialogState.Error -> { is DialogState.Error -> {
ErrorDialog( ErrorDialog(
exception = dialog.exception, 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 -> {} else -> {}
} }
@ -192,7 +236,7 @@ object AccountTab : Tab {
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { viewModel.openDialog(DialogState.NewAccount) } onClick = { screenModel.openDialog(DialogState.NewAccount) }
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_add_account), painter = painterResource(id = R.drawable.ic_add_account),
@ -251,7 +295,7 @@ object AccountTab : Tab {
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing, padding = MaterialTheme.spacing.mediumSpacing,
onClick = { launcher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) } onClick = { screenModel.openDialog(DialogState.OPMLChoice) }
) )
SelectableIconText( SelectableIconText(
@ -262,7 +306,7 @@ object AccountTab : Tab {
padding = MaterialTheme.spacing.mediumSpacing, padding = MaterialTheme.spacing.mediumSpacing,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
tint = 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" konsumexml = "com.gitlab.mvysny.konsume-xml:konsume-xml:1.1"
kotlinxmlbuilder = "org.redundent:kotlin-xml-builder:1.7.3" #TODO update this kotlinxmlbuilder = "org.redundent:kotlin-xml-builder:1.7.3" #TODO update this
jdk-desugar = "com.android.tools:desugar_jdk_libs:2.0.4"
[bundles] [bundles]
compose = ["bom", "compose-foundation", "compose-runtime", "compose-animation", compose = ["bom", "compose-foundation", "compose-runtime", "compose-animation",