Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
e276d70
Add Either type with left, right and match functions
Stypox Jan 28, 2025
64c5aa2
Start implementing LongPressMenu
Stypox Jan 28, 2025
6ad16fc
Calculate button placing in long press menu
Stypox Jan 28, 2025
4fe7b83
Treat decorations better
Stypox Jan 28, 2025
a7701c4
Add buttons to long press menu
Stypox Jan 28, 2025
155fa05
Remove button background and make text 2 lines
Stypox Jan 28, 2025
d4eb403
Add small Edit button in LongPressMenu
Stypox Jan 31, 2025
8e454dc
Click on long press menu subtitle opens channel details
Stypox Feb 11, 2025
9abbb7b
Initial work on handling many long-press actions
Stypox Feb 11, 2025
719f8a5
Make LongPressable a data class
Stypox Feb 11, 2025
4581f20
Move long press menu drag handle to its own composable
Stypox Feb 11, 2025
b727834
Improve how long press menu buttons are laid out
Stypox Feb 11, 2025
ee0f98f
Dismiss long press menu after click on a button
Stypox Feb 11, 2025
b213a46
Add download long press menu action
Stypox Feb 11, 2025
eca3486
Move LongPressable builders to LongPressable class
Stypox Feb 11, 2025
985872f
Replace InfoItemDialog with LongPressMenu
Stypox Feb 12, 2025
bfbde93
Remove InfoItemDialog
Stypox Feb 12, 2025
3eb42d6
Slight adjustments to long press menu
Stypox Feb 12, 2025
e0b24c7
Add more previews to LongPressMenu
Stypox Feb 13, 2025
1bb298b
Use LongPressMenu in BookmarkFragment
Stypox Feb 14, 2025
612122c
Tune transparencies of decorations in long press menu
Stypox Feb 14, 2025
a189337
Use faded marquee text in long press menu header
Stypox Feb 14, 2025
1d16885
Add long press actions to channels and playlists info items
Stypox Mar 14, 2025
4c60498
Add OpenInNew icon next to channel name
Stypox Aug 17, 2025
0347fd9
Fix some lints
Stypox Aug 27, 2025
b50db47
Add icons for play/background/popup from here
Stypox Aug 28, 2025
4b90295
Uniform localizing view counts
Stypox Aug 28, 2025
6777871
Implement background/popup/play from here
Stypox Aug 28, 2025
91d5e48
Implement "play from here" for feed fragment
Stypox Aug 28, 2025
89fa03a
Implement "play from here" for channels
Stypox Aug 28, 2025
89dcb6f
Improve icons for background/popup/play from here
Stypox Aug 29, 2025
9f4730e
Implement long pressing on subscriptions
Stypox Aug 29, 2025
701e8c5
Consider duration 0 as duration not known
Stypox Oct 19, 2025
162c9ce
Address Isira review comment
Stypox Oct 19, 2025
23c2de7
Extract FixedHeightCenteredText from LongPressMenu
Stypox Oct 21, 2025
9e1c340
Implement LongPressMenuEditor UI (still not persisted)
Stypox Oct 21, 2025
a3af6e2
Add Back content description to toolbar back buttons
Stypox Oct 21, 2025
0cc6334
Access editor from long press menu + fix scrolling
Stypox Oct 21, 2025
6396c97
Rewrite LongPressMenuEditor logic
Stypox Oct 22, 2025
e350b10
Make LongPressMenuEditor work with DPAD / Android TV
Stypox Oct 23, 2025
f0c3248
Fix an edge case on the DragMarker position logic
Stypox Oct 23, 2025
444aba2
Handle scrolling on Android TV
Stypox Dec 24, 2025
3d6c37a
Autoscroll when dragging close to border
Stypox Dec 24, 2025
032a853
Fix long press menu on DPAD clicks onEditActions right after opened
Stypox Dec 24, 2025
f2a1a63
DetectDragModifier now detects long-presses
Stypox Dec 30, 2025
35401e7
Distinguish between isDraggable and isCaption
Stypox Dec 30, 2025
44dc35a
Make channel link less attractive
Stypox Dec 30, 2025
b948548
Tune long press menu UI
Stypox Dec 30, 2025
cf4bfa5
Make it clearer when items are being dragged under the finger
Stypox Dec 30, 2025
4d8cdc4
Fix strange animations when quickly reording items
Stypox Jan 6, 2026
1a42f30
Improve some strings and add some comments
Stypox Jan 6, 2026
3d62b92
Separate @Composables from state logic for actions editor
Stypox Jan 6, 2026
85cb372
Persist long press actions to settings
Stypox Jan 6, 2026
c62004d
Load settings in LongPressMenu too
Stypox Jan 6, 2026
8f19f95
Show loading when action takes some time
Stypox Feb 2, 2026
70c502d
Fix formatting with new ktlint rules
Stypox Feb 2, 2026
ec75dda
Only show Enqueue and EnqueueNext if player open
Stypox Feb 3, 2026
5e0b307
Allow playing local playlists directly
Stypox Feb 3, 2026
378fdef
Fix opening channel fragment from anywhere
Stypox Feb 3, 2026
96a5780
Add reset button to long press menu editor
Stypox Feb 7, 2026
b3b6cf3
Add bg/popup/play shuffled actions
Stypox Feb 7, 2026
f495cc0
Fix player shuffle state not starting out synchronized with queue
Stypox Feb 7, 2026
59841e9
Correctly handle Kodi action in long press menu
Stypox Feb 7, 2026
5094caf
Fix LongPressMenu crashing if dismissed while loading
Stypox Feb 7, 2026
3ee031e
Add accessibility label to show channel details button
Stypox Feb 7, 2026
35e673a
Add tests for LongPressMenuSettings
Stypox Feb 7, 2026
48010d0
Extract some common test methods to InstrumentedTestUtil
Stypox Feb 7, 2026
a92c8b2
Setup espresso for testing
Stypox Feb 8, 2026
2a28e7a
Make LongPressMenu scrollable if it does not fit on screen
Stypox Feb 8, 2026
dc7ed1c
Add 38 UI tests for LongPressMenu
Stypox Feb 8, 2026
512b536
Resizing display in tests is only supported on API>=24
Stypox Feb 8, 2026
0332568
Add -grpc to emulator options for CI
Stypox Feb 8, 2026
3b19d63
Remove unused field uploaderUrl from LongPressable
Stypox Feb 9, 2026
f65094b
Use getPlayQueueStartingAt in BaseListFragment
Stypox Feb 9, 2026
d561444
Implement getPlayQueueStartingAt for Compose ItemList too
Stypox Feb 9, 2026
5b10a93
Embed actions impls inside LongPressAction when possible
Stypox Feb 9, 2026
920d630
Add some documentation to tests
Stypox Feb 9, 2026
0830095
Add Subscribe button to LongPressMenu
Stypox Feb 9, 2026
9c558d9
Build LongPressAction lists using builder pattern
Stypox Feb 9, 2026
d075539
Add Context.findFragmentManager() extension function
Stypox Feb 9, 2026
3aa5edc
Add more documentation
Stypox Feb 9, 2026
3fc4bc9
Add tooltips for long press menu icons
Stypox Feb 10, 2026
9460813
Add documentation to LongPressMenu and simplify code
Stypox Feb 10, 2026
ae214a0
Add 22 tests for LongPressMenuEditor
Stypox Feb 10, 2026
34d4eae
Finish adding documentation to all files
Stypox Feb 11, 2026
90d6c7c
Improve style of item being dragged in LongPressMenuEditor
Stypox Feb 11, 2026
a09cf90
Remove previous versions of custom PlayShuffled/FromHere icons
Stypox Feb 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ jobs:
api-level: ${{ matrix.api-level }}
target: ${{ matrix.target }}
arch: ${{ matrix.arch }}
# the default emulator options from https://github.com/ReactiveCircus/android-emulator-runner#configurations
# plus `-grpc 8554 -grpc-use-jwt` to allow Espresso device control for instrumented tests
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -grpc 8554 -grpc-use-jwt
script: ./gradlew connectedCheck --stacktrace

- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
Expand Down
13 changes: 12 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ configure<ApplicationExtension> {
System.getProperty("versionNameSuffix")?.let { versionNameSuffix = it }

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// https://blog.grobox.de/2019/disable-google-android-instrumentation-test-tracking/
testInstrumentationRunnerArguments["disableAnalytics"] = "true"
// https://developer.android.com/studio/test/espresso-api#set_up_your_project_for_the_espresso_device_api
testOptions {
emulatorControl {
enable = true
}
}
}

buildTypes {
Expand Down Expand Up @@ -363,7 +371,10 @@ dependencies {
testImplementation(libs.mockito.core)

androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.test.espresso)
androidTestImplementation(libs.androidx.test.espresso.device)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.assertj.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
Expand Down
143 changes: 143 additions & 0 deletions app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package org.schabi.newpipe

import android.app.Instrumentation
import android.content.Context
import android.os.SystemClock
import android.view.MotionEvent
import androidx.annotation.StringRes
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.TouchInjectionScope
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.preference.PreferenceManager
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.fail

/**
* Use this instead of calling `InstrumentationRegistry.getInstrumentation()` every time.
*/
val inst: Instrumentation
get() = InstrumentationRegistry.getInstrumentation()

/**
* Use this instead of passing contexts around in instrumented tests.
*/
val ctx: Context
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please prefer context and instrumentation instead

get() = InstrumentationRegistry.getInstrumentation().targetContext

fun putBooleanInPrefs(@StringRes key: Int, value: Boolean) {
PreferenceManager.getDefaultSharedPreferences(ctx)
.edit().putBoolean(ctx.getString(key), value).apply()
}

fun putStringInPrefs(@StringRes key: Int, value: String) {
PreferenceManager.getDefaultSharedPreferences(ctx)
.edit().putString(ctx.getString(key), value).apply()
}

fun clearPrefs() {
PreferenceManager.getDefaultSharedPreferences(ctx)
.edit().clear().apply()
}

/**
* E.g. useful to tap outside dialogs to see whether they close.
*/
fun tapAtAbsoluteXY(x: Float, y: Float) {
val t = SystemClock.uptimeMillis()
inst.sendPointerSync(MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0))
inst.sendPointerSync(MotionEvent.obtain(t, t + 50, MotionEvent.ACTION_UP, x, y, 0))
}

