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 {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
}
|
}
|
|
@ -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
|
||||||
}
|
}
|
|
@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
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",
|
||||||
|
|
Loading…
Reference in New Issue