From 2d5c9dbfc273e85438a80b52500bbbfb45b459b4 Mon Sep 17 00:00:00 2001 From: Ash Date: Wed, 30 Mar 2022 14:38:47 +0800 Subject: [PATCH] Add export OPML file feature and fix ksoap2 XmlPullParser confusion --- app/build.gradle | 1 + app/proguard-rules.pro | 7 +- .../main/java/me/ash/reader/MainActivity.kt | 3 + .../java/me/ash/reader/data/group/GroupDao.kt | 11 +- .../data/repository/AbstractRssRepository.kt | 2 +- .../reader/data/repository/OpmlRepository.kt | 54 +++++++++ .../reader/data/source/OpmlLocalDataSource.kt | 107 ++++++++++-------- .../reader/ui/page/home/feeds/FeedsPage.kt | 48 ++++++-- .../ui/page/home/feeds/FeedsViewModel.kt | 19 +++- 9 files changed, 191 insertions(+), 61 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 24da39e..5503ec2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -53,6 +53,7 @@ android { } dependencies { + implementation 'be.ceau:opml-parser:2.2.0' implementation "androidx.profileinstaller:profileinstaller:1.2.0-alpha02" implementation("io.coil-kt:coil-compose:2.0.0-rc02") implementation("androidx.compose.animation:animation-graphics:$compose_version") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 175709e..5b929a3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,4 +24,9 @@ # Disable ServiceLoader reproducibility-breaking optimizations -keep class kotlinx.coroutines.CoroutineExceptionHandler --keep class kotlinx.coroutines.internal.MainDispatcherFactory \ No newline at end of file +-keep class kotlinx.coroutines.internal.MainDispatcherFactory + +# ksoap2 XmlPullParser confusion +-dontwarn org.xmlpull.v1.XmlPullParser +-dontwarn org.xmlpull.v1.XmlSerializer +-keep class org.xmlpull.v1.* {*;} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/MainActivity.kt b/app/src/main/java/me/ash/reader/MainActivity.kt index b59f4d0..9873d80 100644 --- a/app/src/main/java/me/ash/reader/MainActivity.kt +++ b/app/src/main/java/me/ash/reader/MainActivity.kt @@ -1,9 +1,11 @@ package me.ash.reader import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.view.WindowCompat +import androidx.profileinstaller.ProfileInstallerInitializer import dagger.hilt.android.AndroidEntryPoint import me.ash.reader.ui.page.common.HomeEntry @@ -13,6 +15,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) + Log.i("RLog", "onCreate: ${ProfileInstallerInitializer().create(this)}") setContent { HomeEntry(intent.extras).also { intent.replaceExtras(null) diff --git a/app/src/main/java/me/ash/reader/data/group/GroupDao.kt b/app/src/main/java/me/ash/reader/data/group/GroupDao.kt index 9d2e5cc..66147bd 100644 --- a/app/src/main/java/me/ash/reader/data/group/GroupDao.kt +++ b/app/src/main/java/me/ash/reader/data/group/GroupDao.kt @@ -20,7 +20,16 @@ interface GroupDao { WHERE accountId = :accountId """ ) - fun queryAllGroupWithFeed(accountId: Int): Flow> + fun queryAllGroupWithFeedAsFlow(accountId: Int): Flow> + + @Transaction + @Query( + """ + SELECT * FROM `group` + WHERE accountId = :accountId + """ + ) + fun queryAllGroupWithFeed(accountId: Int): List @Query( """ diff --git a/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt index 3c95cb5..7d4d027 100644 --- a/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt @@ -63,7 +63,7 @@ abstract class AbstractRssRepository constructor( } fun pullFeeds(): Flow> { - return groupDao.queryAllGroupWithFeed(context.currentAccountId).flowOn(Dispatchers.IO) + return groupDao.queryAllGroupWithFeedAsFlow(context.currentAccountId).flowOn(Dispatchers.IO) } fun pullArticles( diff --git a/app/src/main/java/me/ash/reader/data/repository/OpmlRepository.kt b/app/src/main/java/me/ash/reader/data/repository/OpmlRepository.kt index e69f9fb..a115539 100644 --- a/app/src/main/java/me/ash/reader/data/repository/OpmlRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/OpmlRepository.kt @@ -1,16 +1,29 @@ package me.ash.reader.data.repository +import android.content.Context import android.util.Log +import be.ceau.opml.OpmlWriter +import be.ceau.opml.entity.Body +import be.ceau.opml.entity.Head +import be.ceau.opml.entity.Opml +import be.ceau.opml.entity.Outline +import dagger.hilt.android.qualifiers.ApplicationContext +import me.ash.reader.currentAccountId +import me.ash.reader.data.account.AccountDao import me.ash.reader.data.feed.Feed import me.ash.reader.data.feed.FeedDao import me.ash.reader.data.group.GroupDao import me.ash.reader.data.source.OpmlLocalDataSource import java.io.InputStream +import java.util.* import javax.inject.Inject class OpmlRepository @Inject constructor( + @ApplicationContext + private val context: Context, private val groupDao: GroupDao, private val feedDao: FeedDao, + private val accountDao: AccountDao, private val rssRepository: RssRepository, private val opmlLocalDataSource: OpmlLocalDataSource ) { @@ -36,4 +49,45 @@ class OpmlRepository @Inject constructor( Log.e("saveToDatabase", "${e.message}") } } + + suspend fun saveToString(): String? = + try { + val defaultGroup = groupDao.queryById(opmlLocalDataSource.getDefaultGroupId())!! + OpmlWriter().write( + Opml( + "2.0", + Head( + accountDao.queryById(context.currentAccountId).name, + Date().toString(), null, null, null, + null, null, null, null, + null, null, null, null, + ), + Body(groupDao.queryAllGroupWithFeed(context.currentAccountId).map { + Outline( + mapOf( + "text" to it.group.name, + "title" to it.group.name, + "isDefault" to (it.group.id == defaultGroup.id).toString() + ), + it.feeds.map { feed -> + Outline( + mapOf( + "text" to feed.name, + "title" to feed.name, + "xmlUrl" to feed.url, + "htmlUrl" to feed.url, + "isNotification" to feed.isNotification.toString(), + "isFullContent" to feed.isFullContent.toString(), + ), + listOf() + ) + } + ) + }) + ) + ) + } catch (e: Exception) { + Log.e("saveToString", "${e.message}") + null + } } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/data/source/OpmlLocalDataSource.kt b/app/src/main/java/me/ash/reader/data/source/OpmlLocalDataSource.kt index 6858611..01be475 100644 --- a/app/src/main/java/me/ash/reader/data/source/OpmlLocalDataSource.kt +++ b/app/src/main/java/me/ash/reader/data/source/OpmlLocalDataSource.kt @@ -1,15 +1,13 @@ package me.ash.reader.data.source import android.content.Context -import android.util.Log -import android.util.Xml +import be.ceau.opml.OpmlParser import dagger.hilt.android.qualifiers.ApplicationContext import me.ash.reader.* import me.ash.reader.data.feed.Feed import me.ash.reader.data.group.Group import me.ash.reader.data.group.GroupWithFeed import me.ash.reader.data.repository.StringsRepository -import org.xmlpull.v1.XmlPullParser import java.io.InputStream import java.util.* import javax.inject.Inject @@ -29,59 +27,78 @@ class OpmlLocalDataSource @Inject constructor( // @Throws(XmlPullParserException::class, IOException::class) fun parseFileInputStream(inputStream: InputStream, defaultGroup: Group): List { - val groupWithFeedList = mutableListOf() val accountId = context.currentAccountId - inputStream.use { - val parser: XmlPullParser = Xml.newPullParser() - parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) - parser.setInput(it, null) - parser.nextTag() - while (parser.next() != XmlPullParser.END_DOCUMENT) { - if (parser.eventType != XmlPullParser.START_TAG) { - continue - } - if (parser.name != "outline") { - continue - } - if ("rss" == parser.getAttributeValue(null, "type")) { - val title = parser.getAttributeValue(null, "title") - val xmlUrl = parser.getAttributeValue(null, "xmlUrl") - Log.i("RLog", "rss: ${title} , ${xmlUrl}") - if (groupWithFeedList.isEmpty()) { - groupWithFeedList.add( - GroupWithFeed( - group = defaultGroup, - feeds = mutableListOf() - ) - ) - } - groupWithFeedList.last().let { groupWithFeed -> - groupWithFeed.feeds.add( - Feed( + val opml = OpmlParser().parse(inputStream) + val groupWithFeedList = mutableListOf().also { + it.addGroup(defaultGroup) + } + + opml.body.outlines.forEach { + // Only feeds + if (it.subElements.isEmpty()) { + // It's a empty group + if (it.attributes["xmlUrl"] == null) { + if (!it.attributes["isDefault"].toBoolean()) { + groupWithFeedList.addGroup( + Group( id = UUID.randomUUID().toString(), - name = title, - url = xmlUrl, - groupId = groupWithFeed.group.id, + name = it.attributes["title"] ?: it.text!!, accountId = accountId, ) ) } } else { - val title = parser.getAttributeValue(null, "title") - Log.i("RLog", "title: ${title}") - groupWithFeedList.add( - GroupWithFeed( - group = Group( - id = UUID.randomUUID().toString(), - name = title, - accountId = accountId, - ), - feeds = mutableListOf() + groupWithFeedList.addFeedToDefault( + Feed( + id = UUID.randomUUID().toString(), + name = it.attributes["title"] ?: it.text!!, + url = it.attributes["xmlUrl"]!!, + groupId = defaultGroup.id, + accountId = accountId, + isNotification = it.attributes["isNotification"].toBoolean(), + isFullContent = it.attributes["isFullContent"].toBoolean(), + ) + ) + } + } else { + var groupId = defaultGroup.id + if (!it.attributes["isDefault"].toBoolean()) { + groupId = UUID.randomUUID().toString() + groupWithFeedList.addGroup( + Group( + id = groupId, + name = it.attributes["title"] ?: it.text!!, + accountId = accountId, + ) + ) + } + it.subElements.forEach { outline -> + groupWithFeedList.addFeed( + Feed( + id = UUID.randomUUID().toString(), + name = outline.attributes["title"] ?: outline.text!!, + url = outline.attributes["xmlUrl"]!!, + groupId = groupId, + accountId = accountId, + isNotification = outline.attributes["isNotification"].toBoolean(), + isFullContent = outline.attributes["isFullContent"].toBoolean(), ) ) } } - return groupWithFeedList } + return groupWithFeedList + } + + private fun MutableList.addGroup(group: Group) { + add(GroupWithFeed(group = group, feeds = mutableListOf())) + } + + private fun MutableList.addFeed(feed: Feed) { + last().feeds.add(feed) + } + + private fun MutableList.addFeedToDefault(feed: Feed) { + first().feeds.add(feed) } } \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt index 61b350b..5d2bb1e 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt @@ -1,8 +1,11 @@ package me.ash.reader.ui.page.home.feeds +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.Crossfade import androidx.compose.animation.core.* import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -18,6 +21,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -36,7 +41,10 @@ import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeViewModel import me.ash.reader.ui.widget.Banner import me.ash.reader.ui.widget.Subtitle -@OptIn(ExperimentalMaterial3Api::class, com.google.accompanist.pager.ExperimentalPagerApi::class) +@OptIn( + ExperimentalMaterial3Api::class, com.google.accompanist.pager.ExperimentalPagerApi::class, + androidx.compose.foundation.ExperimentalFoundationApi::class +) @Composable fun FeedsPage( modifier: Modifier = Modifier, @@ -45,6 +53,7 @@ fun FeedsPage( homeViewModel: HomeViewModel = hiltViewModel(), subscribeViewModel: SubscribeViewModel = hiltViewModel(), ) { + val context = LocalContext.current val scope = rememberCoroutineScope() val viewState = feedsViewModel.viewState.collectAsStateValue() val filterState = homeViewModel.filterState.collectAsStateValue() @@ -59,6 +68,18 @@ fun FeedsPage( ) ) + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument() + ) { result -> + feedsViewModel.dispatch(FeedsViewAction.ExportAsString { string -> + result?.let { uri -> + context.contentResolver.openOutputStream(uri)?.let { outputStream -> + outputStream.write(string.toByteArray()) + } + } + }) + } + LaunchedEffect(Unit) { feedsViewModel.dispatch(FeedsViewAction.FetchAccount) } @@ -77,7 +98,8 @@ fun FeedsPage( SmallTopAppBar( title = {}, navigationIcon = { - IconButton(onClick = { }) { + IconButton(onClick = { + }) { Icon( imageVector = Icons.Rounded.ArrowBack, contentDescription = stringResource(R.string.back), @@ -113,18 +135,26 @@ fun FeedsPage( content = { SubscribeDialog( openInputStreamCallback = { - feedsViewModel.dispatch(FeedsViewAction.AddFromFile(it)) + feedsViewModel.dispatch(FeedsViewAction.ImportFromInputStream(it)) }, ) LazyColumn { item { Text( - modifier = Modifier.padding( - start = 24.dp, - top = 48.dp, - end = 24.dp, - bottom = 24.dp - ), + modifier = Modifier + .padding( + start = 24.dp, + top = 48.dp, + end = 24.dp, + bottom = 24.dp + ) + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + launcher.launch("ReadYou.opml") + } + ) + }, text = viewState.account?.name ?: stringResource(R.string.unknown), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt index a6de0fe..984c726 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt @@ -31,7 +31,8 @@ class FeedsViewModel @Inject constructor( when (action) { is FeedsViewAction.FetchAccount -> fetchAccount() is FeedsViewAction.FetchData -> fetchData(action.filterState) - is FeedsViewAction.AddFromFile -> addFromFile(action.inputStream) + is FeedsViewAction.ImportFromInputStream -> importFromInputStream(action.inputStream) + is FeedsViewAction.ExportAsString -> exportAsOpml(action.callback) is FeedsViewAction.ScrollToItem -> scrollToItem(action.index) } } @@ -46,13 +47,19 @@ class FeedsViewModel @Inject constructor( } } - private fun addFromFile(inputStream: InputStream) { + private fun importFromInputStream(inputStream: InputStream) { viewModelScope.launch(Dispatchers.IO) { opmlRepository.saveToDatabase(inputStream) rssRepository.get().doSync() } } + private fun exportAsOpml(callback: (String) -> Unit = {}) { + viewModelScope.launch(Dispatchers.Default) { + opmlRepository.saveToString()?.let { callback(it) } + } + } + private fun fetchData(filterState: FilterState) { viewModelScope.launch(Dispatchers.IO) { pullFeeds( @@ -143,12 +150,16 @@ sealed class FeedsViewAction { val filterState: FilterState, ) : FeedsViewAction() - object FetchAccount: FeedsViewAction() + object FetchAccount : FeedsViewAction() - data class AddFromFile( + data class ImportFromInputStream( val inputStream: InputStream ) : FeedsViewAction() + data class ExportAsString( + val callback: (String) -> Unit = {} + ) : FeedsViewAction() + data class ScrollToItem( val index: Int ) : FeedsViewAction()