diff --git a/.gitignore b/.gitignore index 7bccc3132..a5219720c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ app/release/ bin/ .vscode/ *.code-workspace + +# logs +*.log diff --git a/app/build.gradle b/app/build.gradle index ec7bc3776..59ca2b6a8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -342,6 +342,10 @@ dependencies { androidTestImplementation libs.androidx.runner androidTestImplementation libs.androidx.room.testing androidTestImplementation libs.assertj.core + androidTestImplementation platform(libs.androidx.compose.bom) + androidTestImplementation libs.androidx.compose.ui.test.junit4 + debugImplementation libs.androidx.compose.ui.test.manifest + } static String getGitWorkingBranch() { diff --git a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java deleted file mode 100644 index 892d1df0f..000000000 --- a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.schabi.newpipe.error; - -import android.os.Parcel; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.LargeTest; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.exceptions.ParsingException; - -import java.util.Arrays; -import java.util.Objects; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -/** - * Instrumented tests for {@link ErrorInfo}. - */ -@RunWith(AndroidJUnit4.class) -@LargeTest -public class ErrorInfoTest { - - /** - * @param errorInfo the error info to access - * @return the private field errorInfo.message.stringRes using reflection - */ - private int getMessageFromErrorInfo(final ErrorInfo errorInfo) - throws NoSuchFieldException, IllegalAccessException { - final var message = ErrorInfo.class.getDeclaredField("message"); - message.setAccessible(true); - final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo); - - final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes"); - stringRes.setAccessible(true); - return (int) Objects.requireNonNull(stringRes.get(messageValue)); - } - - @Test - public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException { - final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"), - UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId()); - // Obtain a Parcel object and write the parcelable object to it: - final Parcel parcel = Parcel.obtain(); - info.writeToParcel(parcel, 0); - parcel.setDataPosition(0); - final ErrorInfo infoFromParcel = (ErrorInfo) ErrorInfo.CREATOR.createFromParcel(parcel); - - assertTrue(Arrays.toString(infoFromParcel.getStackTraces()) - .contains(ErrorInfoTest.class.getSimpleName())); - assertEquals(UserAction.USER_REPORT, infoFromParcel.getUserAction()); - assertEquals(ServiceList.YouTube.getServiceInfo().getName(), - infoFromParcel.getServiceName()); - assertEquals("request", infoFromParcel.getRequest()); - assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel)); - - parcel.recycle(); - } -} diff --git a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.kt b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.kt new file mode 100644 index 000000000..2dee463ef --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.kt @@ -0,0 +1,128 @@ +package org.schabi.newpipe.error + +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.exceptions.ParsingException +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import java.io.IOException +import java.net.SocketTimeoutException + +/** + * Instrumented tests for {@link ErrorInfo}. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class ErrorInfoTest { + private val context: Context by lazy { ApplicationProvider.getApplicationContext() } + + /** + * @param errorInfo the error info to access + * @return the private field errorInfo.message.stringRes using reflection + */ + @Throws(NoSuchFieldException::class, IllegalAccessException::class) + private fun getMessageFromErrorInfo(errorInfo: ErrorInfo): Int { + val message = ErrorInfo::class.java.getDeclaredField("message") + message.isAccessible = true + val messageValue = message.get(errorInfo) as ErrorInfo.Companion.ErrorMessage + + val stringRes = ErrorInfo.Companion.ErrorMessage::class.java.getDeclaredField("stringRes") + stringRes.isAccessible = true + return stringRes.get(messageValue) as Int + } + + @Test + @Throws(NoSuchFieldException::class, IllegalAccessException::class) + fun errorInfoTestParcelable() { + val info = ErrorInfo( + ParsingException("Hello"), + UserAction.USER_REPORT, + "request", + ServiceList.YouTube.serviceId + ) + // Obtain a Parcel object and write the parcelable object to it: + val parcel = Parcel.obtain() + info.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + val creatorField = ErrorInfo::class.java.getDeclaredField("CREATOR") + val creator = creatorField.get(null) + check(creator is Parcelable.Creator<*>) + val infoFromParcel = requireNotNull( + creator.createFromParcel(parcel) as? ErrorInfo + ) + assertTrue( + infoFromParcel.stackTraces.contentToString() + .contains(ErrorInfoTest::class.java.simpleName) + ) + assertEquals(UserAction.USER_REPORT, infoFromParcel.userAction) + assertEquals( + ServiceList.YouTube.serviceInfo.name, + infoFromParcel.getServiceName() + ) + assertEquals("request", infoFromParcel.request) + assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel)) + + parcel.recycle() + } + + /** + * Test: Network error on initial load (Resource.Error) + */ + + @Test + fun testInitialCommentNetworkError() { + val errorInfo = ErrorInfo( + throwable = SocketTimeoutException("Connection timeout"), + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) + assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context)) + assertTrue(errorInfo.isReportable) + assertTrue(errorInfo.isRetryable) + assertNull(errorInfo.recaptchaUrl) + } + + /** + * Test: Network error on paging (LoadState.Error) + */ + @Test + fun testPagingNetworkError() { + val errorInfo = ErrorInfo( + throwable = IOException("Paging failed"), + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) + assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context)) + assertTrue(errorInfo.isReportable) + assertTrue(errorInfo.isRetryable) + assertNull(errorInfo.recaptchaUrl) + } + + /** + * Test: ReCaptcha during comments load + */ + @Test + fun testReCaptchaDuringComments() { + val url = "https://www.google.com/recaptcha/api/fallback?k=test" + val errorInfo = ErrorInfo( + throwable = ReCaptchaException("ReCaptcha needed", url), + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) + assertEquals(context.getString(R.string.recaptcha_request_toast), errorInfo.getMessage(context)) + assertEquals(url, errorInfo.recaptchaUrl) + assertFalse(errorInfo.isReportable) + assertTrue(errorInfo.isRetryable) + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/common/ErrorPanelTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/common/ErrorPanelTest.kt new file mode 100644 index 000000000..a6627ee66 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/common/ErrorPanelTest.kt @@ -0,0 +1,126 @@ +package org.schabi.newpipe.ui.components.common + +import androidx.activity.ComponentActivity +import androidx.annotation.StringRes +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import org.schabi.newpipe.ui.theme.AppTheme +import java.net.UnknownHostException + +@RunWith(AndroidJUnit4::class) +class ErrorPanelTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun setErrorPanel(errorInfo: ErrorInfo, onRetry: (() -> Unit)? = null) { + composeRule.setContent { + AppTheme { + ErrorPanel(errorInfo = errorInfo, onRetry = onRetry) + } + } + } + private fun text(@StringRes id: Int) = composeRule.activity.getString(id) + + /** + * Test Network Error + */ + @Test + fun testNetworkErrorShowsRetryWithoutReportButton() { + val networkErrorInfo = ErrorInfo( + throwable = UnknownHostException("offline"), + userAction = UserAction.REQUESTED_STREAM, + request = "https://example.com/watch?v=foo" + ) + + setErrorPanel(networkErrorInfo, onRetry = {}) + composeRule.onNodeWithText(text(R.string.network_error)).assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + .assertDoesNotExist() + composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true) + .assertDoesNotExist() + } + + /** + * Test Unexpected Error, Shows Report and Retry buttons + */ + @Test + fun unexpectedErrorShowsReportAndRetryButtons() { + val unexpectedErrorInfo = ErrorInfo( + throwable = RuntimeException("Unexpected error"), + userAction = UserAction.REQUESTED_STREAM, + request = "https://example.com/watch?v=bar" + ) + + setErrorPanel(unexpectedErrorInfo, onRetry = {}) + composeRule.onNodeWithText(text(R.string.error_snackbar_message)).assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + .assertIsDisplayed() + } + + /** + * Test Recaptcha Error shows solve, retry and open in browser buttons + */ + @Test + fun recaptchaErrorShowsSolveAndRetryOpenInBrowserButtons() { + var retryClicked = false + val recaptchaErrorInfo = ErrorInfo( + throwable = ReCaptchaException( + "Recaptcha required", + "https://example.com/captcha" + ), + userAction = UserAction.REQUESTED_STREAM, + request = "https://example.com/watch?v=baz", + openInBrowserUrl = "https://example.com/watch?v=baz" + ) + + setErrorPanel( + errorInfo = recaptchaErrorInfo, + onRetry = { retryClicked = true } + + ) + composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true) + .assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true) + .assertIsDisplayed() + .performClick() + composeRule.onNodeWithText(text(R.string.open_in_browser), ignoreCase = true) + .assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + .assertDoesNotExist() + assert(retryClicked) { "onRetry callback should have been invoked" } + } + + /** + * Test Content Not Available Error hides retry button + */ + @Test + fun testNonRetryableErrorHidesRetryAndReportButtons() { + val contentNotAvailable = ErrorInfo( + throwable = ContentNotAvailableException("Video has been removed"), + userAction = UserAction.REQUESTED_STREAM, + request = "https://example.com/watch?v=qux" + ) + + setErrorPanel(contentNotAvailable) + + composeRule.onNodeWithText(text(R.string.content_not_available)) + .assertIsDisplayed() + composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true) + .assertDoesNotExist() + composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + .assertDoesNotExist() + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/video/comment/CommentSectionInstrumentedTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/video/comment/CommentSectionInstrumentedTest.kt new file mode 100644 index 000000000..eed80ead4 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/video/comment/CommentSectionInstrumentedTest.kt @@ -0,0 +1,358 @@ +package org.schabi.newpipe.ui.components.video.comment + +import androidx.annotation.StringRes +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 +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.rememberNestedScrollInteropConnection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.schabi.newpipe.R +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.exceptions.ReCaptchaException +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 +import org.schabi.newpipe.ui.emptystate.EmptyStateSpec +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.viewmodels.util.Resource +import java.net.UnknownHostException + +class CommentSectionInstrumentedTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val uiStateFlow = MutableStateFlow>(Resource.Loading) + private val pagingFlow = MutableStateFlow(PagingData.empty()) + private fun string(@StringRes resId: Int) = composeRule.activity.getString(resId) + + @Before + fun setUp() { + composeRule.setContent { + AppTheme { + TestCommentSection(uiStateFlow = uiStateFlow, commentsFlow = pagingFlow) + } + } + } + + private fun successState(commentCount: Int) = Resource.Success( + CommentInfo( + serviceId = 0, + url = "", + comments = emptyList(), + nextPage = null, + commentCount = commentCount, + isCommentsDisabled = false + ) + ) + + @Test + fun commentListLoadsAndScrolls() { + val comments = (1..25).map { index -> + CommentsInfoItem( + commentText = Description("Comment $index", Description.PLAIN_TEXT), + uploaderName = "Uploader $index", + replies = Page(""), + replyCount = 0 + ) + } + uiStateFlow.value = successState(comments.size) + pagingFlow.value = PagingData.from(comments) + composeRule.waitForIdle() + composeRule.onNodeWithText("Comment 1").assertIsDisplayed() + composeRule.onNodeWithTag("comment_list") + .performScrollToNode(hasText("Comment 25")) + composeRule.onNodeWithText("Comment 25").assertIsDisplayed() + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun pagingErrorShowsErrorPanelAndAllowsRetry() { + uiStateFlow.value = successState(10) + pagingFlow.value = PagingData.from( + data = emptyList(), + sourceLoadStates = LoadStates( + refresh = LoadState.Error(ReCaptchaException("captcha required", "https://example.com")), + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true) + ) + ) + composeRule.waitForIdle() + + val solveMatcher = hasText(string(R.string.recaptcha_solve), ignoreCase = true) + .and(hasClickAction()) + val retryMatcher = hasText(string(R.string.retry), ignoreCase = true) + .and(hasClickAction()) + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(solveMatcher).fetchSemanticsNodes().isNotEmpty() + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty() + } + + composeRule.onNode(retryMatcher) + .performScrollTo() + .performClick() + + val recoveredComment = CommentsInfoItem( + commentText = Description("Recovered comment", Description.PLAIN_TEXT), + uploaderName = "Uploader", + replies = Page(""), + replyCount = 0 + ) + + uiStateFlow.value = successState(1) + pagingFlow.value = PagingData.from( + data = listOf(recoveredComment), + sourceLoadStates = LoadStates( + refresh = LoadState.NotLoading(false), + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true) + ) + ) + composeRule.waitForIdle() + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(hasText("Recovered comment")) + .fetchSemanticsNodes() + .isNotEmpty() + } + composeRule.onNodeWithText("Recovered comment").assertIsDisplayed() + + composeRule.onNode(solveMatcher).assertDoesNotExist() + composeRule.onNode(retryMatcher).assertDoesNotExist() + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun resourceErrorShowsErrorPanelAndRetry() { + uiStateFlow.value = Resource.Error(UnknownHostException("offline")) + composeRule.waitForIdle() + + composeRule.onNodeWithText(string(R.string.network_error)).assertIsDisplayed() + val retryMatcher = hasText(string(R.string.retry), ignoreCase = true) + .and(hasClickAction()) + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty() + } + + composeRule.onNode(retryMatcher) + .performScrollTo() + .performClick() + + val recoveredComment = CommentsInfoItem( + commentText = Description("Recovered comment", Description.PLAIN_TEXT), + uploaderName = "Uploader", + replies = Page(""), + replyCount = 0 + ) + + uiStateFlow.value = successState(1) + pagingFlow.value = PagingData.from( + data = listOf(recoveredComment), + sourceLoadStates = LoadStates( + refresh = LoadState.NotLoading(false), + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true) + ) + ) + composeRule.waitForIdle() + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(hasText("Recovered comment")) + .fetchSemanticsNodes() + .isNotEmpty() + } + composeRule.onNodeWithText("Recovered comment").assertIsDisplayed() + + composeRule.onNodeWithText(string(R.string.network_error)) + .assertDoesNotExist() + composeRule.onNode(retryMatcher).assertDoesNotExist() + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun retryAfterErrorRecoversList() { + uiStateFlow.value = Resource.Error(RuntimeException("boom")) + composeRule.waitForIdle() + + val retryMatcher = hasText(string(R.string.retry), ignoreCase = true) + .and(hasClickAction()) + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNode(retryMatcher) + .performScrollTo() + .performClick() + + val firstComment = CommentsInfoItem( + commentText = Description("First comment", Description.PLAIN_TEXT), + uploaderName = "Uploader", + replies = Page(""), + replyCount = 0 + ) + + uiStateFlow.value = successState(1) + pagingFlow.value = PagingData.from( + data = listOf(firstComment), + sourceLoadStates = LoadStates( + refresh = LoadState.NotLoading(false), + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true) + ) + ) + composeRule.waitForIdle() + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodes(hasText("First comment")) + .fetchSemanticsNodes() + .isNotEmpty() + } + composeRule.onNodeWithText("First comment").assertIsDisplayed() + + composeRule.onNodeWithText(string(R.string.network_error)) + .assertDoesNotExist() + composeRule.onNode(retryMatcher).assertDoesNotExist() + } +} + +@Composable +private fun TestCommentSection( + uiStateFlow: StateFlow>, + commentsFlow: Flow> +) { + val uiState by uiStateFlow.collectAsState() + val comments = commentsFlow.collectAsLazyPagingItems() + val nestedScrollInterop = rememberNestedScrollInteropConnection() + val listState = rememberLazyListState() + val COMMENT_LIST_TAG = "comment_list" + + LazyColumnThemedScrollbar(state = listState) { + LazyColumn( + modifier = Modifier + .testTag(COMMENT_LIST_TAG) + .nestedScroll(nestedScrollInterop), + state = listState + ) { + when (uiState) { + is Resource.Loading -> item { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } + is Resource.Success -> { + val commentInfo = (uiState as Resource.Success).data + val count = commentInfo.commentCount + + when { + commentInfo.isCommentsDisabled -> item { + EmptyStateComposable( + spec = EmptyStateSpec.DisabledComments, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp) + ) + } + count == 0 -> item { + EmptyStateComposable( + spec = EmptyStateSpec.NoComments, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 128.dp) + ) + } + else -> { + if (count >= 0) { + item { + Text( + modifier = Modifier + .padding(start = 12.dp, end = 12.dp, bottom = 4.dp), + text = pluralStringResource(R.plurals.comments, count, count), + maxLines = 1, + style = MaterialTheme.typography.titleMedium + ) + } + } + when (val refresh = comments.loadState.refresh) { + is LoadState.Loading -> item { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } + is LoadState.Error -> item { + Box( + modifier = Modifier.fillMaxWidth() + ) { + ErrorPanel( + errorInfo = ErrorInfo( + throwable = refresh.error, + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ), + onRetry = { comments.retry() }, + modifier = Modifier.align(Alignment.Center) + ) + } + } + else -> items(comments.itemCount) { index -> + Comment(comment = comments[index]!!) {} + } + } + } + } + } + is Resource.Error -> item { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorPanel( + errorInfo = ErrorInfo( + throwable = (uiState as Resource.Error).throwable, + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ), + onRetry = { comments.retry() }, + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt index 609fbb336..45ab8daa0 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt @@ -3,7 +3,6 @@ package org.schabi.newpipe.error import android.content.Context import android.os.Parcelable import androidx.annotation.StringRes -import androidx.core.content.ContextCompat import com.google.android.exoplayer2.ExoPlaybackException import com.google.android.exoplayer2.upstream.HttpDataSource import com.google.android.exoplayer2.upstream.Loader @@ -28,6 +27,7 @@ import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentExcepti import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.player.mediasource.FailedMediaSource import org.schabi.newpipe.player.resolver.PlaybackResolver +import org.schabi.newpipe.util.Localization import java.net.UnknownHostException /** @@ -147,13 +147,11 @@ class ErrorInfo private constructor( private vararg val formatArgs: String, ) : Parcelable { fun getString(context: Context): String { + // use Localization.compatGetString() just in case context is not AppCompatActivity return if (formatArgs.isEmpty()) { - // use ContextCompat.getString() just in case context is not AppCompatActivity - ContextCompat.getString(context, stringRes) + Localization.compatGetString(context, stringRes) } else { - // ContextCompat.getString() with formatArgs does not exist, so we just - // replicate its source code but with formatArgs - ContextCompat.getContextForLanguage(context).getString(stringRes, *formatArgs) + Localization.compatGetString(context, stringRes, *formatArgs) } } } @@ -276,6 +274,9 @@ class ErrorInfo private constructor( // we don't have an exception, so this is a manually built error, which likely // indicates that it's important and is thus reportable null -> true + // a recaptcha was detected, and the user needs to solve it, there is no use in + // letting users report it + is ReCaptchaException -> false // the service explicitly said that content is not available (e.g. age restrictions, // video deleted, etc.), there is no use in letting users report it is ContentNotAvailableException -> false diff --git a/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt b/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt index 669485e66..20d67a283 100644 --- a/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt +++ b/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt @@ -16,12 +16,14 @@ class CommentsSource(private val commentInfo: CommentInfo) : PagingSource): LoadResult { // params.key is null the first time the load() function is called, so we need to return the // first batch of already-loaded comments + if (params.key == null) { return LoadResult.Page(commentInfo.comments, null, commentInfo.nextPage) } else { val info = withContext(Dispatchers.IO) { CommentsInfo.getMoreItems(service, commentInfo.url, params.key) } + return LoadResult.Page(info.items, null, info.nextPage) } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt index 4815965a3..fbd506456 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt @@ -6,7 +6,6 @@ import android.os.Bundle import android.os.ResultReceiver import android.support.v4.media.session.PlaybackStateCompat import android.util.Log -import androidx.core.content.ContextCompat import androidx.core.net.toUri import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer @@ -29,6 +28,7 @@ import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.util.ChannelTabHelper import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NavigationHelper import java.util.function.BiConsumer import java.util.function.Consumer @@ -111,7 +111,7 @@ class MediaBrowserPlaybackPreparer( //region Errors private fun onUnsupportedError() { setMediaSessionError.accept( - ContextCompat.getString(context, R.string.content_not_supported), + Localization.compatGetString(context, R.string.content_not_supported), PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED ) } diff --git a/app/src/main/java/org/schabi/newpipe/settings/viewmodel/SettingsViewModel.kt b/app/src/main/java/org/schabi/newpipe/settings/viewmodel/SettingsViewModel.kt index ae3520c94..1e48fef5e 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/viewmodel/SettingsViewModel.kt @@ -3,13 +3,13 @@ package org.schabi.newpipe.settings.viewmodel import android.app.Application import android.content.Context import android.content.SharedPreferences -import androidx.core.content.ContextCompat import androidx.lifecycle.AndroidViewModel import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import org.schabi.newpipe.R +import org.schabi.newpipe.util.Localization import javax.inject.Inject @HiltViewModel @@ -20,11 +20,12 @@ class SettingsViewModel @Inject constructor( private var _settingsLayoutRedesignPref: Boolean get() = preferenceManager.getBoolean( - ContextCompat.getString(getApplication(), R.string.settings_layout_redesign_key), false + Localization.compatGetString(getApplication(), R.string.settings_layout_redesign_key), + false ) set(value) { preferenceManager.edit().putBoolean( - ContextCompat.getString(getApplication(), R.string.settings_layout_redesign_key), + Localization.compatGetString(getApplication(), R.string.settings_layout_redesign_key), value ).apply() } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/ErrorPanel.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/ErrorPanel.kt new file mode 100644 index 000000000..666d1759d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/ErrorPanel.kt @@ -0,0 +1,105 @@ +package org.schabi.newpipe.ui.components.common + +import android.content.Intent +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.platform.LocalInspectionMode +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 androidx.compose.ui.unit.dp +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.error.UserAction +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.external_communication.ShareUtils + +@Composable +fun ErrorPanel( + errorInfo: ErrorInfo, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + val context = LocalContext.current + val isPreview = LocalInspectionMode.current + val messageText = if (isPreview) { + stringResource(R.string.error_snackbar_message) + } else { + errorInfo.getMessage(context) + } + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + Text( + text = messageText, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center + ) + + if (errorInfo.recaptchaUrl != null) { + ServiceColoredButton(onClick = { + // Starting ReCaptcha Challenge Activity + val intent = Intent(context, ReCaptchaActivity::class.java) + .putExtra( + ReCaptchaActivity.RECAPTCHA_URL_EXTRA, + errorInfo.recaptchaUrl + ) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + }) { + Text(stringResource(R.string.recaptcha_solve).uppercase()) + } + } + + if (errorInfo.isRetryable) { + onRetry?.let { + ServiceColoredButton(onClick = it) { + Text(stringResource(R.string.retry).uppercase()) + } + } + } + + if (errorInfo.isReportable) { + ServiceColoredButton(onClick = { ErrorUtil.openActivity(context, errorInfo) }) { + Text(stringResource(R.string.error_snackbar_action).uppercase()) + } + } + + errorInfo.openInBrowserUrl?.let { url -> + ServiceColoredButton(onClick = { ShareUtils.openUrlInBrowser(context, url) }) { + Text(stringResource(R.string.open_in_browser).uppercase()) + } + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ErrorPanelPreview() { + AppTheme { + ErrorPanel( + errorInfo = ErrorInfo( + throwable = ReCaptchaException("An error", "https://example.com"), + userAction = UserAction.REQUESTED_STREAM, + request = "Preview request", + openInBrowserUrl = "https://example.com", + ), + onRetry = {}, + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/ServiceColoredButton.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/ServiceColoredButton.kt new file mode 100644 index 000000000..59a37066c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/ServiceColoredButton.kt @@ -0,0 +1,56 @@ +package org.schabi.newpipe.ui.components.common + +import android.content.res.Configuration +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.ui.theme.SizeTokens.SpacingMedium +import org.schabi.newpipe.ui.theme.SizeTokens.SpacingSmall + +@Composable +fun ServiceColoredButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable() RowScope.() -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier.wrapContentWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ), + contentPadding = PaddingValues(horizontal = SpacingMedium, vertical = SpacingSmall), + shape = RectangleShape, + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 8.dp, + + ), + ) { + content() + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ServiceColoredButtonPreview() { + AppTheme { + ServiceColoredButton( + onClick = {}, + content = { + Text("Button") + } + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt index a33ffc0ca..f0a293784 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt @@ -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.rememberNestedScrollInteropConnection @@ -25,9 +28,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.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 @@ -74,6 +80,7 @@ private fun CommentSection( modifier = Modifier .fillMaxWidth() .heightIn(min = 128.dp) + ) } } else if (count == 0) { @@ -98,21 +105,32 @@ private fun CommentSection( ) } } - - when (comments.loadState.refresh) { + when (val refresh = comments.loadState.refresh) { is LoadState.Loading -> { item { LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) } } - is LoadState.Error -> { + val errorInfo = ErrorInfo( + throwable = refresh.error, + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) + item { - // TODO use error panel instead - EmptyStateComposable(EmptyStateSpec.ErrorLoadingComments) + Box( + modifier = Modifier + .fillMaxWidth() + ) { + ErrorPanel( + errorInfo = errorInfo, + onRetry = { comments.retry() }, + modifier = Modifier.align(Alignment.Center) + ) + } } } - else -> { items(comments.itemCount) { Comment(comment = comments[it]!!) {} @@ -121,16 +139,24 @@ private fun CommentSection( } } } - is Resource.Error -> { + val errorInfo = ErrorInfo( + throwable = uiState.throwable, + userAction = UserAction.REQUESTED_COMMENTS, + request = "comments" + ) item { - // TODO use error panel instead - EmptyStateComposable( - spec = EmptyStateSpec.ErrorLoadingComments, + Box( modifier = Modifier - .fillMaxWidth() - .heightIn(min = 128.dp) - ) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorPanel( + errorInfo = errorInfo, + onRetry = { comments.retry() }, + modifier = Modifier.align(Alignment.Center) + ) + } } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index 890981e90..f5bcc40d3 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -18,6 +18,7 @@ import androidx.annotation.Nullable; import androidx.annotation.PluralsRes; import androidx.annotation.StringRes; import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.content.ContextCompat; import androidx.core.math.MathUtils; import androidx.core.os.LocaleListCompat; import androidx.preference.PreferenceManager; @@ -71,6 +72,46 @@ public final class Localization { private Localization() { } + /** + * Gets a string like you would normally do with {@link Context#getString}, except that when + * Context is not an AppCompatActivity the correct locale is still used. The latter step uses + * {@link ContextCompat#getString}, which might fail if the Locale system service is not + * available (e.g. inside of Compose previews). In that case this method falls back to plain old + * {@link Context#getString}. + *

