Add export OPML file feature and fix ksoap2 XmlPullParser confusion

This commit is contained in:
Ash 2022-03-30 14:38:47 +08:00
parent 8263b12ae7
commit 2d5c9dbfc2
9 changed files with 191 additions and 61 deletions

View File

@ -53,6 +53,7 @@ android {
} }
dependencies { dependencies {
implementation 'be.ceau:opml-parser:2.2.0'
implementation "androidx.profileinstaller:profileinstaller:1.2.0-alpha02" implementation "androidx.profileinstaller:profileinstaller:1.2.0-alpha02"
implementation("io.coil-kt:coil-compose:2.0.0-rc02") implementation("io.coil-kt:coil-compose:2.0.0-rc02")
implementation("androidx.compose.animation:animation-graphics:$compose_version") implementation("androidx.compose.animation:animation-graphics:$compose_version")

View File

@ -25,3 +25,8 @@
# Disable ServiceLoader reproducibility-breaking optimizations # Disable ServiceLoader reproducibility-breaking optimizations
-keep class kotlinx.coroutines.CoroutineExceptionHandler -keep class kotlinx.coroutines.CoroutineExceptionHandler
-keep class kotlinx.coroutines.internal.MainDispatcherFactory -keep class kotlinx.coroutines.internal.MainDispatcherFactory
# ksoap2 XmlPullParser confusion
-dontwarn org.xmlpull.v1.XmlPullParser
-dontwarn org.xmlpull.v1.XmlSerializer
-keep class org.xmlpull.v1.* {*;}

View File

@ -1,9 +1,11 @@
package me.ash.reader package me.ash.reader
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.profileinstaller.ProfileInstallerInitializer
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import me.ash.reader.ui.page.common.HomeEntry import me.ash.reader.ui.page.common.HomeEntry
@ -13,6 +15,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
Log.i("RLog", "onCreate: ${ProfileInstallerInitializer().create(this)}")
setContent { setContent {
HomeEntry(intent.extras).also { HomeEntry(intent.extras).also {
intent.replaceExtras(null) intent.replaceExtras(null)

View File

@ -20,7 +20,16 @@ interface GroupDao {
WHERE accountId = :accountId WHERE accountId = :accountId
""" """
) )
fun queryAllGroupWithFeed(accountId: Int): Flow<MutableList<GroupWithFeed>> fun queryAllGroupWithFeedAsFlow(accountId: Int): Flow<MutableList<GroupWithFeed>>
@Transaction
@Query(
"""
SELECT * FROM `group`
WHERE accountId = :accountId
"""
)
fun queryAllGroupWithFeed(accountId: Int): List<GroupWithFeed>
@Query( @Query(
""" """

View File

@ -63,7 +63,7 @@ abstract class AbstractRssRepository constructor(
} }
fun pullFeeds(): Flow<MutableList<GroupWithFeed>> { fun pullFeeds(): Flow<MutableList<GroupWithFeed>> {
return groupDao.queryAllGroupWithFeed(context.currentAccountId).flowOn(Dispatchers.IO) return groupDao.queryAllGroupWithFeedAsFlow(context.currentAccountId).flowOn(Dispatchers.IO)
} }
fun pullArticles( fun pullArticles(

View File

@ -1,16 +1,29 @@
package me.ash.reader.data.repository package me.ash.reader.data.repository
import android.content.Context
import android.util.Log 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.Feed
import me.ash.reader.data.feed.FeedDao import me.ash.reader.data.feed.FeedDao
import me.ash.reader.data.group.GroupDao import me.ash.reader.data.group.GroupDao
import me.ash.reader.data.source.OpmlLocalDataSource import me.ash.reader.data.source.OpmlLocalDataSource
import java.io.InputStream import java.io.InputStream
import java.util.*
import javax.inject.Inject import javax.inject.Inject
class OpmlRepository @Inject constructor( class OpmlRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val groupDao: GroupDao, private val groupDao: GroupDao,
private val feedDao: FeedDao, private val feedDao: FeedDao,
private val accountDao: AccountDao,
private val rssRepository: RssRepository, private val rssRepository: RssRepository,
private val opmlLocalDataSource: OpmlLocalDataSource private val opmlLocalDataSource: OpmlLocalDataSource
) { ) {
@ -36,4 +49,45 @@ class OpmlRepository @Inject constructor(
Log.e("saveToDatabase", "${e.message}") 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
}
} }

View File

@ -1,15 +1,13 @@
package me.ash.reader.data.source package me.ash.reader.data.source
import android.content.Context import android.content.Context
import android.util.Log import be.ceau.opml.OpmlParser
import android.util.Xml
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import me.ash.reader.* import me.ash.reader.*
import me.ash.reader.data.feed.Feed import me.ash.reader.data.feed.Feed
import me.ash.reader.data.group.Group import me.ash.reader.data.group.Group
import me.ash.reader.data.group.GroupWithFeed import me.ash.reader.data.group.GroupWithFeed
import me.ash.reader.data.repository.StringsRepository import me.ash.reader.data.repository.StringsRepository
import org.xmlpull.v1.XmlPullParser
import java.io.InputStream import java.io.InputStream
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@ -29,59 +27,78 @@ class OpmlLocalDataSource @Inject constructor(
// @Throws(XmlPullParserException::class, IOException::class) // @Throws(XmlPullParserException::class, IOException::class)
fun parseFileInputStream(inputStream: InputStream, defaultGroup: Group): List<GroupWithFeed> { fun parseFileInputStream(inputStream: InputStream, defaultGroup: Group): List<GroupWithFeed> {
val groupWithFeedList = mutableListOf<GroupWithFeed>()
val accountId = context.currentAccountId val accountId = context.currentAccountId
inputStream.use { val opml = OpmlParser().parse(inputStream)
val parser: XmlPullParser = Xml.newPullParser() val groupWithFeedList = mutableListOf<GroupWithFeed>().also {
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) it.addGroup(defaultGroup)
parser.setInput(it, null)
parser.nextTag()
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
} }
if (parser.name != "outline") {
continue opml.body.outlines.forEach {
} // Only feeds
if ("rss" == parser.getAttributeValue(null, "type")) { if (it.subElements.isEmpty()) {
val title = parser.getAttributeValue(null, "title") // It's a empty group
val xmlUrl = parser.getAttributeValue(null, "xmlUrl") if (it.attributes["xmlUrl"] == null) {
Log.i("RLog", "rss: ${title} , ${xmlUrl}") if (!it.attributes["isDefault"].toBoolean()) {
if (groupWithFeedList.isEmpty()) { groupWithFeedList.addGroup(
groupWithFeedList.add( Group(
GroupWithFeed(
group = defaultGroup,
feeds = mutableListOf()
)
)
}
groupWithFeedList.last().let { groupWithFeed ->
groupWithFeed.feeds.add(
Feed(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
name = title, name = it.attributes["title"] ?: it.text!!,
url = xmlUrl,
groupId = groupWithFeed.group.id,
accountId = accountId, accountId = accountId,
) )
) )
} }
} else { } else {
val title = parser.getAttributeValue(null, "title") groupWithFeedList.addFeedToDefault(
Log.i("RLog", "title: ${title}") Feed(
groupWithFeedList.add(
GroupWithFeed(
group = Group(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
name = title, name = it.attributes["title"] ?: it.text!!,
url = it.attributes["xmlUrl"]!!,
groupId = defaultGroup.id,
accountId = accountId, accountId = accountId,
), isNotification = it.attributes["isNotification"].toBoolean(),
feeds = mutableListOf() 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<GroupWithFeed>.addGroup(group: Group) {
add(GroupWithFeed(group = group, feeds = mutableListOf()))
}
private fun MutableList<GroupWithFeed>.addFeed(feed: Feed) {
last().feeds.add(feed)
}
private fun MutableList<GroupWithFeed>.addFeedToDefault(feed: Feed) {
first().feeds.add(feed)
} }
} }

View File

@ -1,8 +1,11 @@
package me.ash.reader.ui.page.home.feeds 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.Crossfade
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@ -18,6 +21,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate 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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.Banner
import me.ash.reader.ui.widget.Subtitle 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 @Composable
fun FeedsPage( fun FeedsPage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -45,6 +53,7 @@ fun FeedsPage(
homeViewModel: HomeViewModel = hiltViewModel(), homeViewModel: HomeViewModel = hiltViewModel(),
subscribeViewModel: SubscribeViewModel = hiltViewModel(), subscribeViewModel: SubscribeViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val viewState = feedsViewModel.viewState.collectAsStateValue() val viewState = feedsViewModel.viewState.collectAsStateValue()
val filterState = homeViewModel.filterState.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) { LaunchedEffect(Unit) {
feedsViewModel.dispatch(FeedsViewAction.FetchAccount) feedsViewModel.dispatch(FeedsViewAction.FetchAccount)
} }
@ -77,7 +98,8 @@ fun FeedsPage(
SmallTopAppBar( SmallTopAppBar(
title = {}, title = {},
navigationIcon = { navigationIcon = {
IconButton(onClick = { }) { IconButton(onClick = {
}) {
Icon( Icon(
imageVector = Icons.Rounded.ArrowBack, imageVector = Icons.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back), contentDescription = stringResource(R.string.back),
@ -113,18 +135,26 @@ fun FeedsPage(
content = { content = {
SubscribeDialog( SubscribeDialog(
openInputStreamCallback = { openInputStreamCallback = {
feedsViewModel.dispatch(FeedsViewAction.AddFromFile(it)) feedsViewModel.dispatch(FeedsViewAction.ImportFromInputStream(it))
}, },
) )
LazyColumn { LazyColumn {
item { item {
Text( Text(
modifier = Modifier.padding( modifier = Modifier
.padding(
start = 24.dp, start = 24.dp,
top = 48.dp, top = 48.dp,
end = 24.dp, end = 24.dp,
bottom = 24.dp bottom = 24.dp
), )
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
launcher.launch("ReadYou.opml")
}
)
},
text = viewState.account?.name ?: stringResource(R.string.unknown), text = viewState.account?.name ?: stringResource(R.string.unknown),
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,

View File

@ -31,7 +31,8 @@ class FeedsViewModel @Inject constructor(
when (action) { when (action) {
is FeedsViewAction.FetchAccount -> fetchAccount() is FeedsViewAction.FetchAccount -> fetchAccount()
is FeedsViewAction.FetchData -> fetchData(action.filterState) 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) 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) { viewModelScope.launch(Dispatchers.IO) {
opmlRepository.saveToDatabase(inputStream) opmlRepository.saveToDatabase(inputStream)
rssRepository.get().doSync() rssRepository.get().doSync()
} }
} }
private fun exportAsOpml(callback: (String) -> Unit = {}) {
viewModelScope.launch(Dispatchers.Default) {
opmlRepository.saveToString()?.let { callback(it) }
}
}
private fun fetchData(filterState: FilterState) { private fun fetchData(filterState: FilterState) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
pullFeeds( pullFeeds(
@ -143,12 +150,16 @@ sealed class FeedsViewAction {
val filterState: FilterState, val filterState: FilterState,
) : FeedsViewAction() ) : FeedsViewAction()
object FetchAccount: FeedsViewAction() object FetchAccount : FeedsViewAction()
data class AddFromFile( data class ImportFromInputStream(
val inputStream: InputStream val inputStream: InputStream
) : FeedsViewAction() ) : FeedsViewAction()
data class ExportAsString(
val callback: (String) -> Unit = {}
) : FeedsViewAction()
data class ScrollToItem( data class ScrollToItem(
val index: Int val index: Int
) : FeedsViewAction() ) : FeedsViewAction()