diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt index f25c7a6c9..5d9a94a5e 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt @@ -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 @@ -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 @@ -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 @@ -221,6 +226,10 @@ class MainActivity : AppCompatActivity() { setupBackPressedHandling() + setupTvBackFocusHandling() + + setupTvInitialFocusHandling() + addMenuProvider( object : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -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 diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt index 255b419dd..29dd9021b 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt @@ -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() @@ -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() diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/CustomColorPickerDialog.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/CustomColorPickerDialog.kt index a1996a082..bb29a08c2 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/CustomColorPickerDialog.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/settings/view/CustomColorPickerDialog.kt @@ -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 @@ -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() } } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/NonFocusableNestedScrollView.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/NonFocusableNestedScrollView.kt new file mode 100644 index 000000000..bc07e04bb --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/NonFocusableNestedScrollView.kt @@ -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 + } +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt index a4c8cda44..2ac8a545f 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/Extension.kt @@ -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 @@ -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 * diff --git a/app/app/src/main/res/color/focus_fill.xml b/app/app/src/main/res/color/focus_fill.xml new file mode 100644 index 000000000..6b145eae3 --- /dev/null +++ b/app/app/src/main/res/color/focus_fill.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/app/src/main/res/color/focus_fill_on_container.xml b/app/app/src/main/res/color/focus_fill_on_container.xml new file mode 100644 index 000000000..5f49278bc --- /dev/null +++ b/app/app/src/main/res/color/focus_fill_on_container.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/app/src/main/res/color/focus_fill_on_primary.xml b/app/app/src/main/res/color/focus_fill_on_primary.xml new file mode 100644 index 000000000..bffab042f --- /dev/null +++ b/app/app/src/main/res/color/focus_fill_on_primary.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/app/src/main/res/color/list_card_stroke.xml b/app/app/src/main/res/color/list_card_stroke.xml new file mode 100644 index 000000000..0724c3582 --- /dev/null +++ b/app/app/src/main/res/color/list_card_stroke.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/app/src/main/res/drawable/focus_ring_control.xml b/app/app/src/main/res/drawable/focus_ring_control.xml new file mode 100644 index 000000000..cc4a3ff7e --- /dev/null +++ b/app/app/src/main/res/drawable/focus_ring_control.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/app/app/src/main/res/drawable/focus_ring_fab.xml b/app/app/src/main/res/drawable/focus_ring_fab.xml new file mode 100644 index 000000000..142990855 --- /dev/null +++ b/app/app/src/main/res/drawable/focus_ring_fab.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/app/app/src/main/res/drawable/focus_ring_pill.xml b/app/app/src/main/res/drawable/focus_ring_pill.xml new file mode 100644 index 000000000..b2190e780 --- /dev/null +++ b/app/app/src/main/res/drawable/focus_ring_pill.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/app/app/src/main/res/drawable/focus_ring_toolbar.xml b/app/app/src/main/res/drawable/focus_ring_toolbar.xml new file mode 100644 index 000000000..b00c00ee4 --- /dev/null +++ b/app/app/src/main/res/drawable/focus_ring_toolbar.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/app/app/src/main/res/layout-land/fragment_downloads_list.xml b/app/app/src/main/res/layout-land/fragment_downloads_list.xml index 25e0b79d1..1effc80a1 100644 --- a/app/app/src/main/res/layout-land/fragment_downloads_list.xml +++ b/app/app/src/main/res/layout-land/fragment_downloads_list.xml @@ -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" /> @@ -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" /> diff --git a/app/app/src/main/res/layout-land/fragment_torrents_list.xml b/app/app/src/main/res/layout-land/fragment_torrents_list.xml index 41d1a2a88..1151aee72 100644 --- a/app/app/src/main/res/layout-land/fragment_torrents_list.xml +++ b/app/app/src/main/res/layout-land/fragment_torrents_list.xml @@ -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" /> @@ -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" /> @@ -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" /> @@ -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" /> @@ -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" /> @@ -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" /> @@ -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" diff --git a/app/app/src/main/res/layout/custom_chip_layout.xml b/app/app/src/main/res/layout/custom_chip_layout.xml index 97da800b1..93429e95f 100644 --- a/app/app/src/main/res/layout/custom_chip_layout.xml +++ b/app/app/src/main/res/layout/custom_chip_layout.xml @@ -1,5 +1,5 @@ diff --git a/app/app/src/main/res/layout/dialog_manage_repository.xml b/app/app/src/main/res/layout/dialog_manage_repository.xml index 0ee5c5aad..ac8ca78ce 100644 --- a/app/app/src/main/res/layout/dialog_manage_repository.xml +++ b/app/app/src/main/res/layout/dialog_manage_repository.xml @@ -8,7 +8,7 @@ android:layout_height="wrap_content" tools:ignore="HardcodedText"> - @@ -178,5 +178,5 @@ app:layout_constraintTop_toBottomOf="@id/bUninstallRepo" /> - + \ No newline at end of file diff --git a/app/app/src/main/res/layout/dialog_service_picker.xml b/app/app/src/main/res/layout/dialog_service_picker.xml index c1d9faade..41ef8dd11 100644 --- a/app/app/src/main/res/layout/dialog_service_picker.xml +++ b/app/app/src/main/res/layout/dialog_service_picker.xml @@ -1,6 +1,6 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/app/src/main/res/layout/fragment_complete_service.xml b/app/app/src/main/res/layout/fragment_complete_service.xml index 9c7ce5ad7..a3f3d57eb 100644 --- a/app/app/src/main/res/layout/fragment_complete_service.xml +++ b/app/app/src/main/res/layout/fragment_complete_service.xml @@ -1,6 +1,6 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/app/src/main/res/layout/fragment_download_details.xml b/app/app/src/main/res/layout/fragment_download_details.xml index e249b9302..131497756 100644 --- a/app/app/src/main/res/layout/fragment_download_details.xml +++ b/app/app/src/main/res/layout/fragment_download_details.xml @@ -1,6 +1,6 @@ - @@ -164,6 +165,7 @@ android:id="@+id/fabOpenLink" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:focusable="true" android:contentDescription="@string/open_link" app:srcCompat="@drawable/icon_open_external" /> @@ -189,6 +191,7 @@ android:id="@+id/fabCopyLink" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:focusable="true" android:contentDescription="@string/copy_link" app:srcCompat="@drawable/icon_copy" /> @@ -214,6 +217,7 @@ android:id="@+id/fabDownloadLink" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:focusable="true" android:contentDescription="@string/download" app:srcCompat="@drawable/icon_download" /> @@ -238,6 +242,7 @@ android:id="@+id/fabSendToPlayer" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:focusable="true" android:contentDescription="@string/send_to_player" app:srcCompat="@drawable/icon_play_local" /> @@ -263,6 +268,7 @@ android:id="@+id/fabPickStreaming" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:focusable="true" android:contentDescription="@string/streaming" app:srcCompat="@drawable/icon_remote" /> @@ -315,6 +321,7 @@ android:id="@+id/fabLoadStreams" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:focusable="true" android:contentDescription="@string/load_streams" app:srcCompat="@drawable/icon_transform" /> @@ -347,4 +354,4 @@ - + diff --git a/app/app/src/main/res/layout/fragment_downloads_list.xml b/app/app/src/main/res/layout/fragment_downloads_list.xml index 540f3e5a5..ed531e83a 100644 --- a/app/app/src/main/res/layout/fragment_downloads_list.xml +++ b/app/app/src/main/res/layout/fragment_downloads_list.xml @@ -58,12 +58,24 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:contentDescription="@string/share" - android:nextFocusRight="@id/rvDownloadList" + android:nextFocusRight="@id/bRefresh" android:nextFocusDown="@id/rvDownloadList" android:tooltipText="@string/share_selected" app:icon="@drawable/icon_share" app:iconSize="25dp" /> +