1
0
Fork 0
mirror of https://github.com/TeamNewPipe/NewPipe.git synced 2025-10-03 01:39:38 +02:00

Updated ERefactor ErrorPanel to use ErrorInfo directly and remove UI models

This commit is contained in:
Su TT 2025-07-30 17:45:56 -04:00
parent 19fb9899cd
commit 8292f729ea
11 changed files with 237 additions and 316 deletions

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ class CommentsSource(private val commentInfo: CommentInfo) : PagingSource<Page,
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
// first batch of already-loaded comments
return LoadResult.Error(IOException("💥 forced test error"))
if (params.key == null) {
return LoadResult.Page(commentInfo.comments, null, commentInfo.nextPage)
} else {

View file

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

View file

@ -1,117 +1,132 @@
package org.schabi.newpipe.ui.components.common
import android.content.Intent
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
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.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.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
fun ErrorPanel(
spec: ErrorPanelSpec,
onRetry: () -> Unit,
onReport: () -> Unit,
onOpenInBrowser: () -> Unit,
errorInfo: ErrorInfo,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
val explanation = errorInfo.getExplanation()
val canOpenInBrowser = errorInfo.openInBrowserUrl != null
val errorActionType = determineErrorAction(errorInfo)
val context = LocalContext.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.wrapContentWidth()
.padding(horizontal = SpacingLarge, vertical = SpacingMedium)
) {
val message = stringResource(spec.messageRes)
Text(
text = message,
text = stringResource(errorInfo.messageStringId),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center
)
spec.serviceInfoRes?.let { infoRes ->
if (explanation.isNotBlank()) {
Spacer(Modifier.height(SpacingSmall))
val serviceInfo = stringResource(infoRes)
Text(
text = serviceInfo,
text = explanation,
style = MaterialTheme.typography.bodyMedium,
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))
if (spec.showReport) {
ServiceColoredButton(onClick = onReport) {
Text(stringResource(R.string.error_snackbar_action).uppercase())
when (errorActionType) {
ErrorAction.REPORT -> {
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())
}
}
if (spec.showOpenInBrowser) {
ServiceColoredButton(onClick = onOpenInBrowser) {
if (canOpenInBrowser) {
ServiceColoredButton(onClick = {
errorInfo.openInBrowserUrl?.let { url ->
ShareUtils.openUrlInBrowser(context, url)
}
}) {
Text(stringResource(R.string.open_in_browser).uppercase())
}
}
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)
@Composable
fun ErrorPanelPreview() {
AppTheme {
ErrorPanel(
spec = ErrorPanelSpec(
messageRes = android.R.string.httpErrorBadUrl,
showRetry = true,
showReport = false,
showOpenInBrowser = false
),
onRetry = {},
onReport = {},
onOpenInBrowser = {},
modifier = Modifier
)
}
}

View file

@ -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 = {}
)
}

View file

@ -1,6 +1,8 @@
package org.schabi.newpipe.ui.components.video.comment
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.heightIn
import androidx.compose.foundation.layout.padding
@ -11,6 +13,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@ -26,11 +29,12 @@ import androidx.paging.compose.collectAsLazyPagingItems
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
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.extractor.Page
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
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.LoadingIndicator
import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
@ -78,6 +82,7 @@ private fun CommentSection(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp)
)
}
} else if (count == 0) {
@ -109,13 +114,25 @@ private fun CommentSection(
}
}
is LoadState.Error -> {
val error = (comments.loadState.refresh as LoadState.Error).error
val errorInfo = ErrorInfo(
throwable = error,
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
)
item {
CommentErrorHandler(
throwable = (comments.loadState.refresh as LoadState.Error).error,
userAction = UserAction.REQUESTED_COMMENTS,
onRetry = { comments.retry() },
onReport = { info -> ErrorUtil.openActivity(context, info) },
)
Box(
modifier = Modifier
.fillMaxWidth()
) {
ErrorPanel(
errorInfo = errorInfo,
onRetry = { comments.retry() },
modifier = Modifier.align(Alignment.Center)
)
}
}
}
else -> {
@ -127,13 +144,23 @@ private fun CommentSection(
}
}
is Resource.Error -> {
val errorInfo = ErrorInfo(
throwable = uiState.throwable,
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
)
item {
CommentErrorHandler(
throwable = uiState.throwable,
userAction = UserAction.REQUESTED_COMMENTS,
onRetry = { comments.retry() },
onReport = { info -> ErrorUtil.openActivity(context, info) },
)
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
ErrorPanel(
errorInfo = errorInfo,
onRetry = { comments.retry() },
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}

View file

@ -23,7 +23,6 @@ import org.schabi.newpipe.viewmodels.util.Resource
class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val uiState = savedStateHandle.getStateFlow(KEY_URL, "")
.map {
// Resource.Error(RuntimeException("Forced error for testing"))
try {
Resource.Success(CommentInfo(CommentsInfo.getInfo(it)))
} catch (e: Exception) {

View file

@ -214,6 +214,14 @@
android:layout_marginTop="@dimen/video_item_detail_error_panel_margin"
android: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-->
<LinearLayout

View file

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