Add export OPML file feature and fix ksoap2 XmlPullParser confusion
This commit is contained in:
parent
8263b12ae7
commit
2d5c9dbfc2
|
@ -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")
|
||||
|
|
5
app/proguard-rules.pro
vendored
5
app/proguard-rules.pro
vendored
|
@ -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.* {*;}
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue
Block a user