Updated modules screen so it displays all the content in one recyclerview

Added "endless" scrolling support
 - this is done in order to display everything very swiftly and load as user needs it
 - for the most part we'll download only ~10 items and load the rest as scroll progresses, this accomplishes the illusion that whole list is being populated
Added sections and updated repo view
This commit is contained in:
Viktor De Pasquale
2019-11-08 19:03:43 +01:00
parent 19fd4dd89c
commit f83f92d3fa
15 changed files with 569 additions and 188 deletions

View File

@@ -0,0 +1,29 @@
@file:JvmMultifileClass
package com.topjohnwu.magisk.data.database
import androidx.room.Dao
import androidx.room.Query
import com.topjohnwu.magisk.model.entity.module.Repo
interface RepoBase {
fun getRepos(offset: Int, limit: Int = 10): List<Repo>
}
@Dao
interface RepoByUpdatedDao : RepoBase {
@Query("SELECT * FROM repos ORDER BY last_update DESC LIMIT :limit OFFSET :offset")
override fun getRepos(offset: Int, limit: Int): List<Repo>
}
@Dao
interface RepoByNameDao : RepoBase {
@Query("SELECT * FROM repos ORDER BY name COLLATE NOCASE LIMIT :limit OFFSET :offset")
override fun getRepos(offset: Int, limit: Int): List<Repo>
}

View File

@@ -7,5 +7,8 @@ import com.topjohnwu.magisk.model.entity.module.Repo
@Database(version = 6, entities = [Repo::class, RepoEtag::class])
abstract class RepoDatabase : RoomDatabase() {
abstract fun repoDao() : RepoDao
abstract fun repoDao(): RepoDao
abstract fun repoByUpdatedDao(): RepoByUpdatedDao
abstract fun repoByNameDao(): RepoByNameDao
}

View File

@@ -14,6 +14,8 @@ val databaseModule = module {
single { StringDao() }
single { createRepoDatabase(get()) }
single { get<RepoDatabase>().repoDao() }
single { get<RepoDatabase>().repoByNameDao() }
single { get<RepoDatabase>().repoByUpdatedDao() }
single { RepoUpdater(get(), get()) }
}

View File

