Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 6943ee44 authored by Wenbo Jie's avatar Wenbo Jie
Browse files

[DocsUI M3] Add RecyclerView adapter for nav roots

This is the preparation of converting navigation tree from ListView
to RecyclerView (in order to fix the tab cycle problem). The existing
RootsAdapter doesn't work with RecyclerView, so creating this to
replicate the same functionalities.

Bug: 370628223
Test: atest DocumentsUIGoogleTests:com.android.documentsui.sidebar.RecyclerRootsAdapterTest
Flag: com.android.documentsui.flags.use_material3
Change-Id: Ifb18cc50a9ba19f439cf3470214e69687cf385e4
parent 4b9cda59
Loading
Loading
Loading
Loading
+9 −0
Original line number Original line Diff line number Diff line
@@ -234,3 +234,12 @@
-keep class com.android.documentsui.JobPanelViewModel$Companion {
-keep class com.android.documentsui.JobPanelViewModel$Companion {
  public void setDisableAutoDismiss(boolean);
  public void setDisableAutoDismiss(boolean);
}
}

# This is used in the unit test RecyclerRootsAdapterTest
-keep class androidx.recyclerview.widget.RecyclerView$AdapterDataObserver {
  public void onItemRangeChanged(int, int, Object);
}

# Temporarily add this here because the class is not used in the source code yet, will remove it
# after adopting the class in the follow-up CL.
-keep class com.android.documentsui.sidebar.RecyclerRootsAdapter { *; }
+15 −0
Original line number Original line Diff line number Diff line
@@ -38,6 +38,13 @@ import com.android.documentsui.base.UserId;
 */
 */
public abstract class Item {
public abstract class Item {
    private final @LayoutRes int mLayoutId;
    private final @LayoutRes int mLayoutId;
    /**
     * This is to manage the item selection state in RecyclerView, with ListView the selection
     * state is managed by the list via `setChoiceMode`, but there's no such thing in RecyclerView,
     * we need to maintain that in the model layer here and do control the selection logic in the
     * adapter.
     */
    private boolean mIsSelected;


    public final String title;
    public final String title;
    public final UserId userId;
    public final UserId userId;
@@ -94,4 +101,12 @@ public abstract class Item {
    }
    }


    void createContextMenu(Menu menu, MenuInflater inflater, MenuManager menuManager) {}
    void createContextMenu(Menu menu, MenuInflater inflater, MenuManager menuManager) {}

    public void setSelected(boolean selected) {
        mIsSelected = selected;
    }

    public boolean isSelected() {
        return mIsSelected;
    }
}
}
+160 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.documentsui.sidebar

import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.android.documentsui.BaseActivity
import com.android.documentsui.R
import com.android.documentsui.base.State
import com.android.documentsui.util.Material3Config.Companion.getRes

// Since the main binding logic is in [Item.bindView], the ViewHolder itself
// doesn't do anything here.
class RootItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

/**
 * This is the RecyclerView version of the [RootsAdapter], it will replace [RootsAdapter] when the
 * use_material3 flag is ON. It not only covers the existing functionality of [RootsAdapter], but
 * also covers the [RootsList], because in the layout file we will use the naked RecyclerView
 * instead of [RootsList] when the flag is ON.
 */
