Add OPML import in AccountTab

This commit is contained in:
Shinokuni 2024-03-31 19:48:29 +02:00
parent 1a92684c18
commit e0874f2297
16 changed files with 262 additions and 104 deletions

View File

@ -78,4 +78,6 @@ dependencies {
debugApi 'com.chimerapps.niddler:niddler:1.5.5'
releaseApi 'com.chimerapps.niddler:niddler-noop:1.5.5'
testImplementation(libs.coroutines.test)
}

View File

@ -1,10 +1,9 @@
package com.readrops.api.opml
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import io.reactivex.Completable
import io.reactivex.Single
import org.redundent.kotlin.xml.xml
import java.io.InputStream
import java.io.OutputStream
@ -12,48 +11,37 @@ import java.io.OutputStream
object OPMLParser {
@JvmStatic
fun read(stream: InputStream): Single<Map<Folder?, List<Feed>>> {
return Single.create { emitter ->
try {
val adapter = OPMLAdapter()
val opml = adapter.fromXml(stream.konsumeXml())
suspend fun read(stream: InputStream): Map<Folder?, List<Feed>> {
try {
val adapter = OPMLAdapter()
val opml = adapter.fromXml(stream.konsumeXml())
emitter.onSuccess(opml)
} catch (e: Exception) {
emitter.onError(e)
}
stream.close()
return opml
} catch (e: Exception) {
throw ParseException(e.message)
}
}
@JvmStatic
fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream): Completable {
return Completable.create { emitter ->
val opml = xml("opml") {
attribute("version", "2.0")
suspend fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream) {
val opml = xml("opml") {
attribute("version", "2.0")
"head" {
-"Subscriptions"
}
"head" {
-"Subscriptions"
}
"body" {
for (folderAndFeeds in foldersAndFeeds) {
if (folderAndFeeds.key != null) { // feeds with folder
"outline" {
folderAndFeeds.key?.name?.let {
attribute("title", it)
attribute("text", it)
}
for (feed in folderAndFeeds.value) {
"outline" {
feed.name?.let { attribute("title", it) }
attribute("xmlUrl", feed.url!!)
feed.siteUrl?.let { attribute("htmlUrl", it) }
}
}
"body" {
for (folderAndFeeds in foldersAndFeeds) {
if (folderAndFeeds.key != null) { // feeds with folder
"outline" {
folderAndFeeds.key?.name?.let {
attribute("title", it)
attribute("text", it)
}
} else {
for (feed in folderAndFeeds.value) { // feeds without folder
for (feed in folderAndFeeds.value) {
"outline" {
feed.name?.let { attribute("title", it) }
attribute("xmlUrl", feed.url!!)
@ -61,14 +49,20 @@ object OPMLParser {
}
}
}
} else {
for (feed in folderAndFeeds.value) { // feeds without folder
"outline" {
feed.name?.let { attribute("title", it) }
attribute("xmlUrl", feed.url!!)
feed.siteUrl?.let { attribute("htmlUrl", it) }
}
}
}
}
}
outputStream.write(opml.toString().toByteArray())
outputStream.flush()
emitter.onComplete()
}
outputStream.write(opml.toString().toByteArray())
outputStream.flush()
}
}

View File

@ -16,6 +16,8 @@ object ApiUtils {
const val HTTP_NOT_FOUND = 404
const val HTTP_CONFLICT = 409
val OPML_MIMETYPES = listOf("application/xml", "text/xml", "text/x-opml")
private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)"
fun isMimeImage(type: String): Boolean =

View File

@ -4,8 +4,8 @@ import com.readrops.api.TestUtils
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import io.reactivex.schedulers.Schedulers
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.File
import java.io.FileOutputStream
@ -13,83 +13,85 @@ import java.io.FileOutputStream
class OPMLParserTest {
@Test
fun readOpmlTest() {
fun readOpmlTest() = runTest {
val stream = TestUtils.loadResource("opml/subscriptions.opml")
val foldersAndFeeds = OPMLParser.read(stream)
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
assertEquals(foldersAndFeeds.size, 6)
OPMLParser.read(stream)
.observeOn(Schedulers.trampoline())
.subscribeOn(Schedulers.trampoline())
.subscribe { result -> foldersAndFeeds = result }
assertEquals(foldersAndFeeds?.size, 6)
assertEquals(foldersAndFeeds?.get(Folder(name = "Folder 1"))?.size, 2)
assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 1"))?.size, 4)
assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 2"))?.size, 1)
assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 1"))?.size, 2)
assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 2"))?.size, 0)
assertEquals(foldersAndFeeds?.get(null)?.size, 2)
assertEquals(foldersAndFeeds[Folder(name = "Folder 1")]?.size, 2)
assertEquals(foldersAndFeeds[Folder(name = "Subfolder 1")]?.size, 4)
assertEquals(foldersAndFeeds[Folder(name = "Subfolder 2")]?.size, 1)
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 1")]?.size, 2)
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 2")]?.size, 0)
assertEquals(foldersAndFeeds[null]?.size, 2)
stream.close()
}
@Test
fun readLiteSubscriptionsTest() {
fun readLiteSubscriptionsTest() = runTest {
val stream = TestUtils.loadResource("opml/lite_subscriptions.opml")
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
val foldersAndFeeds = OPMLParser.read(stream)
OPMLParser.read(stream)
.subscribe { result -> foldersAndFeeds = result }
assertEquals(foldersAndFeeds?.values?.first()?.size, 2)
assertEquals(foldersAndFeeds?.values?.first()?.first()?.url, "http://www.theverge.com/rss/index.xml")
assertEquals(foldersAndFeeds?.values?.first()?.get(1)?.url, "https://techcrunch.com/feed/")
assertEquals(foldersAndFeeds.values.first().size, 2)
assertEquals(
foldersAndFeeds.values.first().first().url,
"http://www.theverge.com/rss/index.xml"
)
assertEquals(foldersAndFeeds.values.first()[1].url, "https://techcrunch.com/feed/")
stream.close()
}
@Test
fun opmlVersionTest() {
@Test(expected = ParseException::class)
fun opmlVersionTest() = runTest {
val stream = TestUtils.loadResource("opml/wrong_version.opml")
OPMLParser.read(stream)
.test()
.assertError(ParseException::class.java)
stream.close()
}
@Test
fun writeOpmlTest() {
fun writeOpmlTest() = runTest {
val file = File("subscriptions.opml")
val outputStream = FileOutputStream(file)
val foldersAndFeeds: Map<Folder?, List<Feed>> = HashMap<Folder?, List<Feed>>().apply {
put(null, listOf(Feed(name = "Feed1", url = "https://feed1.com"),
Feed(name = "Feed2", url = "https://feed2.com")))
put(
null, listOf(
Feed(name = "Feed1", url = "https://feed1.com"),
Feed(name = "Feed2", url = "https://feed2.com")
)
)
put(Folder(name = "Folder1"), listOf())
put(Folder(name = "Folder2"), listOf(Feed(name = "Feed3", url = "https://feed3.com"),
Feed(name = "Feed4", url ="https://feed4.com")))
put(
Folder(name = "Folder2"), listOf(
Feed(name = "Feed3", url = "https://feed3.com"),
Feed(name = "Feed4", url = "https://feed4.com")
)
)
}
OPMLParser.write(foldersAndFeeds, outputStream)
.subscribeOn(Schedulers.trampoline())
.subscribe()
outputStream.flush()
outputStream.close()
val inputStream = file.inputStream()
var foldersAndFeeds2: Map<Folder?, List<Feed>>? = null
OPMLParser.read(inputStream).subscribe { result -> foldersAndFeeds2 = result }
val foldersAndFeeds2 = OPMLParser.read(inputStream)
assertEquals(foldersAndFeeds.size, foldersAndFeeds2?.size)
assertEquals(foldersAndFeeds[Folder(name = "Folder1")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder1"))?.size)
assertEquals(foldersAndFeeds[Folder(name = "Folder2")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder2"))?.size)
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2?.get(null)?.size)
assertEquals(foldersAndFeeds.size, foldersAndFeeds2.size)
assertEquals(
foldersAndFeeds[Folder(name = "Folder1")]?.size,
foldersAndFeeds2[Folder(name = "Folder1")]?.size
)
assertEquals(
foldersAndFeeds[Folder(name = "Folder2")]?.size,
foldersAndFeeds2[Folder(name = "Folder2")]?.size
)
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2[null]?.size)
inputStream.close()
}

View File

@ -64,7 +64,8 @@ public class AccountViewModel extends ViewModel {
}
public Completable parseOPMLFile(Uri uri, Context context) throws FileNotFoundException {
return OPMLParser.read(context.getContentResolver().openInputStream(uri))
.flatMapCompletable(foldersAndFeeds -> repository.insertOPMLFoldersAndFeeds(foldersAndFeeds));
/*return OPMLParser.read(context.getContentResolver().openInputStream(uri))
.flatMapCompletable(foldersAndFeeds -> repository.insertOPMLFoldersAndFeeds(foldersAndFeeds));*/
return Completable.complete();
}
}

View File

@ -257,8 +257,8 @@ public class AccountSettingsFragment extends PreferenceFragmentCompat {
.blockingGet();
OPMLParser.write(folderListMap, outputStream)
.blockingAwait();
/*OPMLParser.write(folderListMap, outputStream)
.blockingAwait();*/
return Unit.INSTANCE;
});

View File

@ -1,6 +1,9 @@
package com.readrops.app.compose.account
import android.content.Context
import android.net.Uri
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.api.opml.OPMLParser
import com.readrops.app.compose.base.TabScreenModel
import com.readrops.db.Database
import com.readrops.db.entities.account.Account
@ -45,6 +48,39 @@ class AccountScreenModel(
_closeHome.update { true }
}
}
fun parseOPMLFile(uri: Uri, context: Context) {
screenModelScope.launch(Dispatchers.IO) {
val stream = context.contentResolver.openInputStream(uri)!!
val foldersAndFeeds = OPMLParser.read(stream)
openDialog(
DialogState.OPMLImport(
currentFeed = foldersAndFeeds.values.first().first().name!!,
feedCount = 0,
feedMax = foldersAndFeeds.values.flatten().size
)
)
repository?.insertOPMLFoldersAndFeeds(
foldersAndFeeds = foldersAndFeeds,
onUpdate = { feed ->
_accountState.update {
val dialog = (it.dialog as DialogState.OPMLImport)
it.copy(
dialog = dialog.copy(
currentFeed = feed.name!!,
feedCount = dialog.feedCount + 1
)
)
}
}
)
closeDialog()
}
}
}
data class AccountState(
@ -55,4 +91,6 @@ data class AccountState(
sealed interface DialogState {
object DeleteAccount : DialogState
object NewAccount : DialogState
data class OPMLImport(val currentFeed: String, val feedCount: Int, val feedMax: Int) :
DialogState
}

View File

@ -1,5 +1,7 @@
package com.readrops.app.compose.account
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -24,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -33,6 +36,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.readrops.api.utils.ApiUtils
import com.readrops.app.compose.R
import com.readrops.app.compose.account.credentials.AccountCredentialsScreen
import com.readrops.app.compose.account.selection.AccountSelectionDialog
@ -56,6 +60,7 @@ object AccountTab : Tab {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val viewModel = getScreenModel<AccountScreenModel>()
val closeHome by viewModel.closeHome.collectAsStateWithLifecycle()
@ -65,8 +70,13 @@ object AccountTab : Tab {
navigator.replaceAll(AccountSelectionScreen())
}
when (state.dialog) {
DialogState.DeleteAccount -> {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let { viewModel.parseOPMLFile(uri, context) }
}
when (val dialog = state.dialog) {
is DialogState.DeleteAccount -> {
TwoChoicesDialog(
title = stringResource(R.string.delete_account),
text = stringResource(R.string.delete_account_question),
@ -81,7 +91,7 @@ object AccountTab : Tab {
)
}
DialogState.NewAccount -> {
is DialogState.NewAccount -> {
AccountSelectionDialog(
onDismiss = { viewModel.closeDialog() },
onValidate = { accountType ->
@ -91,6 +101,14 @@ object AccountTab : Tab {
)
}
is DialogState.OPMLImport -> {
OPMLImportProgressDialog(
currentFeed = dialog.currentFeed,
feedCount = dialog.feedCount,
feedMax = dialog.feedMax
)
}
else -> {}
}
@ -164,6 +182,15 @@ object AccountTab : Tab {
onClick = { }
)
SelectableIconText(
icon = painterResource(id = R.drawable.ic_import_export),
text = stringResource(R.string.opml_import_export),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
onClick = { launcher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) }
)
SelectableIconText(
icon = rememberVectorPainter(image = Icons.Default.AccountCircle),
text = stringResource(R.string.delete_account),

View File

@ -0,0 +1,27 @@
package com.readrops.app.compose.account
import androidx.compose.runtime.Composable
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.components.RefreshIndicator
@Composable
fun OPMLImportProgressDialog(
currentFeed: String,
feedCount: Int,
feedMax: Int,
) {
BaseDialog(
title = stringResource(id = R.string.opml_import),
icon = painterResource(R.drawable.ic_import_export),
onDismiss = {}
) {
RefreshIndicator(
currentFeed = currentFeed,
feedCount = feedCount,
feedMax = feedMax
)
}
}

View File

@ -188,7 +188,9 @@ class FeedScreenModel(
try {
if (localRSSDataSource.isUrlRSSResource(url)) {
// TODO add support for all account types
repository?.insertNewFeeds(listOf(url))
repository?.insertNewFeeds(
newFeeds = listOf(Feed(url = url))
) {}
closeDialog(DialogState.AddFeed)
} else {
@ -200,7 +202,9 @@ class FeedScreenModel(
}
} else {
// TODO add support for all account types
repository?.insertNewFeeds(rssUrls.map { it.url })
repository?.insertNewFeeds(
newFeeds = rssUrls.map { Feed(url = it.url) }
) {}
closeDialog(DialogState.AddFeed)
}

View File

@ -37,7 +37,7 @@ abstract class ARepository(
*/
abstract suspend fun synchronize(): SyncResult
abstract suspend fun insertNewFeeds(urls: List<String>)
abstract suspend fun insertNewFeeds(newFeeds: List<Feed>, onUpdate: (Feed) -> Unit)
}
abstract class BaseRepository(
@ -45,7 +45,8 @@ abstract class BaseRepository(
account: Account,
) : ARepository(database, account) {
open suspend fun updateFeed(feed: Feed) = database.newFeedDao().updateFeedFields(feed.id, feed.name!!, feed.url!!, feed.folderId)
open suspend fun updateFeed(feed: Feed) =
database.newFeedDao().updateFeedFields(feed.id, feed.name!!, feed.url!!, feed.folderId)
open suspend fun deleteFeed(feed: Feed) = database.newFeedDao().delete(feed)
@ -82,4 +83,30 @@ abstract class BaseRepository(
open suspend fun setAllItemsReadByFolder(folderId: Int, accountId: Int) {
database.newItemDao().setAllItemsReadByFolder(folderId, accountId)
}
suspend fun insertOPMLFoldersAndFeeds(
foldersAndFeeds: Map<Folder?, List<Feed>>,
onUpdate: (Feed) -> Unit
) {
for ((folder, feeds) in foldersAndFeeds) {
if (folder != null) {
folder.accountId = account.id
val dbFolder = database.newFolderDao().selectFolderByName(folder.name!!, account.id)
if (dbFolder != null) {
folder.id = dbFolder.id
} else {
folder.id = database.newFolderDao().insert(folder).toInt()
}
}
feeds.forEach { it.folderId = folder?.id }
insertNewFeeds(
newFeeds = feeds,
onUpdate = onUpdate
)
}
}
}

View File

@ -1,5 +1,6 @@
package com.readrops.app.compose.repositories
import android.util.Log
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.services.SyncResult
import com.readrops.api.utils.ApiUtils
@ -68,16 +69,19 @@ class LocalRSSRepository(
throw NotImplementedError("This method can't be called here")
override suspend fun insertNewFeeds(urls: List<String>) = withContext(Dispatchers.IO) {
for (url in urls) {
try {
val result = dataSource.queryRSSResource(url, null)!!
insertFeed(result.first)
} catch (e: Exception) {
throw e
override suspend fun insertNewFeeds(newFeeds: List<Feed>, onUpdate: (Feed) -> Unit) = withContext(Dispatchers.IO) {
for (newFeed in newFeeds) {
onUpdate(newFeed)
try {
val result = dataSource.queryRSSResource(newFeed.url!!, null)!!
insertFeed(result.first.also { it.folderId = newFeed.folderId })
} catch (e: Exception) {
Log.d("LocalRSSRepository", e.message.orEmpty())
//throw e
}
}
}
}
private suspend fun insertNewItems(items: List<Item>, feed: Feed) {
items.sortedWith(Item::compareTo) // TODO Check if ordering is useful in this situation

View File

@ -1,8 +1,11 @@
package com.readrops.app.compose.util.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import com.readrops.app.compose.util.theme.VeryShortSpacer
@Composable
@ -12,6 +15,24 @@ fun RefreshScreen(
feedMax: Int
) {
CenteredColumn {
RefreshIndicator(
currentFeed = currentFeed,
feedCount = feedCount,
feedMax = feedMax
)
}
}
@Composable
fun RefreshIndicator(
currentFeed: String,
feedCount: Int,
feedMax: Int
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LinearProgressIndicator(
progress = { feedCount.toFloat() / feedMax.toFloat() }
)
@ -19,7 +40,8 @@ fun RefreshScreen(
VeryShortSpacer()
Text(
text = "$currentFeed ($feedCount/$feedMax)"
text = "$currentFeed ($feedCount/$feedMax)",
maxLines = 1
)
}
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#727272"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M9,3L5,6.99h3L8,14h2L10,6.99h3L9,3zM16,17.01L16,10h-2v7.01h-3L15,21l4,-3.99h-3z"/>
</vector>

View File

@ -93,7 +93,7 @@
<string name="external_navigator">Navigateur externe</string>
<string name="actualize">Actualiser</string>
<string name="share_url">Partager le lien</string>
<string name="opml_import_export">Importation/Exportation OPML</string>
<string name="opml_import_export">Import/Export OPML</string>
<string name="opml_processing">Traitement du fichier OPML</string>
<string name="operation_takes_time">Cette opération peut prendre un certain temps car il faut interroger chaque flux.</string>
<string name="processing_file_failed">Une erreur s\'est produite lors du traitement du fichier</string>

View File

@ -18,4 +18,7 @@ interface NewFolderDao : NewBaseDao<Folder> {
@Query("Select * From Folder Where account_id = :accountId")
fun selectFolders(accountId: Int): Flow<List<Folder>>
@Query("Select * From Folder Where name = :name And account_id = :accountId")
suspend fun selectFolderByName(name: String, accountId: Int): Folder?
}