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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,3 +19,6 @@ app/release/
|
||||
bin/
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
@@ -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() {
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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()
|
||||
}
|
||||
}
|
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
)
|
||||
}
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
@@ -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")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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));
|
||||
|
@@ -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);
|
||||
|
@@ -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"
|
||||
|
@@ -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" }
|
||||
|
Reference in New Issue
Block a user