Show opml import errors in AccountTab

This commit is contained in:
Shinokuni 2024-04-01 13:02:59 +02:00
parent e0874f2297
commit 0a1574df0d
9 changed files with 178 additions and 50 deletions

View File

@ -2,10 +2,14 @@ package com.readrops.app.compose.account
import android.content.Context
import android.net.Uri
import androidx.core.net.toFile
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.api.opml.OPMLParser
import com.readrops.app.compose.base.TabScreenModel
import com.readrops.app.compose.repositories.ErrorResult
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import kotlinx.coroutines.Dispatchers
@ -38,7 +42,15 @@ class AccountScreenModel(
fun openDialog(dialog: DialogState) = _accountState.update { it.copy(dialog = dialog) }
fun closeDialog() = _accountState.update { it.copy(dialog = null) }
fun closeDialog(dialog: DialogState? = null) {
if (dialog is DialogState.ErrorList) {
_accountState.update { it.copy(synchronizationErrors = null) }
} else if (dialog is DialogState.Error) {
_accountState.update { it.copy(opmlImportError = null) }
}
_accountState.update { it.copy(dialog = null) }
}
fun deleteAccount() {
screenModelScope.launch(Dispatchers.IO) {
@ -51,8 +63,20 @@ class AccountScreenModel(
fun parseOPMLFile(uri: Uri, context: Context) {
screenModelScope.launch(Dispatchers.IO) {
val stream = context.contentResolver.openInputStream(uri)!!
val foldersAndFeeds = OPMLParser.read(stream)
val foldersAndFeeds: Map<Folder?, List<Feed>>
try {
val stream = context.contentResolver.openInputStream(uri)
if (stream == null) {
_accountState.update { it.copy(opmlImportError = NoSuchFileException(uri.toFile())) }
return@launch
}
foldersAndFeeds = OPMLParser.read(stream)
} catch (e: Exception) {
_accountState.update { it.copy(opmlImportError = e) }
return@launch
}
openDialog(
DialogState.OPMLImport(
@ -62,7 +86,7 @@ class AccountScreenModel(
)
)
repository?.insertOPMLFoldersAndFeeds(
val errors = repository?.insertOPMLFoldersAndFeeds(
foldersAndFeeds = foldersAndFeeds,
onUpdate = { feed ->
_accountState.update {
@ -79,6 +103,10 @@ class AccountScreenModel(
)
closeDialog()
_accountState.update {
it.copy(synchronizationErrors = if (errors!!.isNotEmpty()) errors else null)
}
}
}
}
@ -86,6 +114,8 @@ class AccountScreenModel(
data class AccountState(
val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL),
val dialog: DialogState? = null,
val synchronizationErrors: ErrorResult? = null,
val opmlImportError: Exception? = null
)
sealed interface DialogState {
@ -93,4 +123,7 @@ sealed interface DialogState {
object NewAccount : DialogState
data class OPMLImport(val currentFeed: String, val feedCount: Int, val feedMax: Int) :
DialogState
data class ErrorList(val errorResult: ErrorResult) : DialogState
data class Error(val exception: Exception) : DialogState
}

View File

@ -19,10 +19,16 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
@ -41,6 +47,8 @@ import com.readrops.app.compose.R
import com.readrops.app.compose.account.credentials.AccountCredentialsScreen
import com.readrops.app.compose.account.selection.AccountSelectionDialog
import com.readrops.app.compose.account.selection.AccountSelectionScreen
import com.readrops.app.compose.timelime.ErrorListDialog
import com.readrops.app.compose.util.components.ErrorDialog
import com.readrops.app.compose.util.components.SelectableIconText
import com.readrops.app.compose.util.components.TwoChoicesDialog
import com.readrops.app.compose.util.theme.LargeSpacer
@ -66,6 +74,8 @@ object AccountTab : Tab {
val closeHome by viewModel.closeHome.collectAsStateWithLifecycle()
val state by viewModel.accountState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
if (closeHome) {
navigator.replaceAll(AccountSelectionScreen())
}
@ -75,6 +85,44 @@ object AccountTab : Tab {
uri?.let { viewModel.parseOPMLFile(uri, context) }
}
LaunchedEffect(state.opmlImportError) {
if (state.opmlImportError != null) {
val action = snackbarHostState.showSnackbar(
message = context.resources.getQuantityString(
R.plurals.error_occurred,
1
),
actionLabel = context.getString(R.string.details),
duration = SnackbarDuration.Short
)
if (action == SnackbarResult.ActionPerformed) {
viewModel.openDialog(DialogState.Error(state.opmlImportError!!))
} else {
viewModel.closeDialog(DialogState.Error(state.opmlImportError!!))
}
}
}
LaunchedEffect(state.synchronizationErrors) {
if (state.synchronizationErrors != null) {
val action = snackbarHostState.showSnackbar(
message = context.resources.getQuantityString(
R.plurals.error_occurred,
state.synchronizationErrors!!.size
),
actionLabel = context.getString(R.string.details),
duration = SnackbarDuration.Short
)
if (action == SnackbarResult.ActionPerformed) {
viewModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
} else {
viewModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
}
}
}
when (val dialog = state.dialog) {
is DialogState.DeleteAccount -> {
TwoChoicesDialog(
@ -109,6 +157,20 @@ object AccountTab : Tab {
)
}
is DialogState.ErrorList -> {
ErrorListDialog(
errorResult = dialog.errorResult,
onDismiss = { viewModel.closeDialog(dialog) }
)
}
is DialogState.Error -> {
ErrorDialog(
exception = dialog.exception,
onDismiss = { viewModel.closeDialog(dialog) }
)
}
else -> {}
}
@ -137,7 +199,8 @@ object AccountTab : Tab {
contentDescription = null
)
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
Column(
modifier = Modifier

View File

@ -37,7 +37,7 @@ abstract class ARepository(
*/
abstract suspend fun synchronize(): SyncResult
abstract suspend fun insertNewFeeds(newFeeds: List<Feed>, onUpdate: (Feed) -> Unit)
abstract suspend fun insertNewFeeds(newFeeds: List<Feed>, onUpdate: (Feed) -> Unit): ErrorResult
}
abstract class BaseRepository(
@ -87,7 +87,9 @@ abstract class BaseRepository(
suspend fun insertOPMLFoldersAndFeeds(
foldersAndFeeds: Map<Folder?, List<Feed>>,
onUpdate: (Feed) -> Unit
) {
): ErrorResult {
val errors = mutableMapOf<Feed, Exception>()
for ((folder, feeds) in foldersAndFeeds) {
if (folder != null) {
folder.accountId = account.id
@ -103,10 +105,12 @@ abstract class BaseRepository(
feeds.forEach { it.folderId = folder?.id }
insertNewFeeds(
errors += insertNewFeeds(
newFeeds = feeds,
onUpdate = onUpdate
)
}
return errors
}
}

View File

@ -1,6 +1,5 @@
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
@ -69,20 +68,26 @@ class LocalRSSRepository(
throw NotImplementedError("This method can't be called here")
override suspend fun insertNewFeeds(newFeeds: List<Feed>, onUpdate: (Feed) -> Unit) = withContext(Dispatchers.IO) {
for (newFeed in newFeeds) {
onUpdate(newFeed)
override suspend fun insertNewFeeds(
newFeeds: List<Feed>,
onUpdate: (Feed) -> Unit
): ErrorResult = withContext(Dispatchers.IO) {
val errors = mutableMapOf<Feed, Exception>()
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
}
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) {
errors[newFeed] = e
}
}
return@withContext errors
}
private suspend fun insertNewItems(items: List<Item>, feed: Feed) {
items.sortedWith(Item::compareTo) // TODO Check if ordering is useful in this situation
val itemsToInsert = mutableListOf<Item>()

View File

@ -11,16 +11,12 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.readrops.api.utils.exceptions.HttpException
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.exceptions.UnknownFormatException
import com.readrops.app.compose.R
import com.readrops.app.compose.repositories.ErrorResult
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.components.errorText
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.ShortSpacer
import java.io.IOException
import java.net.UnknownHostException
@Composable
fun ErrorListDialog(
@ -54,14 +50,4 @@ fun ErrorListDialog(
}
}
}
}
// TODO check compatibility with other accounts errors
@Composable
fun errorText(exception: Exception) = when (exception) {
is HttpException -> stringResource(id = R.string.unreachable_feed_http_error, exception.code.toString())
is UnknownHostException -> stringResource(R.string.unreachable_feed)
is IOException -> stringResource(R.string.network_failure, exception.message.orEmpty())
is ParseException, is UnknownFormatException -> stringResource(R.string.processing_feed_error)
else -> "${exception.javaClass.simpleName}: ${exception.message}"
}

View File

@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -61,7 +60,6 @@ import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.filters.ListSortType
import com.readrops.db.filters.MainFilter
import com.readrops.db.filters.SubFilter
import kotlinx.coroutines.launch
object TimelineTab : Tab {
@ -78,7 +76,6 @@ object TimelineTab : Tab {
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val viewModel = getScreenModel<TimelineScreenModel>()
val state by viewModel.timelineState.collectAsStateWithLifecycle()
@ -132,19 +129,20 @@ object TimelineTab : Tab {
LaunchedEffect(state.synchronizationErrors) {
if (state.synchronizationErrors != null) {
coroutineScope.launch {
val action = snackbarHostState.showSnackbar(
message = context.resources.getQuantityString(R.plurals.error_occurred, state.synchronizationErrors!!.size),
actionLabel = context.getString(R.string.details),
duration = SnackbarDuration.Short
)
val action = snackbarHostState.showSnackbar(
message = context.resources.getQuantityString(
R.plurals.error_occurred,
state.synchronizationErrors!!.size
),
actionLabel = context.getString(R.string.details),
duration = SnackbarDuration.Short
)
if (action == SnackbarResult.ActionPerformed) {
viewModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
} else {
// remove errors from state
viewModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
}
if (action == SnackbarResult.ActionPerformed) {
viewModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
} else {
// remove errors from state
viewModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
}
}
}
@ -186,7 +184,7 @@ object TimelineTab : Tab {
is DialogState.ErrorList -> {
ErrorListDialog(
errorResult = dialog.errorResult,
onDismiss = { viewModel.closeDialog(state.dialog) }
onDismiss = { viewModel.closeDialog(dialog) }
)
}

View File

@ -0,0 +1,37 @@
package com.readrops.app.compose.util.components
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.readrops.api.utils.exceptions.HttpException
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.exceptions.UnknownFormatException
import com.readrops.app.compose.R
import java.io.IOException
import java.net.UnknownHostException
@Composable
fun ErrorDialog(
exception: Exception,
onDismiss: () -> Unit
) {
BaseDialog(
title = stringResource(id = R.string.error_occured),
icon = painterResource(id = R.drawable.ic_error),
onDismiss = onDismiss
) {
Text(text = errorText(exception = exception))
}
}
// TODO check compatibility with other accounts errors
@Composable
fun errorText(exception: Exception) = when (exception) {
is HttpException -> stringResource(id = R.string.unreachable_feed_http_error, exception.code.toString())
is UnknownHostException -> stringResource(R.string.unreachable_feed)
is NoSuchFileException -> stringResource(R.string.unable_open_file)
is IOException -> stringResource(R.string.network_failure, exception.message.orEmpty())
is ParseException, is UnknownFormatException -> stringResource(R.string.processing_feed_error)
else -> "${exception.javaClass.simpleName}: ${exception.message}"
}

View File

@ -156,4 +156,5 @@
<string name="network_failure">Erreur réseau: %1$s</string>
<string name="processing_feed_error">Erreur de traitement du flux</string>
<string name="unreachable_feed">Flux non attaignable</string>
<string name="unable_open_file">Impossible d\'ouvrir le fichier</string>
</resources>

View File

@ -162,4 +162,5 @@
<string name="network_failure">Network failure: %1$s</string>
<string name="processing_feed_error">Processing feed error</string>
<string name="unreachable_feed">Unreachable feed</string>
<string name="unable_open_file">Unable to open file</string>
</resources>