From 87e854cea6f292afe86da613179259788bb6b0a1 Mon Sep 17 00:00:00 2001 From: Norbel AMBANUMBEN Date: Tue, 5 Dec 2023 15:53:04 +0100 Subject: [PATCH] Fix: Dashboard glitch (#612) ## Proposed Changes - Replace `HeterogeneousRecyclerAdapter` with `RecyclerView.Adapter` - Add a `ViewModel` for state management --- .github/workflows/archive.yml | 2 +- .../ooniprobe/adapters/DashboardAdapter.kt | 105 +++++++++++++ .../ooniprobe/fragment/DashboardFragment.java | 140 ------------------ .../ooniprobe/fragment/DashboardFragment.kt | 120 +++++++++++++++ .../fragment/dashboard/DashboardViewModel.kt | 44 ++++++ .../ooniprobe/item/SeperatorItem.java | 1 + .../ooniprobe/item/TestsuiteItem.java | 1 + 7 files changed, 272 insertions(+), 141 deletions(-) create mode 100644 app/src/main/java/org/openobservatory/ooniprobe/adapters/DashboardAdapter.kt delete mode 100644 app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.java create mode 100644 app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.kt create mode 100644 app/src/main/java/org/openobservatory/ooniprobe/fragment/dashboard/DashboardViewModel.kt diff --git a/.github/workflows/archive.yml b/.github/workflows/archive.yml index 40344f610..b04392ac3 100644 --- a/.github/workflows/archive.yml +++ b/.github/workflows/archive.yml @@ -16,4 +16,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: dev-apk - path: app/build/outputs/apk/devFull/release \ No newline at end of file + path: app/build/outputs/apk/devFull/release diff --git a/app/src/main/java/org/openobservatory/ooniprobe/adapters/DashboardAdapter.kt b/app/src/main/java/org/openobservatory/ooniprobe/adapters/DashboardAdapter.kt new file mode 100644 index 000000000..26baea9f4 --- /dev/null +++ b/app/src/main/java/org/openobservatory/ooniprobe/adapters/DashboardAdapter.kt @@ -0,0 +1,105 @@ +package org.openobservatory.ooniprobe.adapters + +import android.content.res.Resources +import android.graphics.PorterDuff +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.cardview.widget.CardView +import androidx.recyclerview.widget.RecyclerView +import org.openobservatory.ooniprobe.R +import org.openobservatory.ooniprobe.common.PreferenceManager +import org.openobservatory.ooniprobe.databinding.ItemSeperatorBinding +import org.openobservatory.ooniprobe.databinding.ItemTestsuiteBinding +import org.openobservatory.ooniprobe.test.suite.AbstractSuite + +class DashboardAdapter( + private val items: List, + private val onClickListener: View.OnClickListener, + private val preferenceManager: PreferenceManager, +) : RecyclerView.Adapter() { + + companion object { + private const val VIEW_TYPE_TITLE = 0 + private const val VIEW_TYPE_CARD = 1 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + VIEW_TYPE_TITLE -> { + CardGroupTitleViewHolder( + ItemSeperatorBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + } + + else -> { + CardViewHolder( + ItemTestsuiteBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = items[position] + when (holder.itemViewType) { + VIEW_TYPE_TITLE -> { + } + + VIEW_TYPE_CARD -> { + val cardHolder = holder as CardViewHolder + if (item is AbstractSuite) { + cardHolder.binding.apply { + title.setText(item.title) + desc.setText(item.cardDesc) + icon.setImageResource(item.iconGradient) + } + holder.itemView.tag = item + if (item.isTestEmpty(preferenceManager)) { + holder.setIsRecyclable(false) + holder.itemView.apply { + elevation = 0f + isClickable = false + } + val resources: Resources = holder.itemView.context.resources + (holder.itemView as CardView).setCardBackgroundColor(resources.getColor(R.color.disabled_test_background)) + holder.binding.apply { + title.setTextColor(resources.getColor(R.color.disabled_test_text)) + desc.setTextColor(resources.getColor(R.color.disabled_test_text)) + icon.setColorFilter(resources.getColor(R.color.disabled_test_text), PorterDuff.Mode.SRC_IN) + } + } else { + holder.itemView.setOnClickListener(onClickListener) + } + } + } + } + } + + override fun getItemCount(): Int { + return items.size + } + + override fun getItemViewType(position: Int): Int { + return when (items[position]) { + is String -> VIEW_TYPE_TITLE + else -> VIEW_TYPE_CARD + } + } + + /** + * ViewHolder for dashboard item group + * @param binding + */ + class CardGroupTitleViewHolder(var binding: ItemSeperatorBinding) : RecyclerView.ViewHolder(binding.root) + + /** + * ViewHolder for dashboard item + * @param binding + */ + class CardViewHolder(var binding: ItemTestsuiteBinding) : RecyclerView.ViewHolder(binding.root) +} \ No newline at end of file diff --git a/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.java b/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.java deleted file mode 100644 index 7eee1ca50..000000000 --- a/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.java +++ /dev/null @@ -1,140 +0,0 @@ -package org.openobservatory.ooniprobe.fragment; - -import android.os.Bundle; -import android.text.format.DateUtils; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; - -import org.openobservatory.ooniprobe.R; -import org.openobservatory.ooniprobe.activity.AbstractActivity; -import org.openobservatory.ooniprobe.activity.OverviewActivity; -import org.openobservatory.ooniprobe.activity.RunningActivity; -import org.openobservatory.ooniprobe.common.Application; -import org.openobservatory.ooniprobe.common.PreferenceManager; -import org.openobservatory.ooniprobe.common.ReachabilityManager; -import org.openobservatory.ooniprobe.common.ThirdPartyServices; -import org.openobservatory.ooniprobe.databinding.FragmentDashboardBinding; -import org.openobservatory.ooniprobe.item.SeperatorItem; -import org.openobservatory.ooniprobe.item.TestsuiteItem; -import org.openobservatory.ooniprobe.model.database.Result; -import org.openobservatory.ooniprobe.test.TestAsyncTask; -import org.openobservatory.ooniprobe.test.suite.AbstractSuite; - -import java.util.ArrayList; - -import javax.inject.Inject; - -import localhost.toolkit.widget.recyclerview.HeterogeneousRecyclerAdapter; -import localhost.toolkit.widget.recyclerview.HeterogeneousRecyclerItem; - -public class DashboardFragment extends Fragment implements View.OnClickListener { - - @Inject - PreferenceManager preferenceManager; - - private ArrayList items; - - private ArrayList testSuites; - - private HeterogeneousRecyclerAdapter adapter; - - private FragmentDashboardBinding binding; - - @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - binding = FragmentDashboardBinding.inflate(inflater,container,false); - ((Application) getActivity().getApplication()).getFragmentComponent().inject(this); - ((AppCompatActivity) getActivity()).setSupportActionBar(binding.toolbar); - ((AppCompatActivity) getActivity()).getSupportActionBar().setTitle(null); - items = new ArrayList<>(); - testSuites = new ArrayList<>(); - adapter = new HeterogeneousRecyclerAdapter<>(getActivity(), items); - binding.recycler.setAdapter(adapter); - binding.recycler.setLayoutManager(new LinearLayoutManager(getActivity())); - binding.runAll.setOnClickListener(v1 -> runAll()); - binding.vpn.setOnClickListener(view -> ((Application) getActivity().getApplication()).openVPNSettings()); - return binding.getRoot(); - } - - @Override public void onResume() { - super.onResume(); - items.clear(); - testSuites.clear(); - testSuites.addAll(TestAsyncTask.getSuites()); - - ArrayList emptySuites = new ArrayList<>(); - for (AbstractSuite testSuite : testSuites){ - if(testSuite.getTestList(preferenceManager).length > 0){ - items.add(new TestsuiteItem(testSuite, this, preferenceManager)); - } else { - emptySuites.add(testSuite); - } - } - - if(!emptySuites.isEmpty()){ - items.add(new SeperatorItem()); - - for(AbstractSuite emptyTest: emptySuites) - items.add(new TestsuiteItem(emptyTest, this, preferenceManager)); - } - - - - setLastTest(); - adapter.notifyTypesChanged(); - if (ReachabilityManager.isVPNinUse(this.getContext()) - && preferenceManager.isWarnVPNInUse()) - binding.vpn.setVisibility(View.VISIBLE); - else - binding.vpn.setVisibility(View.GONE); - } - - private void setLastTest() { - Result lastResult = Result.getLastResult(); - if (lastResult == null) - binding.lastTested.setText(getString(R.string.Dashboard_Overview_LatestTest) - + " " + - getString(R.string.Dashboard_Overview_LastRun_Never)); - else - binding.lastTested.setText(getString(R.string.Dashboard_Overview_LatestTest) - + " " + - DateUtils.getRelativeTimeSpanString(lastResult.start_time.getTime())); - } - - public void runAll() { - RunningActivity.runAsForegroundService((AbstractActivity) getActivity(), testSuites, this::onTestServiceStartedListener, preferenceManager); - } - - private void onTestServiceStartedListener() { - try { - ((AbstractActivity) getActivity()).bindTestService(); - } catch (Exception e) { - e.printStackTrace(); - ThirdPartyServices.logException(e); - } - } - - @Override public void onClick(View v) { - AbstractSuite testSuite = (AbstractSuite) v.getTag(); - switch (v.getId()) { - case R.id.run: - RunningActivity.runAsForegroundService( - (AbstractActivity) getActivity(), - testSuite.asArray(), - this::onTestServiceStartedListener, - preferenceManager - ); - break; - default: - ActivityCompat.startActivity(getActivity(), OverviewActivity.newIntent(getActivity(), testSuite), null); - break; - } - } -} diff --git a/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.kt b/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.kt new file mode 100644 index 000000000..a973b1cee --- /dev/null +++ b/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.kt @@ -0,0 +1,120 @@ +package org.openobservatory.ooniprobe.fragment + +import android.os.Bundle +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import org.openobservatory.ooniprobe.R +import org.openobservatory.ooniprobe.activity.AbstractActivity +import org.openobservatory.ooniprobe.activity.OverviewActivity +import org.openobservatory.ooniprobe.activity.RunningActivity +import org.openobservatory.ooniprobe.adapters.DashboardAdapter +import org.openobservatory.ooniprobe.common.Application +import org.openobservatory.ooniprobe.common.PreferenceManager +import org.openobservatory.ooniprobe.common.ReachabilityManager +import org.openobservatory.ooniprobe.common.ThirdPartyServices +import org.openobservatory.ooniprobe.databinding.FragmentDashboardBinding +import org.openobservatory.ooniprobe.fragment.dashboard.DashboardViewModel +import org.openobservatory.ooniprobe.model.database.Result +import org.openobservatory.ooniprobe.test.suite.AbstractSuite +import javax.inject.Inject + +class DashboardFragment : Fragment(), View.OnClickListener { + @Inject + lateinit var preferenceManager: PreferenceManager + + @Inject + lateinit var viewModel: DashboardViewModel + private var testSuites: ArrayList = ArrayList() + private lateinit var binding: FragmentDashboardBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentDashboardBinding.inflate(inflater, container, false) + (requireActivity().application as Application).fragmentComponent.inject(this) + (requireActivity() as AppCompatActivity).apply { + setSupportActionBar(binding.toolbar) + supportActionBar?.title = null + } + binding.apply { + runAll.setOnClickListener { _: View? -> runAll() } + vpn.setOnClickListener { _: View? -> (requireActivity().application as Application).openVPNSettings() } + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.getGroupedItemList().observe(viewLifecycleOwner) { items -> + binding.recycler.layoutManager = LinearLayoutManager(requireContext()) + binding.recycler.adapter = DashboardAdapter(items, this, preferenceManager) + } + + viewModel.items.observe(viewLifecycleOwner) { items -> + testSuites.apply { + clear() + addAll(items) + } + } + } + + override fun onResume() { + super.onResume() + setLastTest() + if (ReachabilityManager.isVPNinUse(this.context) + && preferenceManager.isWarnVPNInUse + ) binding.vpn.visibility = View.VISIBLE else binding.vpn.visibility = View.GONE + } + + private fun setLastTest() { + val lastResult = Result.getLastResult() + if (lastResult == null) { + (getString(R.string.Dashboard_Overview_LatestTest) + " " + getString(R.string.Dashboard_Overview_LastRun_Never)) + .also { binding.lastTested.text = it } + } else { + (getString(R.string.Dashboard_Overview_LatestTest) + " " + DateUtils.getRelativeTimeSpanString(lastResult.start_time.time)) + .also { binding.lastTested.text = it } + } + } + + private fun runAll() { + RunningActivity.runAsForegroundService( + activity as AbstractActivity?, + testSuites, + { onTestServiceStartedListener() }, + preferenceManager + ) + } + + private fun onTestServiceStartedListener() = try { + (requireActivity() as AbstractActivity).bindTestService() + } catch (e: Exception) { + e.printStackTrace() + ThirdPartyServices.logException(e) + } + + override fun onClick(v: View) { + val testSuite = v.tag as AbstractSuite + when (v.id) { + R.id.run -> RunningActivity.runAsForegroundService( + activity as AbstractActivity?, + testSuite.asArray(), { onTestServiceStartedListener() }, + preferenceManager + ) + + else -> ActivityCompat.startActivity( + requireActivity(), + OverviewActivity.newIntent(activity, testSuite), + null + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/openobservatory/ooniprobe/fragment/dashboard/DashboardViewModel.kt b/app/src/main/java/org/openobservatory/ooniprobe/fragment/dashboard/DashboardViewModel.kt new file mode 100644 index 000000000..009a3cab0 --- /dev/null +++ b/app/src/main/java/org/openobservatory/ooniprobe/fragment/dashboard/DashboardViewModel.kt @@ -0,0 +1,44 @@ +package org.openobservatory.ooniprobe.fragment.dashboard + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import org.openobservatory.ooniprobe.common.PreferenceManager +import org.openobservatory.ooniprobe.test.TestAsyncTask +import org.openobservatory.ooniprobe.test.suite.AbstractSuite +import javax.inject.Inject + +class DashboardViewModel @Inject constructor(private val preferenceManager: PreferenceManager) : ViewModel() { + private val enabledTitle: String = "Enabled" + private val groupedItemList = MutableLiveData>() + val items = MutableLiveData>(TestAsyncTask.getSuites()) + + fun getGroupedItemList(): LiveData> { + if (groupedItemList.value == null) { + fetchItemList() + } + return groupedItemList + } + + private fun fetchItemList() { + + val groupedItems = items.value!!.sortedBy { it.getTestList(preferenceManager).isEmpty() } + .groupBy { + return@groupBy if ((it.getTestList(preferenceManager).isNotEmpty())) { + enabledTitle + } else { + "" + } + } + + val groupedItemList = mutableListOf() + groupedItems.forEach { (status, itemList) -> + if (status != enabledTitle){ + groupedItemList.add(status) + } + groupedItemList.addAll(itemList) + } + + this.groupedItemList.value = groupedItemList + } +} \ No newline at end of file diff --git a/app/src/main/java/org/openobservatory/ooniprobe/item/SeperatorItem.java b/app/src/main/java/org/openobservatory/ooniprobe/item/SeperatorItem.java index 70c971b22..c303f2b07 100644 --- a/app/src/main/java/org/openobservatory/ooniprobe/item/SeperatorItem.java +++ b/app/src/main/java/org/openobservatory/ooniprobe/item/SeperatorItem.java @@ -9,6 +9,7 @@ import localhost.toolkit.widget.recyclerview.HeterogeneousRecyclerItem; +@Deprecated public class SeperatorItem extends HeterogeneousRecyclerItem { public SeperatorItem() { diff --git a/app/src/main/java/org/openobservatory/ooniprobe/item/TestsuiteItem.java b/app/src/main/java/org/openobservatory/ooniprobe/item/TestsuiteItem.java index 9eb8bf60b..a334a751b 100644 --- a/app/src/main/java/org/openobservatory/ooniprobe/item/TestsuiteItem.java +++ b/app/src/main/java/org/openobservatory/ooniprobe/item/TestsuiteItem.java @@ -13,6 +13,7 @@ import org.openobservatory.ooniprobe.databinding.ItemTestsuiteBinding; import org.openobservatory.ooniprobe.test.suite.AbstractSuite; +@Deprecated public class TestsuiteItem extends HeterogeneousRecyclerItem { private final View.OnClickListener onClickListener; private final PreferenceManager preferenceManager;