Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewParent
import android.widget.Toast
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
Expand All @@ -36,6 +38,8 @@ import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.github.livingwithhippos.unchained.BuildConfig
import com.github.livingwithhippos.unchained.R
import com.github.livingwithhippos.unchained.data.model.UserAction
Expand All @@ -59,6 +63,7 @@ import com.github.livingwithhippos.unchained.utilities.SCHEME_MAGNET
import com.github.livingwithhippos.unchained.utilities.SIGNATURE
import com.github.livingwithhippos.unchained.utilities.TelemetryManager
import com.github.livingwithhippos.unchained.utilities.extension.downloadFileInStandardFolder
import com.github.livingwithhippos.unchained.utilities.extension.isTv
import com.github.livingwithhippos.unchained.utilities.extension.openExternalWebPage
import com.github.livingwithhippos.unchained.utilities.extension.parcelable
import com.github.livingwithhippos.unchained.utilities.extension.showToast
Expand Down Expand Up @@ -221,6 +226,10 @@ class MainActivity : AppCompatActivity() {

setupBackPressedHandling()

setupTvBackFocusHandling()

setupTvInitialFocusHandling()

addMenuProvider(
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
Expand Down Expand Up @@ -938,6 +947,50 @@ class MainActivity : AppCompatActivity() {
}
}

/** On TV, a back press while focus is inside a long/endless list moves it to the bottom nav
* instead of navigating, since there is no other way to reach the nav bar from deep in a
* list (see issue #376). */
private fun setupTvBackFocusHandling() {
if (!isTv()) return

onBackPressedDispatcher.addCallback(this) {
val focusRescued =
currentFocus?.isInsideScrollableList() == true &&
binding.bottomNavView.requestFocus()
if (!focusRescued) {
// let the other callbacks or the default handling manage this back press
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}
}

/** On TV, the toolbar's overflow button is first in layout order and silently grabs default
* focus on every new destination. Rescue focus onto the bottom nav when that happens, without
* touching focus a fragment or back navigation already placed deliberately. */
private fun setupTvInitialFocusHandling() {
if (!isTv()) return

navController.addOnDestinationChangedListener { _, _, _ ->
binding.root.post {
if (binding.appBarLayout.findFocus() != null) {
binding.bottomNavView.requestFocus()
}
}
}
}

/** True if inside a scrollable list, ignoring the RecyclerView ViewPager2 uses internally. */
private fun View.isInsideScrollableList(): Boolean {
var current: ViewParent? = parent
while (current != null) {
if (current is RecyclerView && current.parent !is ViewPager2) return true
current = current.parent
}
return false
}

private fun showUpdateDialog(description: String, link: String) {

// passing the baseContext or applicationContext cause a crash in the release version build
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener {
// only in landscape view
viewModel.postEventNotice(ListEvent.NewDownload)
}
binding.bRefresh?.setOnClickListener {
// only in landscape view
binding.bRefresh.setOnClickListener {
if (!binding.srLayout.isRefreshing) {
binding.srLayout.isRefreshing = true
downloadAdapter.refresh()
Expand Down Expand Up @@ -739,7 +738,7 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener {
// landscape only
viewModel.postEventNotice(ListEvent.NewDownload)
}
binding.bRefresh?.setOnClickListener {
binding.bRefresh.setOnClickListener {
if (!binding.srLayout.isRefreshing) {
binding.srLayout.isRefreshing = true
torrentAdapter.refresh()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.MaterialColors
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint

Expand Down Expand Up @@ -120,11 +121,24 @@ class CustomColorPickerDialog : BottomSheetDialogFragment() {
val marginPx = resources.getDimensionPixelSize(R.dimen.custom_color_swatch_margin)
return View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(sizePx, sizePx).apply { marginEnd = marginPx }
background =
val swatchDrawable =
GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(color)
}
background = swatchDrawable
// plain Views are not reachable with a d-pad by default, so make the swatches
// focusable and show a ring around the focused one for TV and keyboard users
isFocusable = true
setOnFocusChangeListener { v, hasFocus ->
val strokeWidth =
if (hasFocus) v.resources.getDimensionPixelSize(R.dimen.focus_stroke_width)
else 0
swatchDrawable.setStroke(
strokeWidth,
MaterialColors.getColor(v, com.google.android.material.R.attr.colorOnSurface),
)
}
setOnClickListener { onClick() }
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.github.livingwithhippos.unchained.utilities

import android.content.Context
import android.util.AttributeSet
import androidx.core.widget.NestedScrollView

/**
* A [NestedScrollView] that never takes d-pad focus itself.
*
* NestedScrollView calls setFocusable(true) in its constructor, overriding any android:focusable
* attribute set in the layout. On Android TV this makes the scroll container itself a focus
* target: it sits directly next to the toolbar and the navigation bar, so FocusFinder routes the
* d-pad onto it instead of the buttons inside, and since a focused scroll view has no visual
* focus state the screen looks like it cannot be navigated at all (see issue #376).
*
* Focused children still scroll into view through requestChildFocus, so scrolling with the d-pad
* keeps working as long as the content contains at least one focusable view.
*/
class NonFocusableNestedScrollView
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.core.R.attr.nestedScrollViewStyle,
) : NestedScrollView(context, attrs, defStyleAttr) {

init {
isFocusable = false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.content.ClipboardManager
import android.content.ContentResolver.SCHEME_CONTENT
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.AssetManager
import android.content.res.Configuration
import android.content.res.Resources
Expand Down Expand Up @@ -365,6 +366,9 @@ fun Uri.getFileName(context: Context): String {
return fileName
}

/** True on Android TV, where several controls need d-pad-specific handling. */
fun Context.isTv(): Boolean = packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)

/**
* Open an url from available apps
*
Expand Down
6 changes: 6 additions & 0 deletions app/app/src/main/res/color/focus_fill.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Translucent fill used inside the focus ring drawables so the whole control lights up
while focused, making the d-pad cursor easy to follow on Android TV from a distance. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.25" android:color="?attr/colorPrimary" />
</selector>
6 changes: 6 additions & 0 deletions app/app/src/main/res/color/focus_fill_on_container.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Translucent focus fill for controls on a colorPrimaryContainer background (fabs),
see focus_fill.xml. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.25" android:color="?attr/colorOnPrimaryContainer" />
</selector>
6 changes: 6 additions & 0 deletions app/app/src/main/res/color/focus_fill_on_primary.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Translucent focus fill for controls on a colorPrimary background (the toolbar), see
focus_fill.xml. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.3" android:color="?attr/colorOnPrimary" />
</selector>
7 changes: 7 additions & 0 deletions app/app/src/main/res/color/list_card_stroke.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Stroke color for the list item cards: visible only while the card is focused,
e.g. when navigating with a d-pad on Android TV. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorPrimary" android:state_focused="true" />
<item android:color="@android:color/transparent" />
</selector>
17 changes: 17 additions & 0 deletions app/app/src/main/res/drawable/focus_ring_control.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Focus indicator overlay for small controls (checkboxes, switches, chips),
see focus_ring_pill.xml. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<corners android:radius="8dp" />
<solid android:color="@color/focus_fill" />
<stroke android:width="@dimen/focus_stroke_width" android:color="?attr/colorPrimary" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>
</item>
</selector>
20 changes: 20 additions & 0 deletions app/app/src/main/res/drawable/focus_ring_fab.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Focus indicator overlay for floating action buttons, see focus_ring_pill.xml. FABs use the
16dp rounded square Material 3 shape and a colorPrimaryContainer background, so the ring
uses the matching on color to stay visible on top of it. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="@color/focus_fill_on_container" />
<stroke
android:width="@dimen/focus_stroke_width"
android:color="?attr/colorOnPrimaryContainer" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>
</item>
</selector>
19 changes: 19 additions & 0 deletions app/app/src/main/res/drawable/focus_ring_pill.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Focus indicator overlay for pill shaped controls (buttons, icon buttons): a colored ring
that is only visible while the view is focused, e.g. when navigating with a d-pad on
Android TV. Used as android:foreground so it never interferes with the background ripple,
elevation or touch feedback. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<corners android:radius="50dp" />
<solid android:color="@color/focus_fill" />
<stroke android:width="@dimen/focus_stroke_width" android:color="?attr/colorPrimary" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>
</item>
</selector>
19 changes: 19 additions & 0 deletions app/app/src/main/res/drawable/focus_ring_toolbar.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Focus indicator overlay for toolbar action icons (menu items, the overflow button), see
focus_ring_pill.xml. These sit on a colorPrimary background, so colorOnPrimary is used
instead of colorPrimary to stay visible, the same reasoning as focus_ring_fab.xml for fabs
on their own colored container. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<corners android:radius="50dp" />
<solid android:color="@color/focus_fill_on_primary" />
<stroke android:width="@dimen/focus_stroke_width" android:color="?attr/colorOnPrimary" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>
</item>
</selector>
3 changes: 3 additions & 0 deletions app/app/src/main/res/layout-land/fragment_downloads_list.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:nextFocusRight="@id/cbSelectAll"
android:nextFocusDown="@id/rvDownloadList"
android:text="@string/add"
app:icon="@drawable/icon_add" />

Expand Down Expand Up @@ -101,6 +103,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:nextFocusUp="@id/cbSelectAll"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_list_download" />
Expand Down
15 changes: 8 additions & 7 deletions app/app/src/main/res/layout-land/fragment_torrents_list.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:nextFocusRight="@id/cbSelectAll"
android:nextFocusDown="@id/rvDownloadList"
android:nextFocusDown="@id/rvTorrentList"
android:text="@string/add"
app:icon="@drawable/icon_add" />

Expand All @@ -36,7 +36,7 @@
android:layout_height="wrap_content"
android:checked="false"
android:nextFocusRight="@id/bDeleteSelected"
android:nextFocusDown="@id/rvDownloadList"
android:nextFocusDown="@id/rvTorrentList"
android:text="0"
android:tooltipText="@string/select_deselect_all" />

Expand All @@ -47,7 +47,7 @@
android:layout_height="wrap_content"
android:contentDescription="@string/delete"
android:nextFocusRight="@id/bDownloadSelected"
android:nextFocusDown="@id/rvDownloadList"
android:nextFocusDown="@id/rvTorrentList"
android:tooltipText="@string/delete_selected"
app:icon="@drawable/icon_delete"
app:iconSize="25dp" />
Expand All @@ -59,7 +59,7 @@
android:layout_height="wrap_content"
android:contentDescription="@string/download"
android:nextFocusRight="@id/bDetailsSelected"
android:nextFocusDown="@id/rvDownloadList"
android:nextFocusDown="@id/rvTorrentList"
android:tooltipText="@string/download_selected"
app:icon="@drawable/icon_download"
app:iconSize="25dp" />
Expand All @@ -71,7 +71,7 @@
android:layout_height="wrap_content"
android:contentDescription="@string/torrent_details"
android:nextFocusRight="@id/bRefresh"
android:nextFocusDown="@id/rvDownloadList"
android:nextFocusDown="@id/rvTorrentList"
android:tooltipText="@string/torrent_details"
app:icon="@drawable/icon_info"
app:iconSize="25dp" />
Expand All @@ -82,8 +82,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/refresh"
android:nextFocusRight="@id/rvDownloadList"
android:nextFocusDown="@id/rvDownloadList"
android:nextFocusRight="@id/rvTorrentList"
android:nextFocusDown="@id/rvTorrentList"
android:tooltipText="@string/refresh"
app:icon="@drawable/icon_reload"
app:iconSize="25dp" />
Expand All @@ -104,6 +104,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:nextFocusUp="@id/cbSelectAll"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintTop_toBottomOf="@id/llMultiSelection"
Expand Down
2 changes: 1 addition & 1 deletion app/app/src/main/res/layout/custom_chip_layout.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.chip.Chip xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/Widget.Material3.Chip.Filter.Elevated"
style="@style/Widget.Unchained.Chip.FilterElevated"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
Loading