/**
* Same as the original `onNodeWithText` except that this takes a [StringRes] instead of a [String].
*/
fun SemanticsNodeInteractionsProvider.onNodeWithText(
@StringRes text: Int,
substring: Boolean = false,
ignoreCase: Boolean = false,
useUnmergedTree: Boolean = false
): SemanticsNodeInteraction {
return this.onNodeWithText(ctx.getString(text), substring, ignoreCase, useUnmergedTree)
}

/**
* Same as the original `onNodeWithContentDescription` except that this takes a [StringRes] instead of a [String].
*/
fun SemanticsNodeInteractionsProvider.onNodeWithContentDescription(
@StringRes text: Int,
substring: Boolean = false,
ignoreCase: Boolean = false,
useUnmergedTree: Boolean = false
): SemanticsNodeInteraction {
return this.onNodeWithContentDescription(ctx.getString(text), substring, ignoreCase, useUnmergedTree)
}

/**
* Shorthand for `.fetchSemanticsNode().positionOnScreen`.
*/
fun SemanticsNodeInteraction.fetchPosOnScreen() = fetchSemanticsNode().positionOnScreen

/**
* Asserts that [value] is in the range [[l], [r]] (both extremes included).
*/
fun <T : Comparable<T>> assertInRange(l: T, r: T, value: T) {
if (l > r) {
fail("Invalid range passed to `assertInRange`: [$l, $r]")
}
if (value !in l..r) {
fail("Expected $value to be in range [$l, $r]")
}
}

/**
* Asserts that [value] is NOT in the range [[l], [r]] (both extremes included).
*/
fun <T : Comparable<T>> assertNotInRange(l: T, r: T, value: T) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better variable names please, for all the function parameters as well.

if (l > r) {
fail("Invalid range passed to `assertInRange`: [$l, $r]")
}
if (value in l..r) {
fail("Expected $value to NOT be in range [$l, $r]")
}
}

