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 {
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")

View File

@ -25,3 +25,8 @@
# Disable ServiceLoader reproducibility-breaking optimizations
-keep class kotlinx.coroutines.CoroutineExceptionHandler
-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
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)

View File

@ -20,7 +20,16 @@ interface GroupDao {
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(
"""

View File

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

View File

@ -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
}
}

View File

@ -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<GroupWithFeed> {
val groupWithFeedList = mutableListOf<GroupWithFeed>()
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<GroupWithFeed>().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<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
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,

View File

@ -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()