@@ -20,7 +20,7 @@ val redesignModule = module {
viewModel { HideViewModel(get()) }
viewModel { HomeViewModel(get()) }
viewModel { LogViewModel() }
viewModel { ModuleViewModel() }
viewModel { ModuleViewModel(get(), get(), get()) }
viewModel { RequestViewModel() }
viewModel { SafetynetViewModel(get()) }
viewModel { SettingsViewModel() }

View File

@@ -5,6 +5,8 @@ import androidx.annotation.StringRes
import androidx.databinding.Bindable
import androidx.databinding.Observable
import androidx.databinding.PropertyChangeRegistry
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.databinding.ComparableRvItem
@@ -79,6 +81,31 @@ class RepoRvItem(val item: Repo) : ComparableRvItem<RepoRvItem>() {
override fun itemSameAs(other: RepoRvItem): Boolean = item.id == other.item.id
}
class SectionTitle(
val title: Int,
val button: Int = 0,
val icon: Int = 0
) : ComparableRvItem<SectionTitle>() {
override val layoutRes = R.layout.item_section_md2
override fun onBindingBound(binding: ViewDataBinding) {
super.onBindingBound(binding)
val params = binding.root.layoutParams as StaggeredGridLayoutManager.LayoutParams
params.isFullSpan = true
}
override fun itemSameAs(other: SectionTitle): Boolean = this === other
override fun contentSameAs(other: SectionTitle): Boolean = this === other
}
class RepoItem(val item: Repo) : ComparableRvItem<RepoItem>() {
override val layoutRes: Int = R.layout.item_repo_md2
override fun contentSameAs(other: RepoItem): Boolean = item == other.item
override fun itemSameAs(other: RepoItem): Boolean = item.id == other.item.id
}
class ModuleItem(val item: Module) : ObservableItem<ModuleItem>(), Observable {
override val layoutRes = R.layout.item_module_md2

View File

@@ -186,7 +186,7 @@ val ManagerJson.isObsolete
fun String.clipVersion() = substringAfter('-')
inline fun <T : ComparableRvItem<T>> itemBindingOf(
inline fun <T : ComparableRvItem<*>> itemBindingOf(
crossinline body: (ItemBinding<*>) -> Unit = {}
) = OnItemBind<T> { itemBinding, _, item ->
item.bind(itemBinding)

View File

@@ -1,9 +1,13 @@
package com.topjohnwu.magisk.redesign.module
import android.graphics.Insets
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.databinding.FragmentModuleMd2Binding
import com.topjohnwu.magisk.redesign.compat.CompatFragment
import com.topjohnwu.magisk.utils.EndlessRecyclerScrollListener
import org.koin.androidx.viewmodel.ext.android.viewModel
class ModuleFragment : CompatFragment<ModuleViewModel, FragmentModuleMd2Binding>() {
@@ -11,12 +15,33 @@ class ModuleFragment : CompatFragment<ModuleViewModel, FragmentModuleMd2Binding>
override val layoutRes = R.layout.fragment_module_md2
override val viewModel by viewModel<ModuleViewModel>()
private lateinit var listener: EndlessRecyclerScrollListener
override fun consumeSystemWindowInsets(insets: Insets) = insets
override fun onStart() {
super.onStart()
activity.title = resources.getString(R.string.section_modules)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setEndlessScroller()
}
override fun onDestroyView() {
if (this::listener.isInitialized) {
binding.moduleRemote.removeOnScrollListener(listener)
}
super.onDestroyView()
}
private fun setEndlessScroller() {
val lama = binding.moduleRemote.layoutManager as? StaggeredGridLayoutManager ?: return
lama.isAutoMeasureEnabled = false
listener = EndlessRecyclerScrollListener(lama, viewModel::loadRemote)
binding.moduleRemote.addOnScrollListener(listener)
}
}

View File

@@ -1,93 +1,226 @@
package com.topjohnwu.magisk.redesign.module
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.recyclerview.widget.DiffUtil
import androidx.databinding.ViewDataBinding
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.Config
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.data.database.RepoByNameDao
import com.topjohnwu.magisk.data.database.RepoByUpdatedDao
import com.topjohnwu.magisk.databinding.ComparableRvItem
import com.topjohnwu.magisk.extensions.subscribeK
import com.topjohnwu.magisk.model.entity.module.Module
import com.topjohnwu.magisk.model.entity.module.Repo
import com.topjohnwu.magisk.model.entity.recycler.ModuleItem
import com.topjohnwu.magisk.model.entity.recycler.RepoItem
import com.topjohnwu.magisk.model.entity.recycler.SectionTitle
import com.topjohnwu.magisk.redesign.compat.CompatViewModel
import com.topjohnwu.magisk.redesign.home.itemBindingOf
import com.topjohnwu.magisk.redesign.superuser.diffListOf
import com.topjohnwu.magisk.utils.DiffObservableList
import com.topjohnwu.magisk.tasks.RepoUpdater
import com.topjohnwu.magisk.utils.currentLocale
import io.reactivex.Completable
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import me.tatarka.bindingcollectionadapter2.BindingRecyclerViewAdapter
class ModuleViewModel : CompatViewModel() {
class ModuleViewModel(
private val repoName: RepoByNameDao,
private val repoUpdated: RepoByUpdatedDao,
private val repoUpdater: RepoUpdater
) : CompatViewModel() {
val items = diffListOf<ModuleItem>()
val itemsPending = diffListOf<ModuleItem>()
val itemBinding = itemBindingOf<ModuleItem> {
val adapter = adapterOf<ComparableRvItem<*>>()
val items = diffListOf<ComparableRvItem<*>>()
val itemBinding = itemBindingOf<ComparableRvItem<*>> {
it.bindExtra(BR.viewModel, this)
}
companion object {
private val sectionRemote = SectionTitle(R.string.module_section_remote)
private val sectionActive = SectionTitle(R.string.module_section_active)
private val sectionPending =
SectionTitle(R.string.module_section_pending, R.string.reboot, R.drawable.ic_restart)
}
// ---
private val itemsPending
@WorkerThread get() = items.asSequence()
.filterIsInstance<ModuleItem>()
.filter { it.isModified }
.toList()
private val itemsActive
@WorkerThread get() = items.asSequence()
.filterIsInstance<ModuleItem>()
.filter { !it.isModified }
.toList()
private val itemsRemote
@WorkerThread get() = items.filterIsInstance<RepoItem>()
private var remoteJob: Disposable? = null
private val dao
get() = when (Config.repoOrder) {
Config.Value.ORDER_DATE -> repoUpdated
Config.Value.ORDER_NAME -> repoName
else -> throw IllegalArgumentException()
}
// ---
override fun refresh() = Single.fromCallable { Module.loadModules() }
.map { it.map { ModuleItem(it) } }
.map { it.order() }
.subscribeK { it.forEach { it.update() } }
.map {
val pending = it.getValue(ModuleState.Modified)
val active = it.getValue(ModuleState.Normal)
build(pending = pending, active = active)
}
.map { it to items.calculateDiff(it) }
.subscribeK {
items.update(it.first, it.second)
if (!items.contains(sectionRemote)) {
loadRemote()
}
}
@Synchronized
fun loadRemote() {
// check for existing jobs
val size = itemsRemote.size
if (remoteJob?.isDisposed?.not() == true || size % 10 != 0) {
return
}
remoteJob = loadRepos(offset = size)
.map { it.map { RepoItem(it) } }
.applyViewModel(this)
.subscribeK {
if (!items.contains(sectionRemote)) {
items.add(sectionRemote)
}
items.addAll(it)
}
}
private fun loadRepos(
offset: Int = 0,
downloadRepos: Boolean = offset == 0
): Single<List<Repo>> = Single.fromCallable { dao.getRepos(offset) }.flatMap {
when {
// in case we find result empty and offset is initial we need to refresh the repos.
downloadRepos && it.isEmpty() && offset == 0 -> downloadRepos()
.andThen(loadRepos(downloadRepos = false))
else -> Single.just(it)
}
}
private fun downloadRepos() = Single.just(Unit)
.flatMap { repoUpdater() }
.ignoreElement()
// ---
@WorkerThread
private fun List<ModuleItem>.order() = sortedBy { it.item.name.toLowerCase(currentLocale) }
private fun List<ModuleItem>.order() = asSequence()
.sortedBy { it.item.name.toLowerCase(currentLocale) }
.groupBy {
when {
it.isModified -> ModuleState.Modified
else -> ModuleState.Normal
}
}
.map {
val diff = when (it.key) {
ModuleState.Modified -> itemsPending
ModuleState.Normal -> items
}.calculateDiff(it.value)
ResultEnclosure(it.key, it.value, diff)
}
.ensureAllStates()
private fun List<ResultEnclosure>.ensureAllStates(): List<ResultEnclosure> {
val me = this as? MutableList<ResultEnclosure> ?: this.toMutableList()
private fun Map<ModuleState, List<ModuleItem>>.ensureAllStates(): Map<ModuleState, List<ModuleItem>> {
val me = this as? MutableMap<ModuleState, List<ModuleItem>> ?: this.toMutableMap()
ModuleState.values().forEach {
if (me.none { rit -> it == rit.state }) {
me.add(ResultEnclosure(it, listOf(), null))
if (me.none { rit -> it == rit.key }) {
me[it] = listOf()
}
}
return me
}
fun moveToState(item: ModuleItem) {
items.removeAll { it.itemSameAs(item) }
itemsPending.removeAll { it.itemSameAs(item) }
// ---
if (item.isModified) {
itemsPending
} else {
items
}.apply {
add(item)
sortWith(compareBy { it.item.name.toLowerCase(currentLocale) })
}
@UiThread
fun moveToState(item: ModuleItem) {
items.removeAll { it.genericItemSameAs(item) }
val isPending = item.isModified
Single.fromCallable { if (isPending) itemsPending else itemsActive }
.map { (listOf(item) + it).toMutableList() }
.map { it.apply { sortWith(compareBy { it.item.name.toLowerCase(currentLocale) }) } }
.map {
if (isPending) build(pending = it)
else build(active = it)
}
.map { it to items.calculateDiff(it) }
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess { items.update(it.first, it.second) }
.ignoreElement()
.andThen(cleanup())
.subscribeK()
}
// ---
private fun cleanup() = Completable
.concat(listOf(cleanPending(), cleanActive(), cleanRemote()))
private fun cleanPending() = Single.fromCallable { itemsPending }
.filter { it.isEmpty() }
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess { items.remove(sectionPending) }
.ignoreElement()
private fun cleanActive() = Single.fromCallable { itemsActive }
.filter { it.isEmpty() }
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess { items.remove(sectionActive) }
.ignoreElement()
private fun cleanRemote() = Single.fromCallable { itemsRemote }
.filter { it.isEmpty() }
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess { items.remove(sectionRemote) }
.ignoreElement()
// ---
private enum class ModuleState {
Modified, Normal
}
private data class ResultEnclosure(
val state: ModuleState,
val list: List<ModuleItem>,
val diff: DiffUtil.DiffResult?
)
// ---
private fun ResultEnclosure.update() = when (state) {
ModuleState.Modified -> itemsPending
ModuleState.Normal -> items
}.update(list, diff)
/** Callable only from worker thread because of expensive list filtering */
@WorkerThread
private fun build(
pending: List<ModuleItem> = itemsPending,
active: List<ModuleItem> = itemsActive,
remote: List<RepoItem> = itemsRemote
) = pending.prependIfNotEmpty { sectionPending } +
active.prependIfNotEmpty { sectionActive } +
remote.prependIfNotEmpty { sectionRemote }
private fun <T> DiffObservableList<T>.update(list: List<T>, diff: DiffUtil.DiffResult?) {
diff ?: let {
update(list)
return
}
update(list, diff)
private fun <T> List<T>.prependIfNotEmpty(item: () -> T) =
if (isNotEmpty()) listOf(item()) + this else this
}
fun <T : ComparableRvItem<*>> adapterOf() = object : BindingRecyclerViewAdapter<T>() {
override fun onBindBinding(
binding: ViewDataBinding,
variableId: Int,
layoutRes: Int,
position: Int,
item: T
) {
super.onBindBinding(binding, variableId, layoutRes, position, item)
item.onBindingBound(binding)
}
}

View File

@@ -135,7 +135,7 @@ class SuperuserViewModel(
}
inline fun <T : ComparableRvItem<T>> diffListOf(
inline fun <T : ComparableRvItem<*>> diffListOf(
vararg newItems: T
) = DiffObservableList(object : DiffObservableList.Callback<T> {
override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem.genericItemSameAs(newItem)

View File

@@ -450,4 +450,9 @@ fun View.setRotationNotAnimated(rotation: Int) {
if (animation != null) {
this.rotation = rotation.toFloat()
}
}
@BindingAdapter("android:text")
fun TextView.setTextSafe(text: Int) {
if (text == 0) this.text = null else setText(text)
}

View File

@@ -0,0 +1,113 @@
package com.topjohnwu.magisk.utils
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
class EndlessRecyclerScrollListener(
private val layoutManager: RecyclerView.LayoutManager,
private val loadMore: (page: Int, totalItemsCount: Int, view: RecyclerView?) -> Unit,
private val direction: Direction = Direction.BOTTOM,
visibleRowsThreshold: Int = VISIBLE_THRESHOLD
) : RecyclerView.OnScrollListener() {
constructor(
layoutManager: RecyclerView.LayoutManager,
loadMore: () -> Unit,
direction: Direction = Direction.BOTTOM,
visibleRowsThreshold: Int = VISIBLE_THRESHOLD
) : this(layoutManager, { _, _, _ -> loadMore() }, direction, visibleRowsThreshold)
enum class Direction {
TOP, BOTTOM
}
companion object {
private const val VISIBLE_THRESHOLD = 5
private const val STARTING_PAGE_INDEX = 0
}
// The minimum amount of items to have above/below your current scroll position
// before loading more.
private val visibleThreshold = when (layoutManager) {
is LinearLayoutManager -> visibleRowsThreshold
is GridLayoutManager -> visibleRowsThreshold * layoutManager.spanCount
is StaggeredGridLayoutManager -> visibleRowsThreshold * layoutManager.spanCount
else -> throw IllegalArgumentException("Only LinearLayoutManager, GridLayoutManager and StaggeredGridLayoutManager are supported")
}
// The current offset index of data you have loaded
private var currentPage = 0
// The total number of items in the dataset after the last load
private var previousTotalItemCount = 0
// True if we are still waiting for the last set of data to load.
private var loading = true
// This happens many times a second during a scroll, so be wary of the code you place here.
// We are given a few useful parameters to help us work out if we need to load some more data,
// but first we check if we are waiting for the previous load to finish.
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
if (dx == 0 && dy == 0) return
val totalItemCount = layoutManager.itemCount
val visibleItemPosition = if (direction == Direction.BOTTOM) {
when (layoutManager) {
is StaggeredGridLayoutManager -> layoutManager.findLastVisibleItemPositions(null).max()
?: 0
is GridLayoutManager -> layoutManager.findLastVisibleItemPosition()
is LinearLayoutManager -> layoutManager.findLastVisibleItemPosition()
else -> throw IllegalArgumentException("Only LinearLayoutManager, GridLayoutManager and StaggeredGridLayoutManager are supported")
}
} else {
when (layoutManager) {
is StaggeredGridLayoutManager -> layoutManager.findFirstVisibleItemPositions(null).min()
?: 0
is GridLayoutManager -> layoutManager.findFirstVisibleItemPosition()
is LinearLayoutManager -> layoutManager.findFirstVisibleItemPosition()
else -> throw IllegalArgumentException("Only LinearLayoutManager, GridLayoutManager and StaggeredGridLayoutManager are supported")
}
}
// If the total item count is zero and the previous isn't, assume the
// list is invalidated and should be reset back to initial state
if (totalItemCount < previousTotalItemCount) {
currentPage =
STARTING_PAGE_INDEX
previousTotalItemCount = totalItemCount
if (totalItemCount == 0) {
loading = true
}
}
// If its still loading, we check to see if the dataset count has
// changed, if so we conclude it has finished loading and update the current page
// number and total item count.
if (loading && totalItemCount > previousTotalItemCount) {
loading = false
previousTotalItemCount = totalItemCount
}
// If it isnt currently loading, we check to see if we have breached
// the visibleThreshold and need to reload more data.
// If we do need to reload some more data, we execute onLoadMore to fetch the data.
// threshold should reflect how many total columns there are too
if (!loading && shouldLoadMoreItems(visibleItemPosition, totalItemCount)) {
currentPage++
loadMore(currentPage, totalItemCount, view)
loading = true
}
}
private fun shouldLoadMoreItems(visibleItemPosition: Int, itemCount: Int) = when (direction) {
Direction.TOP -> visibleItemPosition < visibleThreshold
Direction.BOTTOM -> visibleItemPosition + visibleThreshold > itemCount
}
// Call this method whenever performing new searches
fun resetState() {
currentPage = STARTING_PAGE_INDEX
previousTotalItemCount = 0
loading = true
}
}