mirror of
https://github.com/TeamNewPipe/NewPipe.git
synced 2025-10-03 09:49:21 +02:00
Updated ERefactor ErrorPanel to use ErrorInfo directly and remove UI models
This commit is contained in:
parent
19fb9899cd
commit
8292f729ea
11 changed files with 237 additions and 316 deletions
|
@ -0,0 +1,119 @@
|
||||||
|
package org.schabi.newpipe.ui.components.video.comment
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.paging.LoadState
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.schabi.newpipe.error.ErrorInfo
|
||||||
|
import org.schabi.newpipe.error.UserAction
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||||
|
import org.schabi.newpipe.ui.components.common.ErrorAction
|
||||||
|
import org.schabi.newpipe.ui.components.common.determineErrorAction
|
||||||
|
import org.schabi.newpipe.viewmodels.util.Resource
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CommentSectionErrorIntegrationTest {
|
||||||
|
|
||||||
|
private lateinit var context: Context
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 1: Network error on initial load (Resource.Error)
|
||||||
|
@Test
|
||||||
|
fun testInitialCommentNetworkError() {
|
||||||
|
val expectedMessage = "Connection timeout"
|
||||||
|
val networkError = SocketTimeoutException(expectedMessage)
|
||||||
|
val resourceError = Resource.Error(networkError)
|
||||||
|
|
||||||
|
val errorInfo = ErrorInfo(
|
||||||
|
throwable = resourceError.throwable,
|
||||||
|
userAction = UserAction.REQUESTED_COMMENTS,
|
||||||
|
request = "comments"
|
||||||
|
)
|
||||||
|
assertEquals(networkError, errorInfo.throwable)
|
||||||
|
assertEquals(ErrorAction.REPORT, determineErrorAction(errorInfo))
|
||||||
|
assertEquals(expectedMessage, errorInfo.getExplanation())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Network error on paging (LoadState.Error)
|
||||||
|
@Test
|
||||||
|
fun testPagingNetworkError() {
|
||||||
|
val expectedMessage = "Paging failed"
|
||||||
|
val pagingError = IOException(expectedMessage)
|
||||||
|
val loadStateError = LoadState.Error(pagingError)
|
||||||
|
|
||||||
|
val errorInfo = ErrorInfo(
|
||||||
|
throwable = loadStateError.error,
|
||||||
|
userAction = UserAction.REQUESTED_COMMENTS,
|
||||||
|
request = "comments"
|
||||||
|
)
|
||||||
|
assertEquals(pagingError, errorInfo.throwable)
|
||||||
|
assertEquals(ErrorAction.REPORT, determineErrorAction(errorInfo))
|
||||||
|
assertEquals(expectedMessage, errorInfo.getExplanation())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: ReCaptcha during comments load
|
||||||
|
@Test
|
||||||
|
fun testReCaptchaDuringComments() {
|
||||||
|
val url = "https://www.google.com/recaptcha/api/fallback?k=test"
|
||||||
|
val expectedMessage = "ReCaptcha needed"
|
||||||
|
val captcha = ReCaptchaException(expectedMessage, url)
|
||||||
|
val errorInfo = ErrorInfo(
|
||||||
|
throwable = captcha,
|
||||||
|
userAction = UserAction.REQUESTED_COMMENTS,
|
||||||
|
request = "comments"
|
||||||
|
)
|
||||||
|
assertEquals(ErrorAction.SOLVE_CAPTCHA, determineErrorAction(errorInfo))
|
||||||
|
assertEquals(expectedMessage, errorInfo.getExplanation())
|
||||||
|
|
||||||
|
val intent = Intent(context, org.schabi.newpipe.error.ReCaptchaActivity::class.java).apply {
|
||||||
|
putExtra(org.schabi.newpipe.error.ReCaptchaActivity.RECAPTCHA_URL_EXTRA, url)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
assertEquals(url, intent.getStringExtra(org.schabi.newpipe.error.ReCaptchaActivity.RECAPTCHA_URL_EXTRA))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Retry functionality integration with ErrorPanel
|
||||||
|
@Test
|
||||||
|
fun testRetryIntegrationWithErrorPanel() {
|
||||||
|
val expectedMessage = "Network request failed"
|
||||||
|
val networkError = IOException(expectedMessage)
|
||||||
|
val loadStateError = LoadState.Error(networkError)
|
||||||
|
|
||||||
|
val errorInfo = ErrorInfo(
|
||||||
|
throwable = loadStateError.error,
|
||||||
|
userAction = UserAction.REQUESTED_COMMENTS,
|
||||||
|
request = "comments"
|
||||||
|
)
|
||||||
|
|
||||||
|
val errorAction = determineErrorAction(errorInfo)
|
||||||
|
assertEquals("Network errors should get REPORT action", ErrorAction.REPORT, errorAction)
|
||||||
|
var retryCallbackInvoked = false
|
||||||
|
val mockCommentRetry = {
|
||||||
|
retryCallbackInvoked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
mockCommentRetry()
|
||||||
|
assertTrue("Retry callback should be invoked when user clicks retry", retryCallbackInvoked)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"Error explanation should be available for retry scenarios",
|
||||||
|
expectedMessage, errorInfo.getExplanation()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Error should maintain comment context for retry",
|
||||||
|
UserAction.REQUESTED_COMMENTS, errorInfo.userAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,65 +0,0 @@
|
||||||
package org.schabi.newpipe.ui.components.video.comments
|
|
||||||
|
|
||||||
import android.net.http.NetworkException
|
|
||||||
import androidx.paging.LoadState
|
|
||||||
import androidx.test.ext.junit.rules.ActivityScenarioRule
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.schabi.newpipe.MainActivity
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.ui.UiModel.UnableToLoadCommentsUiModel
|
|
||||||
import org.schabi.newpipe.viewmodels.util.Resource
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class CommentSectionErrorTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val activityRule = ActivityScenarioRule(MainActivity::class.java)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Resource.Error state - when initial comment info loading fails
|
|
||||||
*/
|
|
||||||
class TestNetworkException : NetworkException("Connection attempt timed out", null) {
|
|
||||||
override fun getErrorCode(): Int = NetworkException.ERROR_CONNECTION_TIMED_OUT
|
|
||||||
override fun isImmediatelyRetryable() = true
|
|
||||||
}
|
|
||||||
@Test
|
|
||||||
fun testResourceErrorState_ShowsUnableToLoadCommentsUiModel() {
|
|
||||||
|
|
||||||
val networkException = TestNetworkException()
|
|
||||||
val errorResource = Resource.Error(networkException)
|
|
||||||
assertEquals("Should contain the network exception", networkException, errorResource.throwable)
|
|
||||||
|
|
||||||
val errorModel = UnableToLoadCommentsUiModel(networkException)
|
|
||||||
val spec = errorModel.spec
|
|
||||||
|
|
||||||
assertEquals("Should have correct message resource", R.string.error_unable_to_load_comments, spec.messageRes)
|
|
||||||
assertTrue("Should show retry button", spec.showRetry)
|
|
||||||
assertTrue("Should show report button", spec.showReport)
|
|
||||||
assertFalse("Should NOT show open in browser button", spec.showOpenInBrowser)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test LoadState.Error state - when paging data loading fails
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun testLoadStateErrorState_ShowsUnableToLoadCommentsUiModel() {
|
|
||||||
val pagingException = RuntimeException("Paging data loading failed")
|
|
||||||
val loadStateError = LoadState.Error(pagingException)
|
|
||||||
|
|
||||||
assertEquals("Should contain the paging exception", pagingException, loadStateError.error)
|
|
||||||
|
|
||||||
val errorModel = UnableToLoadCommentsUiModel(pagingException)
|
|
||||||
val spec = errorModel.spec
|
|
||||||
|
|
||||||
assertEquals("Should have correct message resource", R.string.error_unable_to_load_comments, spec.messageRes)
|
|
||||||
assertTrue("Should show retry button", spec.showRetry)
|
|
||||||
assertTrue("Should show report button", spec.showReport)
|
|
||||||
assertFalse("Should NOT show open in browser button", spec.showOpenInBrowser)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
package org.schabi.newpipe.error
|
|
||||||
|
|
||||||
import org.schabi.newpipe.ui.UiModel.ErrorUiModel
|
|
||||||
import org.schabi.newpipe.ui.UiModel.GenericErrorUiModel
|
|
||||||
import org.schabi.newpipe.ui.UiModel.UnableToLoadCommentsUiModel
|
|
||||||
|
|
||||||
fun mapThrowableToErrorUiModel(throwable: Throwable, userAction: UserAction? = null): ErrorUiModel {
|
|
||||||
if (userAction == UserAction.REQUESTED_COMMENTS) {
|
|
||||||
|
|
||||||
return UnableToLoadCommentsUiModel(rawError = throwable)
|
|
||||||
}
|
|
||||||
// Other ErrorInfo logic and throwable + user actions
|
|
||||||
return GenericErrorUiModel(rawError = throwable)
|
|
||||||
}
|
|
|
@ -17,7 +17,7 @@ class CommentsSource(private val commentInfo: CommentInfo) : PagingSource<Page,
|
||||||
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
|
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
|
||||||
// params.key is null the first time the load() function is called, so we need to return the
|
// params.key is null the first time the load() function is called, so we need to return the
|
||||||
// first batch of already-loaded comments
|
// first batch of already-loaded comments
|
||||||
return LoadResult.Error(IOException("💥 forced test error"))
|
|
||||||
if (params.key == null) {
|
if (params.key == null) {
|
||||||
return LoadResult.Page(commentInfo.comments, null, commentInfo.nextPage)
|
return LoadResult.Page(commentInfo.comments, null, commentInfo.nextPage)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
package org.schabi.newpipe.ui.UiModel
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.ui.components.common.ErrorPanelSpec
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Each concrete case from this hierarchy represents a different failure state that the UI can render with ErrorPanel
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
sealed interface ErrorUiModel {
|
|
||||||
val spec: ErrorPanelSpec
|
|
||||||
val rawError: Throwable? get() = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Concrete cases - Comments unable to load, Comments disabled, No connectivity, DNS failure, timeout etc
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
data class UnableToLoadCommentsUiModel(override val rawError: Throwable?) : ErrorUiModel {
|
|
||||||
override val spec: ErrorPanelSpec =
|
|
||||||
ErrorPanelSpec(
|
|
||||||
messageRes = R.string.error_unable_to_load_comments,
|
|
||||||
showRetry = true,
|
|
||||||
showReport = true,
|
|
||||||
showOpenInBrowser = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A generic ErrorUiModel for unhandled cases
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
data class GenericErrorUiModel(override val rawError: Throwable?) : ErrorUiModel {
|
|
||||||
override val spec: ErrorPanelSpec =
|
|
||||||
ErrorPanelSpec(
|
|
||||||
messageRes = R.string.general_error,
|
|
||||||
showRetry = true,
|
|
||||||
showReport = true,
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,117 +1,132 @@
|
||||||
package org.schabi.newpipe.ui.components.common
|
package org.schabi.newpipe.ui.components.common
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.error.ErrorInfo
|
||||||
|
import org.schabi.newpipe.error.ErrorUtil
|
||||||
|
import org.schabi.newpipe.error.ReCaptchaActivity
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||||
|
import org.schabi.newpipe.extractor.timeago.patterns.it
|
||||||
import org.schabi.newpipe.ui.theme.AppTheme
|
import org.schabi.newpipe.ui.theme.AppTheme
|
||||||
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingExtraLarge
|
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingExtraLarge
|
||||||
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingLarge
|
|
||||||
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingMedium
|
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingMedium
|
||||||
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingSmall
|
import org.schabi.newpipe.ui.theme.SizeTokens.SpacingSmall
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||||
|
|
||||||
|
enum class ErrorAction(@StringRes val actionStringId: Int) {
|
||||||
|
REPORT(R.string.error_snackbar_action),
|
||||||
|
SOLVE_CAPTCHA(R.string.recaptcha_solve)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the error action type based on the throwable in ErrorInfo
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
fun determineErrorAction(errorInfo: ErrorInfo): ErrorAction {
|
||||||
|
return when (errorInfo.throwable) {
|
||||||
|
is ReCaptchaException -> ErrorAction.SOLVE_CAPTCHA
|
||||||
|
is AccountTerminatedException -> ErrorAction.REPORT
|
||||||
|
else -> ErrorAction.REPORT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorPanel(
|
fun ErrorPanel(
|
||||||
spec: ErrorPanelSpec,
|
errorInfo: ErrorInfo,
|
||||||
onRetry: () -> Unit,
|
onRetry: (() -> Unit)? = null,
|
||||||
onReport: () -> Unit,
|
|
||||||
onOpenInBrowser: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
|
|
||||||
) {
|
) {
|
||||||
|
val explanation = errorInfo.getExplanation()
|
||||||
|
val canOpenInBrowser = errorInfo.openInBrowserUrl != null
|
||||||
|
val errorActionType = determineErrorAction(errorInfo)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
|
||||||
.wrapContentWidth()
|
|
||||||
.padding(horizontal = SpacingLarge, vertical = SpacingMedium)
|
|
||||||
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val message = stringResource(spec.messageRes)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = stringResource(errorInfo.messageStringId),
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
spec.serviceInfoRes?.let { infoRes ->
|
if (explanation.isNotBlank()) {
|
||||||
Spacer(Modifier.height(SpacingSmall))
|
Spacer(Modifier.height(SpacingSmall))
|
||||||
val serviceInfo = stringResource(infoRes)
|
|
||||||
Text(
|
Text(
|
||||||
text = serviceInfo,
|
text = explanation,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
spec.serviceExplanationRes?.let { explanationRes ->
|
|
||||||
Spacer(Modifier.height(SpacingSmall))
|
|
||||||
val serviceExplanation = stringResource(explanationRes)
|
|
||||||
Text(
|
|
||||||
text = serviceExplanation,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(SpacingMedium))
|
Spacer(Modifier.height(SpacingMedium))
|
||||||
if (spec.showReport) {
|
when (errorActionType) {
|
||||||
ServiceColoredButton(onClick = onReport) {
|
ErrorAction.REPORT -> {
|
||||||
Text(stringResource(R.string.error_snackbar_action).uppercase())
|
ServiceColoredButton(onClick = {
|
||||||
|
ErrorUtil.openActivity(context, errorInfo)
|
||||||
|
}) {
|
||||||
|
Text(stringResource(errorActionType.actionStringId).uppercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ErrorAction.SOLVE_CAPTCHA -> {
|
||||||
|
ServiceColoredButton(onClick = {
|
||||||
|
// Starting ReCaptcha Challenge Activity
|
||||||
|
val intent = Intent(context, ReCaptchaActivity::class.java).apply {
|
||||||
|
putExtra(
|
||||||
|
ReCaptchaActivity.RECAPTCHA_URL_EXTRA,
|
||||||
|
(errorInfo.throwable as ReCaptchaException).url
|
||||||
|
)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}) {
|
||||||
|
Text(stringResource(errorActionType.actionStringId).uppercase())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (spec.showRetry) {
|
|
||||||
ServiceColoredButton(onClick = onRetry) {
|
onRetry?.let {
|
||||||
|
ServiceColoredButton(onClick = it) {
|
||||||
Text(stringResource(R.string.retry).uppercase())
|
Text(stringResource(R.string.retry).uppercase())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (spec.showOpenInBrowser) {
|
if (canOpenInBrowser) {
|
||||||
ServiceColoredButton(onClick = onOpenInBrowser) {
|
ServiceColoredButton(onClick = {
|
||||||
|
errorInfo.openInBrowserUrl?.let { url ->
|
||||||
|
ShareUtils.openUrlInBrowser(context, url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
Text(stringResource(R.string.open_in_browser).uppercase())
|
Text(stringResource(R.string.open_in_browser).uppercase())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(SpacingExtraLarge))
|
Spacer(Modifier.height(SpacingExtraLarge))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ErrorPanelSpec(
|
|
||||||
@StringRes val messageRes: Int,
|
|
||||||
@StringRes val serviceInfoRes: Int? = null,
|
|
||||||
val serviceExplanation: String? = null,
|
|
||||||
@StringRes val serviceExplanationRes: Int? = null,
|
|
||||||
val showRetry: Boolean = false,
|
|
||||||
val showReport: Boolean = false,
|
|
||||||
val showOpenInBrowser: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
|
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorPanelPreview() {
|
fun ErrorPanelPreview() {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
ErrorPanel(
|
|
||||||
spec = ErrorPanelSpec(
|
|
||||||
messageRes = android.R.string.httpErrorBadUrl,
|
|
||||||
showRetry = true,
|
|
||||||
showReport = false,
|
|
||||||
showOpenInBrowser = false
|
|
||||||
),
|
|
||||||
onRetry = {},
|
|
||||||
onReport = {},
|
|
||||||
onOpenInBrowser = {},
|
|
||||||
modifier = Modifier
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,64 +0,0 @@
|
||||||
package org.schabi.newpipe.ui.components.video.comment
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.SideEffect
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import org.schabi.newpipe.error.ErrorInfo
|
|
||||||
import org.schabi.newpipe.error.UserAction
|
|
||||||
import org.schabi.newpipe.error.mapThrowableToErrorUiModel
|
|
||||||
import org.schabi.newpipe.ui.UiModel.ErrorUiModel
|
|
||||||
import org.schabi.newpipe.ui.components.common.ErrorPanel
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CommentErrorHandler(
|
|
||||||
throwable: Throwable,
|
|
||||||
userAction: UserAction,
|
|
||||||
onRetry: () -> Unit,
|
|
||||||
onReport: (ErrorInfo) -> Unit
|
|
||||||
) {
|
|
||||||
SideEffect {
|
|
||||||
Log.d("CommentErrorHandler", "⛔️ rendered for error: ${throwable.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val uiModel: ErrorUiModel = mapThrowableToErrorUiModel(throwable, userAction)
|
|
||||||
val errorInfo = ErrorInfo(
|
|
||||||
throwable = throwable,
|
|
||||||
userAction = userAction,
|
|
||||||
request = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
ErrorPanel(
|
|
||||||
spec = uiModel.spec,
|
|
||||||
onRetry = onRetry,
|
|
||||||
onReport = { onReport(errorInfo) },
|
|
||||||
onOpenInBrowser = {},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.wrapContentHeight()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun PreviewCommentErrorHandler() {
|
|
||||||
CommentErrorHandler(
|
|
||||||
throwable = IOException("No network"),
|
|
||||||
userAction = UserAction.REQUESTED_COMMENTS,
|
|
||||||
onRetry = {},
|
|
||||||
onReport = {}
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,6 +1,8 @@
|
||||||
package org.schabi.newpipe.ui.components.video.comment
|
package org.schabi.newpipe.ui.components.video.comment
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
@ -11,6 +13,7 @@ import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
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
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
@ -26,11 +29,12 @@ import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.error.ErrorUtil
|
import org.schabi.newpipe.error.ErrorInfo
|
||||||
import org.schabi.newpipe.error.UserAction
|
import org.schabi.newpipe.error.UserAction
|
||||||
import org.schabi.newpipe.extractor.Page
|
import org.schabi.newpipe.extractor.Page
|
||||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
|
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
|
||||||
import org.schabi.newpipe.extractor.stream.Description
|
import org.schabi.newpipe.extractor.stream.Description
|
||||||
|
import org.schabi.newpipe.ui.components.common.ErrorPanel
|
||||||
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
|
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
|
||||||
import org.schabi.newpipe.ui.components.common.LoadingIndicator
|
import org.schabi.newpipe.ui.components.common.LoadingIndicator
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
|
import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
|
||||||
|
@ -78,6 +82,7 @@ private fun CommentSection(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 128.dp)
|
.heightIn(min = 128.dp)
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (count == 0) {
|
} else if (count == 0) {
|
||||||
|
@ -109,13 +114,25 @@ private fun CommentSection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is LoadState.Error -> {
|
is LoadState.Error -> {
|
||||||
|
val error = (comments.loadState.refresh as LoadState.Error).error
|
||||||
|
val errorInfo = ErrorInfo(
|
||||||
|
throwable = error,
|
||||||
|
userAction = UserAction.REQUESTED_COMMENTS,
|
||||||
|
request = "comments"
|
||||||
|
)
|
||||||
|
|
||||||
item {
|
item {
|
||||||
CommentErrorHandler(
|
Box(
|
||||||
throwable = (comments.loadState.refresh as LoadState.Error).error,
|
modifier = Modifier
|
||||||
userAction = UserAction.REQUESTED_COMMENTS,
|
.fillMaxWidth()
|
||||||
onRetry = { comments.retry() },
|
|
||||||
onReport = { info -> ErrorUtil.openActivity(context, info) },
|
) {
|
||||||
)
|
ErrorPanel(
|
||||||
|
errorInfo = errorInfo,
|
||||||
|
onRetry = { comments.retry() },
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
@ -127,13 +144,23 @@ private fun CommentSection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Resource.Error -> {
|
is Resource.Error -> {
|
||||||
|
val errorInfo = ErrorInfo(
|
||||||
|
throwable = uiState.throwable,
|
||||||
|
userAction = UserAction.REQUESTED_COMMENTS,
|
||||||
|
request = "comments"
|
||||||
|
)
|
||||||
item {
|
item {
|
||||||
CommentErrorHandler(
|
Box(
|
||||||
throwable = uiState.throwable,
|
modifier = Modifier
|
||||||
userAction = UserAction.REQUESTED_COMMENTS,
|
.fillMaxSize(),
|
||||||
onRetry = { comments.retry() },
|
contentAlignment = Alignment.Center
|
||||||
onReport = { info -> ErrorUtil.openActivity(context, info) },
|
) {
|
||||||
)
|
ErrorPanel(
|
||||||
|
errorInfo = errorInfo,
|
||||||
|
onRetry = { comments.retry() },
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,6 @@ import org.schabi.newpipe.viewmodels.util.Resource
|
||||||
class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
|
class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
|
||||||
val uiState = savedStateHandle.getStateFlow(KEY_URL, "")
|
val uiState = savedStateHandle.getStateFlow(KEY_URL, "")
|
||||||
.map {
|
.map {
|
||||||
// Resource.Error(RuntimeException("Forced error for testing"))
|
|
||||||
try {
|
try {
|
||||||
Resource.Success(CommentInfo(CommentsInfo.getInfo(it)))
|
Resource.Success(CommentInfo(CommentsInfo.getInfo(it)))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
@ -214,6 +214,14 @@
|
||||||
android:layout_marginTop="@dimen/video_item_detail_error_panel_margin"
|
android:layout_marginTop="@dimen/video_item_detail_error_panel_margin"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
tools:visibility="gone" />
|
tools:visibility="gone" />
|
||||||
|
<androidx.compose.ui.platform.ComposeView
|
||||||
|
android:id="@+id/compose_error_panel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@id/detail_title_root_layout"
|
||||||
|
android:layout_marginTop="@dimen/video_item_detail_error_panel_margin"
|
||||||
|
android:visibility="gone"
|
||||||
|
/>
|
||||||
|
|
||||||
<!--HIDING ROOT-->
|
<!--HIDING ROOT-->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|
|
@ -1,63 +0,0 @@
|
||||||
package org.schabi.newpipe.error
|
|
||||||
|
|
||||||
import android.net.http.NetworkException
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.ui.UiModel.GenericErrorUiModel
|
|
||||||
import org.schabi.newpipe.ui.UiModel.UnableToLoadCommentsUiModel
|
|
||||||
|
|
||||||
class ErrorUiModelTest {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that when comments fail to load, the correct error panel is rendered
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `mapThrowableToErrorUiModel with REQUESTED_COMMENTS returns UnableToLoadCommentsUiModel`() {
|
|
||||||
// val throwable = RuntimeException("Comments failed to load")
|
|
||||||
val networkException = object : NetworkException("Connection attempt timed out", null) {
|
|
||||||
override fun getErrorCode() = NetworkException.ERROR_CONNECTION_TIMED_OUT
|
|
||||||
override fun isImmediatelyRetryable() = true
|
|
||||||
}
|
|
||||||
val result = mapThrowableToErrorUiModel(networkException, UserAction.REQUESTED_COMMENTS)
|
|
||||||
assertTrue("Result should be UnableToLoadCommentsUiModel", result is UnableToLoadCommentsUiModel)
|
|
||||||
assertEquals("Raw error should be preserved for debugging", networkException, result.rawError)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test the fallback logic
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `mapThrowableToErrorUiModel with null UserAction returns GenericErrorUiModel`() {
|
|
||||||
val throwable = RuntimeException("Test error")
|
|
||||||
val result = mapThrowableToErrorUiModel(throwable, null)
|
|
||||||
|
|
||||||
assertTrue("Should return GenericErrorUiModel", result is GenericErrorUiModel)
|
|
||||||
assertEquals("Should preserve the original throwable", throwable, result.rawError)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that UnableToLoadCommentsUiModel maps to the correct error panel configuration
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `UnableToLoadCommentsUiModel has correct ErrorPanelSpec`() {
|
|
||||||
val throwable = RuntimeException("Test error")
|
|
||||||
val errorModel = UnableToLoadCommentsUiModel(throwable)
|
|
||||||
val spec = errorModel.spec
|
|
||||||
// Assert: Verify the spec has the correct configuration for comment loading errors
|
|
||||||
assertEquals(
|
|
||||||
"Error message should be 'Unable to load comments'",
|
|
||||||
R.string.error_unable_to_load_comments,
|
|
||||||
spec.messageRes,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue("Retry button should be shown for comment loading errors", spec.showRetry)
|
|
||||||
assertTrue("Report button should be shown for comment loading errors", spec.showReport)
|
|
||||||
assertFalse("Open in browser should NOT be shown for comment loading errors", spec.showOpenInBrowser)
|
|
||||||
|
|
||||||
// Assert: Verify the raw error is set correctly
|
|
||||||
assertEquals("Raw error should be preserved for debugging", throwable, errorModel.rawError)
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue