1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2025-10-12 14:17:38 +00:00

Merge pull request #12404 from SttApollo/Create_CommentSection_ErrorPanel

Implement Compose-based Error Panel, Error UI Model, and Tests for Comments
This commit is contained in:
Aayush Gupta
2025-10-07 09:14:58 +08:00
committed by GitHub
17 changed files with 880 additions and 91 deletions

3
.gitignore vendored
View File

@@ -19,3 +19,6 @@ app/release/
bin/
.vscode/
*.code-workspace
# logs
*.log

View File

@@ -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() {

View File

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

View File

@@ -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<Context>() }
/**
* @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)
}
}

View File

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

View File

@@ -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<androidx.activity.ComponentActivity>()
private val uiStateFlow = MutableStateFlow<Resource<CommentInfo>>(Resource.Loading)
private val pagingFlow = MutableStateFlow(PagingData.empty<CommentsInfoItem>())
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<Resource<CommentInfo>>,
commentsFlow: Flow<PagingData<CommentsInfoItem>>
) {
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<CommentInfo>).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)
)
}
}
}
}
}
}

View File

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

View File

@@ -16,12 +16,14 @@ 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
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)
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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}.
* <p>This method also supports format args (see {@link #compatGetString(Context, int,
* Object...)}, unlike {@link ContextCompat#getString}.</p>
*
* @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));

View File

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

View File

@@ -214,7 +214,6 @@
android:layout_marginTop="@dimen/video_item_detail_error_panel_margin"
android:visibility="gone"
tools:visibility="gone" />
<!--HIDING ROOT-->
<LinearLayout
android:id="@+id/detail_content_root_hiding"

View File

@@ -88,6 +88,8 @@ androidx-compose-adaptive = { group = "androidx.compose.material3.adaptive", nam
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "jetpack-compose" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-text = { group = "androidx.compose.ui", name = "ui-text" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }