diff --git a/app/src/main/java/com/topjohnwu/magisk/data/database/Repo.kt b/app/src/main/java/com/topjohnwu/magisk/data/database/Repo.kt new file mode 100644 index 000000000..3c42c9d2e --- /dev/null +++ b/app/src/main/java/com/topjohnwu/magisk/data/database/Repo.kt @@ -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 + +} + +@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 + +} + +@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 + +} \ No newline at end of file diff --git a/app/src/main/java/com/topjohnwu/magisk/data/database/RepoDatabase.kt b/app/src/main/java/com/topjohnwu/magisk/data/database/RepoDatabase.kt index c7ad3a00e..5206172d5 100644 --- a/app/src/main/java/com/topjohnwu/magisk/data/database/RepoDatabase.kt +++ b/app/src/main/java/com/topjohnwu/magisk/data/database/RepoDatabase.kt @@ -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 + } diff --git a/app/src/main/java/com/topjohnwu/magisk/di/DatabaseModule.kt b/app/src/main/java/com/topjohnwu/magisk/di/DatabaseModule.kt index 5a5cbeb47..0af41f5e2 100644 --- a/app/src/main/java/com/topjohnwu/magisk/di/DatabaseModule.kt +++ b/app/src/main/java/com/topjohnwu/magisk/di/DatabaseModule.kt @@ -14,6 +14,8 @@ val databaseModule = module { single { StringDao() } single { createRepoDatabase(get()) } single { get().repoDao() } + single { get().repoByNameDao() } + single { get().repoByUpdatedDao() } single { RepoUpdater(get(), get()) } } diff --git a/app/src/main/java/com/topjohnwu/magisk/di/RedesignModule.kt b/app/src/main/java/com/topjohnwu/magisk/di/RedesignModule.kt index 631e7b0dc..89cd8efdf 100644 --- a/app/src/main/java/com/topjohnwu/magisk/di/RedesignModule.kt +++ b/app/src/main/java/com/topjohnwu/magisk/di/RedesignModule.kt @@ -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() } diff --git a/app/src/main/java/com/topjohnwu/magisk/model/entity/recycler/ModuleRvItem.kt b/app/src/main/java/com/topjohnwu/magisk/model/entity/recycler/ModuleRvItem.kt index e848bbc91..941f356a0 100644 --- a/app/src/main/java/com/topjohnwu/magisk/model/entity/recycler/ModuleRvItem.kt +++ b/app/src/main/java/com/topjohnwu/magisk/model/entity/recycler/ModuleRvItem.kt @@ -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() { 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() { + 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() { + + 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(), Observable { override val layoutRes = R.layout.item_module_md2 diff --git a/app/src/main/java/com/topjohnwu/magisk/redesign/home/HomeViewModel.kt b/app/src/main/java/com/topjohnwu/magisk/redesign/home/HomeViewModel.kt index 86078999a..599380636 100644 --- a/app/src/main/java/com/topjohnwu/magisk/redesign/home/HomeViewModel.kt +++ b/app/src/main/java/com/topjohnwu/magisk/redesign/home/HomeViewModel.kt @@ -186,7 +186,7 @@ val ManagerJson.isObsolete fun String.clipVersion() = substringAfter('-') -inline fun > itemBindingOf( +inline fun > itemBindingOf( crossinline body: (ItemBinding<*>) -> Unit = {} ) = OnItemBind { itemBinding, _, item -> item.bind(itemBinding) diff --git a/app/src/main/java/com/topjohnwu/magisk/redesign/module/ModuleFragment.kt b/app/src/main/java/com/topjohnwu/magisk/redesign/module/ModuleFragment.kt index c252eaf18..5dcd3d5d3 100644 --- a/app/src/main/java/com/topjohnwu/magisk/redesign/module/ModuleFragment.kt +++ b/app/src/main/java/com/topjohnwu/magisk/redesign/module/ModuleFragment.kt @@ -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() { @@ -11,12 +15,33 @@ class ModuleFragment : CompatFragment override val layoutRes = R.layout.fragment_module_md2 override val viewModel by viewModel() + 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) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/topjohnwu/magisk/redesign/module/ModuleViewModel.kt b/app/src/main/java/com/topjohnwu/magisk/redesign/module/ModuleViewModel.kt index 55d4cbf7a..8397c1ec4 100644 --- a/app/src/main/java/com/topjohnwu/magisk/redesign/module/ModuleViewModel.kt +++ b/app/src/main/java/com/topjohnwu/magisk/redesign/module/ModuleViewModel.kt @@ -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() - val itemsPending = diffListOf() - val itemBinding = itemBindingOf { + val adapter = adapterOf>() + val items = diffListOf>() + val itemBinding = itemBindingOf> { 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() + .filter { it.isModified } + .toList() + private val itemsActive + @WorkerThread get() = items.asSequence() + .filterIsInstance() + .filter { !it.isModified } + .toList() + private val itemsRemote + @WorkerThread get() = items.filterIsInstance() + + 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> = 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.order() = sortedBy { it.item.name.toLowerCase(currentLocale) } + private fun List.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.ensureAllStates(): List { - val me = this as? MutableList ?: this.toMutableList() + private fun Map>.ensureAllStates(): Map> { + val me = this as? MutableMap> ?: 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, - 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 = itemsPending, + active: List = itemsActive, + remote: List = itemsRemote + ) = pending.prependIfNotEmpty { sectionPending } + + active.prependIfNotEmpty { sectionActive } + + remote.prependIfNotEmpty { sectionRemote } - private fun DiffObservableList.update(list: List, diff: DiffUtil.DiffResult?) { - diff ?: let { - update(list) - return - } - update(list, diff) + private fun List.prependIfNotEmpty(item: () -> T) = + if (isNotEmpty()) listOf(item()) + this else this + +} + +fun > adapterOf() = object : BindingRecyclerViewAdapter() { + override fun onBindBinding( + binding: ViewDataBinding, + variableId: Int, + layoutRes: Int, + position: Int, + item: T + ) { + super.onBindBinding(binding, variableId, layoutRes, position, item) + item.onBindingBound(binding) } - } \ No newline at end of file diff --git a/app/src/main/java/com/topjohnwu/magisk/redesign/superuser/SuperuserViewModel.kt b/app/src/main/java/com/topjohnwu/magisk/redesign/superuser/SuperuserViewModel.kt index 5308ed35d..471bb8393 100644 --- a/app/src/main/java/com/topjohnwu/magisk/redesign/superuser/SuperuserViewModel.kt +++ b/app/src/main/java/com/topjohnwu/magisk/redesign/superuser/SuperuserViewModel.kt @@ -135,7 +135,7 @@ class SuperuserViewModel( } -inline fun > diffListOf( +inline fun > diffListOf( vararg newItems: T ) = DiffObservableList(object : DiffObservableList.Callback { override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem.genericItemSameAs(newItem) diff --git a/app/src/main/java/com/topjohnwu/magisk/utils/DataBindingAdapters.kt b/app/src/main/java/com/topjohnwu/magisk/utils/DataBindingAdapters.kt index 346efcc3f..eebc0ed0e 100644 --- a/app/src/main/java/com/topjohnwu/magisk/utils/DataBindingAdapters.kt +++ b/app/src/main/java/com/topjohnwu/magisk/utils/DataBindingAdapters.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/com/topjohnwu/magisk/utils/EndlessRecyclerScrollListener.kt b/app/src/main/java/com/topjohnwu/magisk/utils/EndlessRecyclerScrollListener.kt new file mode 100644 index 000000000..869f12444 --- /dev/null +++ b/app/src/main/java/com/topjohnwu/magisk/utils/EndlessRecyclerScrollListener.kt @@ -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 it’s 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 isn’t 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 + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_module_md2.xml b/app/src/main/res/layout/fragment_module_md2.xml index 35fecd63a..e552171c9 100644 --- a/app/src/main/res/layout/fragment_module_md2.xml +++ b/app/src/main/res/layout/fragment_module_md2.xml @@ -15,145 +15,23 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:orientation="vertical" + android:paddingStart="@dimen/l1" + android:paddingTop="@{viewModel.insets.top + (int) @dimen/internal_action_bar_size + (int) @dimen/l1}" + android:paddingEnd="0dp" + android:paddingBottom="@{viewModel.insets.bottom}" + app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager" + app:spanCount="2" + tools:listitem="@layout/item_module_md2" /> \ No newline at end of file diff --git a/app/src/main/res/layout/item_repo_md2.xml b/app/src/main/res/layout/item_repo_md2.xml new file mode 100644 index 000000000..b3cba72e8 --- /dev/null +++ b/app/src/main/res/layout/item_repo_md2.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_section_md2.xml b/app/src/main/res/layout/item_section_md2.xml new file mode 100644 index 000000000..eb9b8e4cb --- /dev/null +++ b/app/src/main/res/layout/item_section_md2.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings_md2.xml b/app/src/main/res/values/strings_md2.xml index 1c132269e..116517ac8 100644 --- a/app/src/main/res/values/strings_md2.xml +++ b/app/src/main/res/values/strings_md2.xml @@ -75,6 +75,9 @@ You\'re in safe mode. None of user modules will work.\nThis message will disappear once safe mode is disabled. %1$s by %2$s + Pending changes + Active + Remote Toggles logging Toggles “toast” notifications