class RecyclerRootsAdapter(
  private val mActivity: BaseActivity,
  private val mItems: MutableList<Item>,
  private val mDragListener: View.OnDragListener?,
) : RecyclerView.Adapter<RootItemViewHolder>() {
  private var mSelectedPosition = RecyclerView.NO_POSITION

  companion object {
    const val TYPE_ROOT = 0
    const val TYPE_NAV_RAIL_ROOT = 1
    const val TYPE_SPACER = 2
    const val TYPE_HEADER = 3
  }

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RootItemViewHolder {
    val inflater = LayoutInflater.from(parent.context)
    val layoutId =
      when (viewType) {
        TYPE_ROOT -> getRes(R.layout.item_root)
        TYPE_NAV_RAIL_ROOT -> getRes(R.layout.nav_rail_item_root)
        TYPE_SPACER -> getRes(R.layout.item_root_spacer)
        TYPE_HEADER -> getRes(R.layout.item_root_header)
        else -> throw IllegalArgumentException("Invalid view type: $viewType")
      }

    val view = inflater.inflate(layoutId, parent, false)
    return RootItemViewHolder(view)
  }

  override fun onBindViewHolder(holder: RootItemViewHolder, position: Int) {
    val item = mItems[position]
    item.bindView(holder.itemView)
    holder.itemView.setTag(getRes(R.id.item_position_tag), if (item.isRoot) position else null)
    holder.itemView.setOnDragListener(if (item.isRoot) mDragListener else null)

    val isEnabled = item !is SpacerItem
    holder.itemView.isEnabled = isEnabled
    holder.itemView.isActivated = item.isSelected
    holder.itemView.isSelected = item.isSelected

    holder.itemView.setOnClickListener { v: View -> onClick(item) }
    holder.itemView.setOnLongClickListener { v: View -> onLongClick(item) }
    holder.itemView.setOnKeyListener { v: View, keyCode: Int, event: KeyEvent ->
      onKey(keyCode, event)
    }
  }

  fun onClick(item: Item) {
    item.open()
    mActivity.setRootsDrawerOpen(false)
  }

  fun onLongClick(item: Item): Boolean {
    val state = mActivity.displayState
    if (state.action == State.ACTION_GET_CONTENT) {
      return item.showAppDetails()
    }
    return false
  }

  fun onKey(keyCode: Int, event: KeyEvent): Boolean {
    if (event.action != KeyEvent.ACTION_DOWN) {
      return false
    }
    return when (keyCode) {
      /**
       * Ignore tab key events - this causes them to bubble up to the global key handler where they
       * are appropriately handled. See [com.android.documentsui.files.FilesActivity.onKeyDown] and
       * [com.android.documentsui.picker.PickActivity.onKeyDown].
       *
       * The tab press will be bubbled up only when the keyboard navigation feature is enabled,
       * otherwise the event will be swallowed here.
       */
      KeyEvent.KEYCODE_TAB -> !mActivity.getInjector().features.isSystemKeyboardNavigationEnabled()
      // Prevent left/right arrow keystrokes from shifting focus away from the roots list.
      KeyEvent.KEYCODE_DPAD_LEFT,
      KeyEvent.KEYCODE_DPAD_RIGHT -> true

      else -> false
    }
  }

  override fun getItemViewType(position: Int): Int {
    val item = mItems[position]
    return when (item) {
      is NavRailRootItem,
      is NavRailAppItem,
      is NavRailRootAndAppItem,
      is NavRailProfileItem -> TYPE_NAV_RAIL_ROOT
      is RootAndAppItem,
      is ProfileItem,
      is RootItem,
      is AppItem -> TYPE_ROOT
      is SpacerItem -> TYPE_SPACER
      is HeaderItem -> TYPE_HEADER

      else -> TYPE_ROOT
    }
  }

  override fun getItemCount(): Int {
    return mItems.size
  }

  fun getItem(position: Int): Item? {
    return mItems[position]
  }

  fun setItemSelected(position: Int, selected: Boolean) {
    if (position < 0 || position >= mItems.size) {
      return
    }
    if (mSelectedPosition != RecyclerView.NO_POSITION) {
      val previouslySelectedItem = mItems[mSelectedPosition]
      previouslySelectedItem.isSelected = false
      notifyItemChanged(mSelectedPosition)
    }
    mSelectedPosition = position
    val item = mItems[position]
    item.isSelected = selected
    notifyItemChanged(position)
  }
}
+225 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.documentsui.sidebar