This method also supports format args (see {@link #compatGetString(Context, int, + * Object...)}, unlike {@link ContextCompat#getString}.

+ * + * @param context any Android context, even the App context + * @param resId the string resource to resolve + * @return the resolved string + */ + public static String compatGetString(final Context context, @StringRes final int resId) { + try { + return ContextCompat.getString(context, resId); + } catch (final Throwable e) { + return context.getString(resId); + } + } + + /** + * @see #compatGetString(Context, int) + * @param context any Android context, even the App context + * @param resId the string resource to resolve + * @param formatArgs the formatting arguments + * @return the resolved string + */ + public static String compatGetString(final Context context, + @StringRes final int resId, + final Object... formatArgs) { + try { + // ContextCompat.getString() with formatArgs does not exist, so we just + // replicate its source code but with formatArgs + return ContextCompat.getContextForLanguage(context).getString(resId, formatArgs); + } catch (final Throwable e) { + return context.getString(resId, formatArgs); + } + } + @NonNull public static String concatenateStrings(final String... strings) { return concatenateStrings(DOT_SEPARATOR, Arrays.asList(strings)); diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index cc260d254..0a7906b8d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -641,10 +641,9 @@ public final class NavigationHelper { public static void openSettings(final Context context) { final Class settingsClass = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean( - ContextCompat.getString(context, R.string.settings_layout_redesign_key), - false - ) ? SettingsV2Activity.class : SettingsActivity.class; + .getBoolean(Localization.compatGetString(context, + R.string.settings_layout_redesign_key), false) + ? SettingsV2Activity.class : SettingsActivity.class; final Intent intent = new Intent(context, settingsClass); context.startActivity(intent); diff --git a/app/src/main/res/layout/fragment_video_detail.xml b/app/src/main/res/layout/fragment_video_detail.xml index 1a4711581..abf1509b1 100644 --- a/app/src/main/res/layout/fragment_video_detail.xml +++ b/app/src/main/res/layout/fragment_video_detail.xml @@ -214,7 +214,6 @@ android:layout_marginTop="@dimen/video_item_detail_error_panel_margin" android:visibility="gone" tools:visibility="gone" /> -