/**
* Tries to scroll vertically in the container [this] and uses [itemInsideScrollingContainer] to
* compute how much the container actually scrolled. Useful in tandem with [assertMoved] or
* [assertDidNotMove].
*/
fun SemanticsNodeInteraction.scrollVerticallyAndGetOriginalAndFinalY(
itemInsideScrollingContainer: SemanticsNodeInteraction,
startY: TouchInjectionScope.() -> Float = { bottom },
endY: TouchInjectionScope.() -> Float = { top }
): Pair<Float, Float> {
val originalPosition = itemInsideScrollingContainer.fetchPosOnScreen()
this.performTouchInput { swipeUp(startY = startY(), endY = endY()) }
val finalPosition = itemInsideScrollingContainer.fetchPosOnScreen()
assertEquals(originalPosition.x, finalPosition.x)
return Pair(originalPosition.y, finalPosition.y)
}

/**
* Simple assert on results from [scrollVerticallyAndGetOriginalAndFinalY].
*/
fun Pair<Float, Float>.assertMoved() {
val (originalY, finalY) = this
assertNotEquals(originalY, finalY)
}

/**
* Simple assert on results from [scrollVerticallyAndGetOriginalAndFinalY].
*/
fun Pair<Float, Float>.assertDidNotMove() {
val (originalY, finalY) = this
assertEquals(originalY, finalY)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
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 java.net.UnknownHostException
Expand All @@ -16,6 +14,7 @@ import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
import org.schabi.newpipe.onNodeWithText
import org.schabi.newpipe.ui.theme.AppTheme

@RunWith(AndroidJUnit4::class)
Expand All @@ -30,7 +29,6 @@ class ErrorPanelTest {
}
}
}
private fun text(@StringRes id: Int) = composeRule.activity.getString(id)

/**
* Test Network Error
Expand All @@ -44,11 +42,11 @@ class ErrorPanelTest {
)

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)
composeRule.onNodeWithText(R.string.network_error).assertIsDisplayed()
composeRule.onNodeWithText(R.string.retry, ignoreCase = true).assertIsDisplayed()
composeRule.onNodeWithText(R.string.error_snackbar_action, ignoreCase = true)
.assertDoesNotExist()
composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true)
composeRule.onNodeWithText(R.string.recaptcha_solve, ignoreCase = true)
.assertDoesNotExist()
}

Expand All @@ -64,9 +62,9 @@ class ErrorPanelTest {
)

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)
composeRule.onNodeWithText(R.string.error_snackbar_message).assertIsDisplayed()
composeRule.onNodeWithText(R.string.retry, ignoreCase = true).assertIsDisplayed()
composeRule.onNodeWithText(R.string.error_snackbar_action, ignoreCase = true)
.assertIsDisplayed()
}

Expand All @@ -91,14 +89,14 @@ class ErrorPanelTest {
onRetry = { retryClicked = true }

)
composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true)
composeRule.onNodeWithText(R.string.recaptcha_solve, ignoreCase = true)
.assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
composeRule.onNodeWithText(R.string.retry, ignoreCase = true)
.assertIsDisplayed()
.performClick()
composeRule.onNodeWithText(text(R.string.open_in_browser), ignoreCase = true)
composeRule.onNodeWithText(R.string.open_in_browser, ignoreCase = true)
.assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
composeRule.onNodeWithText(R.string.error_snackbar_action, ignoreCase = true)
.assertIsDisplayed()
assert(retryClicked) { "onRetry callback should have been invoked" }
}
Expand All @@ -116,11 +114,11 @@ class ErrorPanelTest {

setErrorPanel(contentNotAvailable)

composeRule.onNodeWithText(text(R.string.unsupported_content_in_country))
composeRule.onNodeWithText(R.string.unsupported_content_in_country)
.assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
composeRule.onNodeWithText(R.string.retry, ignoreCase = true)
.assertDoesNotExist()
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
composeRule.onNodeWithText(R.string.error_snackbar_action, ignoreCase = true)
.assertDoesNotExist()
}
}
Loading
Loading