import android.content.Context
import android.view.KeyEvent
import android.view.View.OnDragListener
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import com.android.documentsui.ActionHandler
import com.android.documentsui.ActivityConfig
import com.android.documentsui.BaseActivity
import com.android.documentsui.Injector
import com.android.documentsui.R
import com.android.documentsui.base.Features
import com.android.documentsui.base.RootInfo
import com.android.documentsui.base.State
import com.google.common.truth.Expect
import kotlin.jvm.java
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.Mockito.spy
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when` as whenever
import org.mockito.MockitoAnnotations

@SmallTest
class RecyclerRootsAdapterTest {

  @Mock private lateinit var activity: BaseActivity
  @Mock private lateinit var dragListener: OnDragListener

  private lateinit var parent: RecyclerView
  private lateinit var adapter: RecyclerRootsAdapter
  private lateinit var items: MutableList<Item>

  @get:Rule val expect = Expect.create()

  @Before
  fun setup() {
    MockitoAnnotations.openMocks(this)
    val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
    parent = RecyclerView(context)
    parent.layoutManager = LinearLayoutManager(context)
    items = mutableListOf()
    adapter = RecyclerRootsAdapter(activity, items, dragListener)
  }

  @Test
  fun testOnBindViewHolder() {
    val mockItem = mock(RootItem::class.java)
    items.add(mockItem)
    val viewHolder = adapter.onCreateViewHolder(parent, 0)

    adapter.onBindViewHolder(viewHolder, 0)

    verify(mockItem).bindView(viewHolder.itemView)
    expect.that(viewHolder.itemView.getTag(R.id.item_position_tag)).isEqualTo(0)
  }

  @Test
  fun testOnBindViewHolder_withSpaceItem() {
    val mockItem = SpacerItem()
    items.add(mockItem)
    val viewHolder = adapter.onCreateViewHolder(parent, 2)

    adapter.onBindViewHolder(viewHolder, 0)

    expect.that(viewHolder.itemView.isEnabled).isFalse()
  }

  @Test
  fun testOnBindViewHolder_withSelectedItem() {
    val mockItem = mock(RootItem::class.java)
    whenever(mockItem.isSelected).thenReturn(true)
    items.add(mockItem)
    val viewHolder = adapter.onCreateViewHolder(parent, 0)

    adapter.onBindViewHolder(viewHolder, 0)

    expect.that(viewHolder.itemView.isActivated).isTrue()
    expect.that(viewHolder.itemView.isSelected).isTrue()
  }

  @Test
  fun testOnBindViewHolder_withUnselectedItem() {
    val mockItem = mock(RootItem::class.java)
    whenever(mockItem.isSelected).thenReturn(false)
    items.add(mockItem)
    val viewHolder = adapter.onCreateViewHolder(parent, 0)

    adapter.onBindViewHolder(viewHolder, 0)

    expect.that(viewHolder.itemView.isActivated).isFalse()
    expect.that(viewHolder.itemView.isSelected).isFalse()
  }

  @Test
  fun testOnBindViewHolder_onClick() {
    // simply mock doesn't work, why?
    val mockItem = spy(RootItem(mock(RootInfo::class.java), mock(ActionHandler::class.java), false))

    adapter.onClick(mockItem)

    verify(mockItem).open()
  }

  @Test
  fun testOnBindViewHolder_onLongClick() {
    val mockItem = mock(RootItem::class.java)
    whenever(activity.displayState).thenReturn(State().apply { action = State.ACTION_GET_CONTENT })

    adapter.onLongClick(mockItem)

    verify(mockItem).showAppDetails()
  }

  @Test
  fun testOnBindViewHolder_onTabKey() {
    val features = mock(Features::class.java)
    whenever(features.isSystemKeyboardNavigationEnabled()).thenReturn(true)
    val injector =
      Injector<ActionHandler>(
        features,
        mock(ActivityConfig::class.java),
        null,
        null,
        null,
        null,
      )
    whenever(activity.getInjector()).thenReturn(injector)
    val event = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_TAB)

    expect.that(adapter.onKey(KeyEvent.KEYCODE_TAB, event)).isFalse()
  }

  @Test
  fun testOnBindViewHolder_onArrowKey() {
    val leftArrowEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT)
    expect.that(adapter.onKey(KeyEvent.KEYCODE_DPAD_LEFT, leftArrowEvent)).isTrue()

    val rightArrowEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)
    expect.that(adapter.onKey(KeyEvent.KEYCODE_DPAD_RIGHT, rightArrowEvent)).isTrue()
  }

  @Test
  fun testGetItemViewType_withRootItem() {
    items.add(mock(RootItem::class.java))
    expect.that(adapter.getItemViewType(0)).isEqualTo(0)
  }

  @Test
  fun testGetItemViewType_withNavRailRootItem() {
    items.add(mock(NavRailRootItem::class.java))
    expect.that(adapter.getItemViewType(0)).isEqualTo(1)
  }

  @Test
  fun testGetItemViewType_withSpacerItem() {
    items.add(SpacerItem())
    expect.that(adapter.getItemViewType(0)).isEqualTo(2)
  }

  @Test
  fun testGetItemViewType_withHeaderItem() {
    items.add(HeaderItem("Test"))
    expect.that(adapter.getItemViewType(0)).isEqualTo(3)
  }

  @Test
  fun testSetItemSelected_updatesSelectedItemAndNotifies() {
    val mockItem1 = mock(RootItem::class.java)
    val mockItem2 = mock(RootItem::class.java)
    val observer: AdapterDataObserver = mock(AdapterDataObserver::class.java)
    adapter.registerAdapterDataObserver(observer)
    items.add(mockItem1)
    items.add(mockItem2)

    // Select item 0.
    adapter.setItemSelected(0, true)
    verify(mockItem1).isSelected = true
    verify(observer).onItemRangeChanged(0, 1, null)

    // Select item 1, item 0 should be unselected.
    adapter.setItemSelected(1, true)

    verify(mockItem1).isSelected = false
    verify(mockItem2).isSelected = true
    // Verify both item 1 and item 2 have changed.
    verify(observer, times(2)).onItemRangeChanged(0, 1, null)
    verify(observer).onItemRangeChanged(1, 1, null)
  }

  @Test
  fun testGetItem() {
    val mockItem1 = mock(RootItem::class.java)
    val mockItem2 = mock(RootItem::class.java)
    items.add(mockItem1)
    items.add(mockItem2)

    expect.that(adapter.itemCount).isEqualTo(2)
    expect.that(adapter.getItem(0)).isEqualTo(mockItem1)
    expect.that(adapter.getItem(1)).isEqualTo(mockItem2)
  }
}