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.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.api.opml.OPMLParser import com.readrops.api.opml.OPMLParser
import com.readrops.app.compose.base.TabScreenModel import com.readrops.app.compose.base.TabScreenModel
import com.readrops.app.compose.repositories.ErrorResult
import com.readrops.db.Database 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.Account
import com.readrops.db.entities.account.AccountType import com.readrops.db.entities.account.AccountType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -38,7 +42,15 @@ class AccountScreenModel(
fun openDialog(dialog: DialogState) = _accountState.update { it.copy(dialog = dialog) } 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() { fun deleteAccount() {
screenModelScope.launch(Dispatchers.IO) { screenModelScope.launch(Dispatchers.IO) {
@ -51,8 +63,20 @@ class AccountScreenModel(
fun parseOPMLFile(uri: Uri, context: Context) { fun parseOPMLFile(uri: Uri, context: Context) {
screenModelScope.launch(Dispatchers.IO) { screenModelScope.launch(Dispatchers.IO) {
val stream = context.contentResolver.openInputStream(uri)!! val foldersAndFeeds: Map<Folder?, List<Feed>>
val foldersAndFeeds = OPMLParser.read(stream)
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( openDialog(
DialogState.OPMLImport( DialogState.OPMLImport(
@ -62,7 +86,7 @@ class AccountScreenModel(
) )
) )
repository?.insertOPMLFoldersAndFeeds( val errors = repository?.insertOPMLFoldersAndFeeds(
foldersAndFeeds = foldersAndFeeds, foldersAndFeeds = foldersAndFeeds,
onUpdate = { feed -> onUpdate = { feed ->
_accountState.update { _accountState.update {
@ -79,6 +103,10 @@ class AccountScreenModel(
) )
closeDialog() closeDialog()
_accountState.update {
it.copy(synchronizationErrors = if (errors!!.isNotEmpty()) errors else null)
}
} }
} }
} }
@ -86,6 +114,8 @@ class AccountScreenModel(
data class AccountState( data class AccountState(
val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL), val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL),
val dialog: DialogState? = null, val dialog: DialogState? = null,
val synchronizationErrors: ErrorResult? = null,
val opmlImportError: Exception? = null
) )
sealed interface DialogState { sealed interface DialogState {
@ -93,4 +123,7 @@ sealed interface DialogState {
object NewAccount : DialogState object NewAccount : DialogState
data class OPMLImport(val currentFeed: String, val feedCount: Int, val feedMax: Int) : data class OPMLImport(val currentFeed: String, val feedCount: Int, val feedMax: Int) :
DialogState 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.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold 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.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter 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.credentials.AccountCredentialsScreen
import com.readrops.app.compose.account.selection.AccountSelectionDialog import com.readrops.app.compose.account.selection.AccountSelectionDialog
import com.readrops.app.compose.account.selection.AccountSelectionScreen 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.SelectableIconText
import com.readrops.app.compose.util.components.TwoChoicesDialog import com.readrops.app.compose.util.components.TwoChoicesDialog
import com.readrops.app.compose.util.theme.LargeSpacer import com.readrops.app.compose.util.theme.LargeSpacer
@ -66,6 +74,8 @@ object AccountTab : Tab {
val closeHome by viewModel.closeHome.collectAsStateWithLifecycle() val closeHome by viewModel.closeHome.collectAsStateWithLifecycle()
val state by viewModel.accountState.collectAsStateWithLifecycle() val state by viewModel.accountState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
if (closeHome) { if (closeHome) {
navigator.replaceAll(AccountSelectionScreen()) navigator.replaceAll(AccountSelectionScreen())
} }
@ -75,6 +85,44 @@ object AccountTab : Tab {
uri?.let { viewModel.parseOPMLFile(uri, context) } 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) { when (val dialog = state.dialog) {
is DialogState.DeleteAccount -> { is DialogState.DeleteAccount -> {
TwoChoicesDialog( 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 -> {} else -> {}
} }
@ -137,7 +199,8 @@ object AccountTab : Tab {
contentDescription = null contentDescription = null
) )
} }
} },
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues -> ) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier

View File

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

View File

@ -1,6 +1,5 @@
package com.readrops.app.compose.repositories package com.readrops.app.compose.repositories
import android.util.Log
import com.readrops.api.localfeed.LocalRSSDataSource import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.services.SyncResult import com.readrops.api.services.SyncResult
import com.readrops.api.utils.ApiUtils import com.readrops.api.utils.ApiUtils
@ -69,20 +68,26 @@ class LocalRSSRepository(
throw NotImplementedError("This method can't be called here") throw NotImplementedError("This method can't be called here")
override suspend fun insertNewFeeds(newFeeds: List<Feed>, onUpdate: (Feed) -> Unit) = withContext(Dispatchers.IO) { override suspend fun insertNewFeeds(
for (newFeed in newFeeds) { newFeeds: List<Feed>,
onUpdate(newFeed) onUpdate: (Feed) -> Unit
): ErrorResult = withContext(Dispatchers.IO) {
val errors = mutableMapOf<Feed, Exception>()
try { for (newFeed in newFeeds) {
val result = dataSource.queryRSSResource(newFeed.url!!, null)!! onUpdate(newFeed)
insertFeed(result.first.also { it.folderId = newFeed.folderId })
} catch (e: Exception) { try {
Log.d("LocalRSSRepository", e.message.orEmpty()) val result = dataSource.queryRSSResource(newFeed.url!!, null)!!
//throw e 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) { private suspend fun insertNewItems(items: List<Item>, feed: Feed) {
items.sortedWith(Item::compareTo) // TODO Check if ordering is useful in this situation items.sortedWith(Item::compareTo) // TODO Check if ordering is useful in this situation
val itemsToInsert = mutableListOf<Item>() 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.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.R
import com.readrops.app.compose.repositories.ErrorResult import com.readrops.app.compose.repositories.ErrorResult
import com.readrops.app.compose.util.components.BaseDialog 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.MediumSpacer
import com.readrops.app.compose.util.theme.ShortSpacer import com.readrops.app.compose.util.theme.ShortSpacer
import java.io.IOException
import java.net.UnknownHostException
@Composable @Composable
fun ErrorListDialog( 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.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll 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.ListSortType
import com.readrops.db.filters.MainFilter import com.readrops.db.filters.MainFilter
import com.readrops.db.filters.SubFilter import com.readrops.db.filters.SubFilter
import kotlinx.coroutines.launch
object TimelineTab : Tab { object TimelineTab : Tab {
@ -78,7 +76,6 @@ object TimelineTab : Tab {
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val viewModel = getScreenModel<TimelineScreenModel>() val viewModel = getScreenModel<TimelineScreenModel>()
val state by viewModel.timelineState.collectAsStateWithLifecycle() val state by viewModel.timelineState.collectAsStateWithLifecycle()
@ -132,19 +129,20 @@ object TimelineTab : Tab {
LaunchedEffect(state.synchronizationErrors) { LaunchedEffect(state.synchronizationErrors) {
if (state.synchronizationErrors != null) { if (state.synchronizationErrors != null) {
coroutineScope.launch { val action = snackbarHostState.showSnackbar(
val action = snackbarHostState.showSnackbar( message = context.resources.getQuantityString(
message = context.resources.getQuantityString(R.plurals.error_occurred, state.synchronizationErrors!!.size), R.plurals.error_occurred,
actionLabel = context.getString(R.string.details), state.synchronizationErrors!!.size
duration = SnackbarDuration.Short ),
) actionLabel = context.getString(R.string.details),
duration = SnackbarDuration.Short
)
if (action == SnackbarResult.ActionPerformed) { if (action == SnackbarResult.ActionPerformed) {
viewModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!)) viewModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
} else { } else {
// remove errors from state // remove errors from state
viewModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!)) viewModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
}
} }
} }
} }
@ -186,7 +184,7 @@ object TimelineTab : Tab {
is DialogState.ErrorList -> { is DialogState.ErrorList -> {
ErrorListDialog( ErrorListDialog(
errorResult = dialog.errorResult, 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="network_failure">Erreur réseau: %1$s</string>
<string name="processing_feed_error">Erreur de traitement du flux</string> <string name="processing_feed_error">Erreur de traitement du flux</string>
<string name="unreachable_feed">Flux non attaignable</string> <string name="unreachable_feed">Flux non attaignable</string>
<string name="unable_open_file">Impossible d\'ouvrir le fichier</string>
</resources> </resources>

View File

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