diff --git a/api/src/main/java/com/readrops/api/opml/OPMLParser.kt b/api/src/main/java/com/readrops/api/opml/OPMLParser.kt index 3717c145..5ec352ce 100644 --- a/api/src/main/java/com/readrops/api/opml/OPMLParser.kt +++ b/api/src/main/java/com/readrops/api/opml/OPMLParser.kt @@ -10,7 +10,6 @@ import java.io.OutputStream object OPMLParser { - @JvmStatic suspend fun read(stream: InputStream): Map> { try { val adapter = OPMLAdapter() @@ -23,7 +22,6 @@ object OPMLParser { } } - @JvmStatic suspend fun write(foldersAndFeeds: Map>, 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() } } \ No newline at end of file diff --git a/appcompose/build.gradle b/appcompose/build.gradle index e0f978f2..2a0041a4 100644 --- a/appcompose/build.gradle +++ b/appcompose/build.gradle @@ -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) } \ No newline at end of file diff --git a/appcompose/src/main/java/com/readrops/app/compose/account/AccountScreenModel.kt b/appcompose/src/main/java/com/readrops/app/compose/account/AccountScreenModel.kt index e1311732..92626199 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/account/AccountScreenModel.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/account/AccountScreenModel.kt @@ -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> @@ -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 } \ No newline at end of file diff --git a/appcompose/src/main/java/com/readrops/app/compose/account/AccountTab.kt b/appcompose/src/main/java/com/readrops/app/compose/account/AccountTab.kt index e064786f..a60e7344 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/account/AccountTab.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/account/AccountTab.kt @@ -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() + val screenModel = getScreenModel() - 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) } ) } } diff --git a/appcompose/src/main/java/com/readrops/app/compose/account/OPMLChoiceDialog.kt b/appcompose/src/main/java/com/readrops/app/compose/account/OPMLChoiceDialog.kt new file mode 100644 index 00000000..36985f3a --- /dev/null +++ b/appcompose/src/main/java/com/readrops/app/compose/account/OPMLChoiceDialog.kt @@ -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) + ) + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d090c707..67d1ecb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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",