+ * This method is called after the {@link ManualUpdateDescriptorsWorker} is enqueued.
+ * The {@link ManualUpdateDescriptorsWorker} task is to fetch updates for the descriptors.
+ *
+ * This method is called after the {@link ManualUpdateDescriptorsWorker} is enqueued.
+ * The {@link ManualUpdateDescriptorsWorker} task is to fetch updates for the descriptors.
+ *
+ * If the task is successful, the {@link WorkInfo} object will contain the updated descriptors.
+ * Otherwise, the {@link WorkInfo} object will be null.
+ *
+ * @param workInfo The {@link WorkInfo} of the task.
+ */
+ private void onManualUpdatesFetchComplete(WorkInfo workInfo) {
+ if (workInfo != null) {
+ switch (workInfo.getState()) {
+ case SUCCEEDED -> {
+ binding.reviewUpdates.setVisibility(View.VISIBLE);
+ binding.reviewUpdates.setOnClickListener(view -> getReviewUpdatesLauncher().launch(
+ ReviewDescriptorUpdatesActivity.newIntent(
+ OverviewActivity.this,
+ workInfo.getOutputData().getString(ManualUpdateDescriptorsWorker.KEY_UPDATED_DESCRIPTORS)
+ )
+ ));
+ binding.swipeRefresh.setRefreshing(false);
+ }
+
+ case FAILED -> {
+ binding.swipeRefresh.setRefreshing(false);
+ Snackbar.make(
+ binding.getRoot(),
+ R.string.Modal_Error,
+ Snackbar.LENGTH_LONG
+ ).show();
+ }
+
+ default -> {
+ }
+ }
+ }
}
@Override
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/ReviewUpdatesAbstractActivity.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/ReviewUpdatesAbstractActivity.kt
new file mode 100644
index 000000000..5adff209b
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/ReviewUpdatesAbstractActivity.kt
@@ -0,0 +1,31 @@
+package org.openobservatory.ooniprobe.activity
+
+import android.content.Intent
+import android.util.Log
+import android.view.View
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import com.google.android.material.snackbar.Snackbar
+import org.openobservatory.ooniprobe.activity.reviewdescriptorupdates.ReviewDescriptorUpdatesActivity
+
+open class ReviewUpdatesAbstractActivity : AbstractActivity() {
+ var reviewUpdatesLauncher: ActivityResultLauncher? = null
+
+ fun registerReviewLauncher(view: View, reviewCompletedListener: () -> Unit?) {
+ reviewUpdatesLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult())
+ { result: ActivityResult ->
+ if (result.resultCode == RESULT_OK) {
+ reviewCompletedListener()
+ result.data?.let { intent: Intent ->
+ intent.getStringExtra(ReviewDescriptorUpdatesActivity.RESULT_MESSAGE)
+ ?.let { message: String ->
+ Snackbar.make(view, message, Snackbar.LENGTH_LONG)
+ .setAnchorView(view).show()
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/AddDescriptorActivity.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/AddDescriptorActivity.kt
index 5b2a0a8d8..6fedc19fd 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/AddDescriptorActivity.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/AddDescriptorActivity.kt
@@ -16,9 +16,6 @@ import androidx.databinding.BindingAdapter
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.noties.markwon.Markwon
-import org.openobservatory.engine.BaseNettest
-import org.openobservatory.engine.OONIRunDescriptor
-import org.openobservatory.engine.OONIRunNettest
import org.openobservatory.ooniprobe.R
import org.openobservatory.ooniprobe.activity.AbstractActivity
import org.openobservatory.ooniprobe.activity.MainActivity
@@ -85,17 +82,19 @@ class AddDescriptorActivity : AbstractActivity() {
* @param iconName is the name of the drawable resource
*/
@JvmStatic
- @BindingAdapter(value = ["resource"])
- fun setImageViewResource(imageView: ImageView, iconName: String?) {
- /* TODO(aanorbel): Update to parse the icon name and set the correct icon.
- * Remember to ignore icons generated when generated doing this.*/
+ @BindingAdapter(value = ["resource","color"])
+ fun setImageViewResource(imageView: ImageView, iconName: String?, color: Int?) {
imageView.setImageResource(
imageView.context.resources.getIdentifier(
StringUtils.camelToSnake(
iconName
), "drawable", imageView.context.packageName
)
- )
+ ).apply {
+ color?.let {
+ imageView.setColorFilter(it)
+ }
+ }
}
}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/AddDescriptorViewModel.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/AddDescriptorViewModel.kt
index 90d25ec08..e0362696c 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/AddDescriptorViewModel.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/AddDescriptorViewModel.kt
@@ -1,11 +1,11 @@
package org.openobservatory.ooniprobe.activity.adddescriptor
+import android.graphics.Color
+import androidx.annotation.ColorInt
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.checkbox.MaterialCheckBox.CheckedState
-import org.openobservatory.engine.OONIRunDescriptor
-import org.openobservatory.engine.OONIRunNettest
import org.openobservatory.ooniprobe.activity.adddescriptor.adapter.GroupedItem
import org.openobservatory.ooniprobe.common.LocaleUtils
import org.openobservatory.ooniprobe.common.TestDescriptorManager
@@ -38,6 +38,10 @@ class AddDescriptorViewModel constructor(
this.descriptor.value = descriptor
}
+ @ColorInt
+ fun getColor(): Int {
+ return Color.parseColor( descriptor.value?.color ?: "#495057")
+ }
/**
* This method is used to get the name of the descriptor.
* Used by the UI during data binding.
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/oonirun/OoniRunV2Activity.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/oonirun/OoniRunV2Activity.kt
index 5b4dc61bc..b5a092621 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/oonirun/OoniRunV2Activity.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/oonirun/OoniRunV2Activity.kt
@@ -134,7 +134,7 @@ class OoniRunV2Activity : AbstractActivity() {
descriptorResponse?.let {
startActivity(AddDescriptorActivity.newIntent(this, descriptorResponse))
} ?: run {
- Toast.makeText(this, getString(R.string.Modal_Error), Toast.LENGTH_LONG).show()
+ finishWithError()
}
}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/reviewdescriptorupdates/ReviewDescriptorUpdatesActivity.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/reviewdescriptorupdates/ReviewDescriptorUpdatesActivity.kt
new file mode 100644
index 000000000..ebe32bd45
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/reviewdescriptorupdates/ReviewDescriptorUpdatesActivity.kt
@@ -0,0 +1,364 @@
+package org.openobservatory.ooniprobe.activity.reviewdescriptorupdates
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.BaseExpandableListAdapter
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
+import com.google.android.material.checkbox.MaterialCheckBox
+import com.google.android.material.snackbar.Snackbar
+import com.google.gson.Gson
+import com.google.gson.internal.LinkedTreeMap
+import org.openobservatory.engine.BaseNettest
+import org.openobservatory.ooniprobe.R
+import org.openobservatory.ooniprobe.activity.AbstractActivity
+import org.openobservatory.ooniprobe.common.TestDescriptorManager
+import org.openobservatory.ooniprobe.databinding.ActivityReviewDescriptorUpdatesBinding
+import org.openobservatory.ooniprobe.databinding.FragmentDescriptorUpdateBinding
+import org.openobservatory.ooniprobe.model.database.ITestDescriptor
+import org.openobservatory.ooniprobe.model.database.InstalledDescriptor
+import org.openobservatory.ooniprobe.model.database.TestDescriptor
+import org.openobservatory.ooniprobe.test.test.AbstractTest
+import java.text.SimpleDateFormat
+import java.util.Locale
+import javax.inject.Inject
+
+/**
+ * This activity is used to review the updates of the descriptors.
+ * When a new update is available, the user is prompted to review the changes.
+ * This activity is started by the [org.openobservatory.ooniprobe.activity.MainActivity] activity.
+ */
+class ReviewDescriptorUpdatesActivity : AbstractActivity() {
+
+ companion object {
+ private const val DESCRIPTORS = "descriptors"
+
+ @JvmField
+ var RESULT_MESSAGE = "result"
+
+ /**
+ * This method is used to create an intent to start this activity.
+ * @param context is the context of the activity that calls this method
+ * @param descriptors is the descriptors to review
+ * @return an intent to start this activity
+ */
+ @JvmStatic
+ fun newIntent(context: Context, descriptors: String?): Intent {
+ return Intent(context, ReviewDescriptorUpdatesActivity::class.java).putExtra(
+ DESCRIPTORS,
+ descriptors
+ )
+ }
+ }
+
+ @Inject
+ lateinit var descriptorManager: TestDescriptorManager
+
+ @Inject
+ lateinit var gson: Gson
+
+ private lateinit var reviewUpdatesPagingAdapter: ReviewUpdatesPagingAdapter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ activityComponent.inject(this)
+ val binding = ActivityReviewDescriptorUpdatesBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ setSupportActionBar(binding.toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(false)
+ supportActionBar?.setDisplayShowHomeEnabled(false)
+ supportActionBar?.title = "Link Update"
+ val descriptorJson = intent.getStringExtra(DESCRIPTORS)
+ try {
+ /**
+ * **[descriptorJson]** is the json string of the intent.
+ * **[descriptors]** is the list of [TestDescriptor] objects obtained from **[descriptorJson]**.
+ * Because [TestDescriptor.nettests] is of type [Any], the gson library converts it to a [LinkedTreeMap].
+ */
+ val descriptors: List =
+ gson.fromJson(descriptorJson, Array::class.java)
+ .map { it.toTestDescriptor() }
+
+ // Disable swipe behavior of viewpager
+ binding.viewpager.isUserInputEnabled = false
+
+ reviewUpdatesPagingAdapter = ReviewUpdatesPagingAdapter(this, descriptors)
+ binding.viewpager.adapter = reviewUpdatesPagingAdapter
+
+ /**
+ * The bottom bar menu item click listener.
+ * When the user clicks on the update button, the viewpager is swiped to the next page.
+ * When the user clicks on the last update, the activity is finished.
+ */
+ val bottomBarOnMenuItemClickListener: Toolbar.OnMenuItemClickListener =
+ Toolbar.OnMenuItemClickListener { item ->
+ when (item.itemId) {
+ R.id.update_descriptor -> {
+ descriptorManager.updateFromNetwork(descriptors[binding.viewpager.currentItem])
+ /**
+ * **[currPos]** is the current position of the viewpager.
+ * If the current position is not the last position, the viewpager is swiped to the next page.
+ * If the current position is the last position, the last update is saved in the shared preferences and the activity is finished.
+ */
+ val currPos: Int = binding.viewpager.currentItem
+ if ((currPos + 1) != binding.viewpager.adapter?.itemCount) {
+ binding.viewpager.currentItem = currPos + 1
+ } else {
+ setResult(RESULT_OK, Intent().putExtra(RESULT_MESSAGE, "Link(s) updated"))
+ finish()
+ }
+ true
+ }
+
+ else -> false
+ }
+ }
+ binding.bottomBar.setOnMenuItemClickListener(bottomBarOnMenuItemClickListener)
+
+ /**
+ * The viewpager page change callback.
+ * When the user swipes to the next page, the bottom bar menu item title is updated.
+ */
+ binding.viewpager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
+ override fun onPageSelected(position: Int) {
+ binding.bottomBar.menu.findItem(R.id.update_descriptor)
+ ?.let {
+ val countString =
+ "(${position + 1} of ${binding.viewpager.adapter?.itemCount})"
+ supportActionBar?.title = "Link Update $countString"
+ it.title = if ((position + 1) != binding.viewpager.adapter?.itemCount) {
+ "UPDATE $countString"
+ } else {
+ "UPDATE AND FINISH $countString"
+ }
+ }
+
+ }
+ })
+
+ } catch (e: Exception) {
+ finish()
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ val inflater: MenuInflater = menuInflater
+ inflater.inflate(R.menu.close, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.close_button -> {
+ finish()
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+}
+
+/**
+ * This adapter is used to display the list of descriptors in the viewpager.
+ * @param fragmentActivity is the activity that contains the viewpager.
+ * @param descriptors is the list of descriptors to display.
+ */
+class ReviewUpdatesPagingAdapter(
+ fragmentActivity: FragmentActivity,
+ private val descriptors: List
+) : FragmentStateAdapter(fragmentActivity) {
+ override fun getItemCount(): Int = descriptors.size
+
+ override fun createFragment(position: Int): Fragment {
+ val fragment = DescriptorUpdateFragment()
+ fragment.arguments = Bundle().apply {
+ putSerializable(DESCRIPTOR, descriptors[position])
+ }
+ return fragment
+ }
+}
+
+private const val DESCRIPTOR = "descriptor"
+
+/**
+ * This fragment is used to display the details of a descriptor.
+ * It is used by [ReviewUpdatesPagingAdapter].
+ * @param descriptor is the descriptor to display.
+ */
+class DescriptorUpdateFragment : Fragment() {
+
+ private lateinit var binding: FragmentDescriptorUpdateBinding
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = FragmentDescriptorUpdateBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ arguments?.takeIf { it.containsKey(DESCRIPTOR) }?.apply {
+ val descriptor: TestDescriptor =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getSerializable(DESCRIPTOR, TestDescriptor::class.java)!!
+ } else {
+ getSerializable(DESCRIPTOR) as TestDescriptor
+ }
+ val absDescriptor = InstalledDescriptor(descriptor)
+ binding.apply {
+ title.text = absDescriptor.title
+ author.text = "Created by ${descriptor.author} on ${
+ SimpleDateFormat(
+ "MMM dd, yyyy",
+ Locale.getDefault()
+ ).format(descriptor.descriptorCreationTime)
+ }"
+ description.text = absDescriptor.description // Use markdown
+ icon.setImageResource(absDescriptor.getDisplayIcon(requireContext()))
+ val adapter =
+ ReviewDescriptorExpandableListAdapter(nettests = absDescriptor.nettests)
+ expandableListView.setAdapter(adapter)
+ // Expand all groups
+ for (i in 0 until adapter.groupCount) {
+ expandableListView.expandGroup(i)
+ }
+ }
+ }
+ }
+}
+
+/**
+ * This adapter is used to display the list of nettests in the expandable list view.
+ * It is used by [DescriptorUpdateFragment] to display the list of nettests.
+ * @param nettests is the list of nettests to display.
+ */
+class ReviewDescriptorExpandableListAdapter(
+ val nettests: List,
+) : BaseExpandableListAdapter() {
+
+ /**
+ * @return Number of groups in the list.
+ */
+ override fun getGroupCount(): Int = nettests.size
+
+ /**
+ * @param groupPosition Position of the group in the list.
+ * @return Number of children in the group.
+ */
+ override fun getChildrenCount(groupPosition: Int): Int =
+ nettests[groupPosition].inputs?.size ?: 0
+
+ /**
+ * @param groupPosition Position of the group in the list.
+ * @return [BaseNettest] object.
+ */
+ override fun getGroup(groupPosition: Int): BaseNettest = nettests[groupPosition]
+
+ /**
+ * @param groupPosition Position of the group in the list.
+ * @param childPosition Position of the child in the group.
+ * @return string item at position.
+ */
+ override fun getChild(groupPosition: Int, childPosition: Int): String? =
+ nettests[groupPosition].inputs?.get(childPosition)
+
+ /**
+ * @param groupPosition Position of the group in the list.
+ * @return Group position.
+ */
+ override fun getGroupId(groupPosition: Int): Long = groupPosition.toLong()
+
+ /**
+ * @param groupPosition Position of the group in the list.
+ * @param childPosition Position of the child in the group.
+ * @return Child position.
+ */
+ override fun getChildId(groupPosition: Int, childPosition: Int): Long = childPosition.toLong()
+
+ /**
+ * @return true if the same ID always refers to the same object.
+ */
+ override fun hasStableIds(): Boolean = false
+
+ /**
+ * @param groupPosition Position of the group in the list.
+ * @param isExpanded true if the group is expanded.
+ * @param convertView View of the group.
+ * @param parent Parent view.
+ * @return View of the group.
+ */
+ override fun getGroupView(
+ groupPosition: Int,
+ isExpanded: Boolean,
+ convertView: View?,
+ parent: ViewGroup,
+ ): View {
+ val view = convertView ?: LayoutInflater.from(parent.context)
+ .inflate(R.layout.nettest_group_list_item, parent, false)
+ val groupItem = getGroup(groupPosition)
+ val groupIndicator = view.findViewById(R.id.group_indicator)
+
+ val abstractNettest = AbstractTest.getTestByName(groupItem.name)
+ view.findViewById(R.id.group_name).text =
+ when (abstractNettest.labelResId == R.string.Test_Experimental_Fullname) {
+ true -> groupItem.name
+ false -> parent.context.resources.getText(abstractNettest.labelResId)
+ }
+
+ val groupCheckBox = view.findViewById(R.id.groupCheckBox)
+ groupCheckBox.visibility = View.GONE
+ if (groupItem.inputs?.isNotEmpty() == true) {
+ if (isExpanded) {
+ groupIndicator.setImageResource(R.drawable.expand_less)
+ } else {
+ groupIndicator.setImageResource(R.drawable.expand_more)
+ }
+ } else {
+ groupIndicator.visibility = View.INVISIBLE
+ }
+
+ return view
+ }
+
+ /**
+ * @param groupPosition Position of the group in the list.
+ * @param childPosition Position of the child in the group.
+ * @param isLastChild True if the child is the last child in the group.
+ * @param convertView View object.
+ * @param parent ViewGroup object.
+ * @return View object.
+ */
+ override fun getChildView(
+ groupPosition: Int,
+ childPosition: Int,
+ isLastChild: Boolean,
+ convertView: View?,
+ parent: ViewGroup
+ ): View {
+ val view = convertView ?: LayoutInflater.from(parent.context)
+ .inflate(R.layout.nettest_child_list_item, parent, false)
+
+ view.findViewById(R.id.text).apply {
+ text = getChild(groupPosition, childPosition)
+ }
+ return view
+ }
+
+ override fun isChildSelectable(groupPosition: Int, childPosition: Int): Boolean = false
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/adapter/RunTestsExpandableListViewAdapter.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/adapter/RunTestsExpandableListViewAdapter.kt
index 59fe79b54..5c94b71c3 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/adapter/RunTestsExpandableListViewAdapter.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/adapter/RunTestsExpandableListViewAdapter.kt
@@ -8,12 +8,11 @@ import android.widget.ImageView
import android.widget.TextView
import org.openobservatory.ooniprobe.R
import org.openobservatory.ooniprobe.activity.runtests.RunTestsViewModel
-import org.openobservatory.ooniprobe.activity.runtests.RunTestsViewModel.Companion.SELECT_NONE
import org.openobservatory.ooniprobe.activity.runtests.RunTestsViewModel.Companion.SELECT_ALL
+import org.openobservatory.ooniprobe.activity.runtests.RunTestsViewModel.Companion.SELECT_NONE
import org.openobservatory.ooniprobe.activity.runtests.RunTestsViewModel.Companion.SELECT_SOME
import org.openobservatory.ooniprobe.activity.runtests.models.ChildItem
import org.openobservatory.ooniprobe.activity.runtests.models.GroupItem
-import org.openobservatory.ooniprobe.common.OONITests
import org.openobservatory.ooniprobe.test.test.AbstractTest
@@ -83,7 +82,9 @@ class RunTestsExpandableListViewAdapter(
convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.run_tests_group_list_item, parent, false)
val groupItem = getGroup(groupPosition)
convertView.findViewById(R.id.group_name).text = groupItem.title
- convertView.findViewById(R.id.group_icon).setImageResource(groupItem.getDisplayIcon(parent.context))
+ val icon = convertView.findViewById(R.id.group_icon)
+ icon.setImageResource(groupItem.getDisplayIcon(parent.context))
+ icon.setColorFilter(groupItem.color)
val groupIndicator = convertView.findViewById(R.id.group_indicator)
val groupSelectionIndicator = convertView.findViewById(R.id.group_select_indicator)
val selectedAllBtnStatus = viewModel.selectedAllBtnStatus.getValue()
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/adapters/DashboardAdapter.kt b/app/src/main/java/org/openobservatory/ooniprobe/adapters/DashboardAdapter.kt
index a731b0cff..de625836c 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/adapters/DashboardAdapter.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/adapters/DashboardAdapter.kt
@@ -62,8 +62,10 @@ class DashboardAdapter(
icon.setImageResource(item.getDisplayIcon(holder.itemView.context)).also {
if (item is InstalledDescriptor){
icon.setColorFilter(item.color)
+ holder.setIsRecyclable(false)
}
- } }
+ }
+ }
holder.itemView.tag = item
if (!item.isEnabled(preferenceManager)) {
holder.setIsRecyclable(false)
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/common/OONIDescriptor.kt b/app/src/main/java/org/openobservatory/ooniprobe/common/OONIDescriptor.kt
index 7d446fdb9..e67604108 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/common/OONIDescriptor.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/common/OONIDescriptor.kt
@@ -42,7 +42,7 @@ abstract class AbstractDescriptor(
* @param preferenceManager The [PreferenceManager] instance used to resolve the status of each nettest.
* @return Boolean Returns true if at least one nettest is enabled, false otherwise.
*/
- open fun isEnabled(preferenceManager: PreferenceManager): Boolean {
+ open fun isEnabled(preferenceManager: PreferenceManager): Boolean {
return when (name) {
OONITests.EXPERIMENTAL.label -> preferenceManager.isExperimentalOn
OONITests.WEBSITES.label -> preferenceManager.countEnabledCategory() > 0
@@ -136,7 +136,7 @@ abstract class AbstractDescriptor(
)
} ?: listOf())
},
- descriptor = descriptor
+ descriptor = this.descriptor
)
}
@@ -171,8 +171,8 @@ abstract class AbstractDescriptor(
* @return String representing the preference prefix.
*/
fun preferencePrefix(): String {
- return when (descriptor?.runId != null) {
- true -> descriptor?.preferencePrefix() ?: ""
+ return when (this.descriptor?.runId != null) {
+ true -> this.descriptor?.preferencePrefix() ?: ""
else -> ""
}
}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/common/TestDescriptorManager.kt b/app/src/main/java/org/openobservatory/ooniprobe/common/TestDescriptorManager.kt
index d0e301ec8..338ec6249 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/common/TestDescriptorManager.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/common/TestDescriptorManager.kt
@@ -100,6 +100,11 @@ class TestDescriptorManager @Inject constructor(
return descriptor.save()
}
+ fun getById(runId: Long): TestDescriptor? {
+ return SQLite.select().from(TestDescriptor::class.java)
+ .where(TestDescriptor_Table.runId.eq(runId)).querySingle()
+ }
+
fun getRunV2Descriptors(): List {
return SQLite.select().from(TestDescriptor::class.java)
.where(TestDescriptor_Table.isArchived.eq(false)).queryList()
@@ -117,4 +122,29 @@ class TestDescriptorManager @Inject constructor(
descriptor.testDescriptor.delete()
}
}
+
+ fun getDescriptorWithAutoUpdateEnabled(): List {
+ return SQLite.select().from(TestDescriptor::class.java)
+ .where(TestDescriptor_Table.auto_update.eq(true)).queryList()
+ }
+
+ fun getDescriptorWithAutoUpdateDisabled(): List {
+ return SQLite.select().from(TestDescriptor::class.java)
+ .where(TestDescriptor_Table.auto_update.eq(false)).queryList()
+ }
+
+ fun updateFromNetwork(testDescriptor: TestDescriptor): Boolean {
+ getById(testDescriptor.runId)?.let { descriptor ->
+ testDescriptor.isAutoUpdate = descriptor.isAutoUpdate
+ return testDescriptor.save()
+ } ?: run {
+ return false
+ }
+ }
+
+ fun getDescriptorsFromIds(ids: Array): List {
+ return SQLite.select().from(TestDescriptor::class.java)
+ .where(TestDescriptor_Table.runId.`in`(ids.toList()))
+ .queryList()
+ }
}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/common/worker/UpdateDescriptorsWorker.kt b/app/src/main/java/org/openobservatory/ooniprobe/common/worker/UpdateDescriptorsWorker.kt
new file mode 100644
index 000000000..ae9d7135b
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/common/worker/UpdateDescriptorsWorker.kt
@@ -0,0 +1,173 @@
+package org.openobservatory.ooniprobe.common.worker
+
+import android.content.Context
+import android.util.Log
+import androidx.work.Data
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import org.openobservatory.ooniprobe.common.Application
+import org.openobservatory.ooniprobe.common.PreferenceManager
+import org.openobservatory.ooniprobe.common.TestDescriptorManager
+import org.openobservatory.ooniprobe.common.ThirdPartyServices
+import org.openobservatory.ooniprobe.model.database.TestDescriptor
+import org.openobservatory.ooniprobe.model.database.shouldUpdate
+import javax.inject.Inject
+
+var d: UpdateDescriptorsWorkerDependencies = UpdateDescriptorsWorkerDependencies()
+const val PROGRESS = "PROGRESS"
+
+class AutoUpdateDescriptorsWorker(
+ context: Context,
+ workerParams: WorkerParameters
+) : Worker(context, workerParams) {
+
+ override fun doWork(): Result {
+ val app = applicationContext.applicationContext as Application
+ app.serviceComponent.inject(d)
+ return try {
+ Log.d(TAG, "Fetching descriptors from input")
+
+ val updatedDescriptors: ArrayList = ArrayList()
+
+ for (descriptor in d.testDescriptorManager.getDescriptorWithAutoUpdateEnabled()) {
+ Log.d(TAG, "Fetching updates for ${descriptor.runId}")
+
+ val updatedDescriptor: TestDescriptor =
+ d.testDescriptorManager.fetchDescriptorFromRunId(
+ descriptor.runId,
+ applicationContext
+ )
+
+ if (descriptor.shouldUpdate(updatedDescriptor)) {
+ updatedDescriptor.isAutoUpdate = descriptor.isAutoUpdate
+ updatedDescriptor.isAutoRun = descriptor.isAutoRun
+
+ Log.d(TAG, "Saving updates for ${descriptor.runId}")
+
+ updatedDescriptor.save()
+ updatedDescriptors.add(updatedDescriptor)
+ }
+ }
+
+ val outputData = Data.Builder()
+ .putString(
+ KEY_UPDATED_DESCRIPTORS,
+ (applicationContext as Application).gson.toJson(updatedDescriptors)
+ ).build()
+
+ Log.e(TAG, "Descriptor updates complete")
+
+ Result.success(outputData)
+
+ } catch (exception: Exception) {
+ Log.e(TAG, "Error Updating")
+ exception.printStackTrace()
+ ThirdPartyServices.logException(exception)
+ Result.failure()
+ }
+ }
+
+ companion object {
+ @JvmField
+ var UPDATED_DESCRIPTORS_WORK_NAME =
+ "${AutoUpdateDescriptorsWorker::class.java.name}.UPDATED_DESCRIPTORS_WORK_NAME"
+
+ private val TAG = AutoUpdateDescriptorsWorker::class.java.simpleName
+
+ private val UPDATE_DESCRIPTOR_CHANNEL: String =
+ AutoUpdateDescriptorsWorker::class.java.simpleName
+
+ private val KEY_UPDATED_DESCRIPTORS =
+ "${AutoUpdateDescriptorsWorker::class.java.name}.KEY_UPDATED_DESCRIPTORS"
+ }
+}
+
+class ManualUpdateDescriptorsWorker(
+ context: Context,
+ workerParams: WorkerParameters
+) : Worker(context, workerParams) {
+
+ override fun doWork(): Result {
+
+ setProgressAsync(Data.Builder().putInt(PROGRESS, 0).build())
+
+ val app = applicationContext.applicationContext as Application
+ app.serviceComponent.inject(d)
+
+ return try {
+ Log.d(TAG, "Fetching descriptors from input")
+
+ val updatedDescriptors: ArrayList = ArrayList()
+
+ val descriptors = inputData.getLongArray(KEY_DESCRIPTOR_IDS)?.let {
+ d.testDescriptorManager.getDescriptorsFromIds(it.toTypedArray())
+ }.run {
+ d.testDescriptorManager.getDescriptorWithAutoUpdateDisabled()
+ }
+
+ if(descriptors.isEmpty()) {
+ Log.e(TAG, "No descriptors to update")
+ return Result.success()
+ }
+
+ for (descriptor in descriptors) {
+ Log.d(TAG, "Fetching updates for ${descriptor.runId}")
+
+ val updatedDescriptor: TestDescriptor =
+ d.testDescriptorManager.fetchDescriptorFromRunId(
+ descriptor.runId,
+ applicationContext
+ )
+ /**
+ * NOTE(aanorbel): Refine this logic to only update if the descriptor has changed.
+ * Consider explicit version compare.
+ */
+ if (descriptor.shouldUpdate(updatedDescriptor)) {
+ updatedDescriptors.add(updatedDescriptor)
+ }
+ }
+ val outputData = Data.Builder()
+ .putString(
+ KEY_UPDATED_DESCRIPTORS,
+ (applicationContext as Application).gson.toJson(updatedDescriptors)
+ ).build()
+
+ Log.e(TAG, "fetching updates complete")
+
+ setProgressAsync(Data.Builder().putInt(PROGRESS, 100).build())
+ Result.success(outputData)
+
+ } catch (exception: Exception) {
+ Log.e(TAG, "Error Updating")
+ exception.printStackTrace()
+ ThirdPartyServices.logException(exception)
+ Result.failure()
+ }
+ }
+
+ companion object {
+ @JvmField
+ var UPDATED_DESCRIPTORS_WORK_NAME =
+ "${AutoUpdateDescriptorsWorker::class.java.name}.UPDATED_DESCRIPTORS_WORK_NAME"
+
+ private val TAG = AutoUpdateDescriptorsWorker::class.java.simpleName
+
+ private val UPDATE_DESCRIPTOR_CHANNEL: String =
+ AutoUpdateDescriptorsWorker::class.java.simpleName
+
+ @JvmField
+ var KEY_UPDATED_DESCRIPTORS =
+ "${AutoUpdateDescriptorsWorker::class.java.name}.KEY_UPDATED_DESCRIPTORS"
+ @JvmField
+ var KEY_DESCRIPTOR_IDS =
+ "${AutoUpdateDescriptorsWorker::class.java.name}.KEY_DESCRIPTOR_IDS"
+ }
+}
+
+class UpdateDescriptorsWorkerDependencies {
+ @Inject
+ lateinit var testDescriptorManager: TestDescriptorManager
+
+ @Inject
+ lateinit var preferenceManager: PreferenceManager
+}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/di/ActivityComponent.java b/app/src/main/java/org/openobservatory/ooniprobe/di/ActivityComponent.java
index 4ed40d976..3377b50d7 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/di/ActivityComponent.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/di/ActivityComponent.java
@@ -1,8 +1,6 @@
package org.openobservatory.ooniprobe.di;
-import org.openobservatory.ooniprobe.activity.adddescriptor.AddDescriptorActivity;
-import org.openobservatory.ooniprobe.activity.customwebsites.CustomWebsiteActivity;
import org.openobservatory.ooniprobe.activity.LogActivity;
import org.openobservatory.ooniprobe.activity.MainActivity;
import org.openobservatory.ooniprobe.activity.MeasurementDetailActivity;
@@ -10,10 +8,13 @@
import org.openobservatory.ooniprobe.activity.OverviewActivity;
import org.openobservatory.ooniprobe.activity.ProxyActivity;
import org.openobservatory.ooniprobe.activity.ResultDetailActivity;
-import org.openobservatory.ooniprobe.activity.oonirun.OoniRunV2Activity;
-import org.openobservatory.ooniprobe.activity.runtests.RunTestsActivity;
import org.openobservatory.ooniprobe.activity.RunningActivity;
import org.openobservatory.ooniprobe.activity.TextActivity;
+import org.openobservatory.ooniprobe.activity.adddescriptor.AddDescriptorActivity;
+import org.openobservatory.ooniprobe.activity.customwebsites.CustomWebsiteActivity;
+import org.openobservatory.ooniprobe.activity.oonirun.OoniRunV2Activity;
+import org.openobservatory.ooniprobe.activity.reviewdescriptorupdates.ReviewDescriptorUpdatesActivity;
+import org.openobservatory.ooniprobe.activity.runtests.RunTestsActivity;
import org.openobservatory.ooniprobe.di.annotations.PerActivity;
import dagger.Subcomponent;
@@ -23,6 +24,7 @@
public interface ActivityComponent {
void inject(OoniRunV2Activity activity);
void inject(AddDescriptorActivity activity);
+ void inject(ReviewDescriptorUpdatesActivity activity);
void inject(CustomWebsiteActivity activity);
void inject(MainActivity activity);
void inject(ProxyActivity activity);
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/di/ServiceComponent.java b/app/src/main/java/org/openobservatory/ooniprobe/di/ServiceComponent.java
index 8ebf524ef..ab6e4c85c 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/di/ServiceComponent.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/di/ServiceComponent.java
@@ -3,6 +3,7 @@
import org.openobservatory.ooniprobe.common.ResubmitTask;
import org.openobservatory.ooniprobe.common.service.RunTestJobService;
import org.openobservatory.ooniprobe.common.service.ServiceUtil;
+import org.openobservatory.ooniprobe.common.worker.UpdateDescriptorsWorkerDependencies;
import org.openobservatory.ooniprobe.di.annotations.PerService;
import dagger.Subcomponent;
@@ -13,4 +14,5 @@ public interface ServiceComponent {
void inject(ResubmitTask.Dependencies dependencies);
void inject(RunTestJobService service);
void inject(ServiceUtil.Dependencies dependencies);
+ void inject(UpdateDescriptorsWorkerDependencies dependencies);
}
\ No newline at end of file
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.kt b/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.kt
index 0e6ca912b..426600f89 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.kt
@@ -12,6 +12,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import org.openobservatory.engine.BaseNettest
import org.openobservatory.ooniprobe.R
import org.openobservatory.ooniprobe.activity.AbstractActivity
+import org.openobservatory.ooniprobe.activity.MainActivity
import org.openobservatory.ooniprobe.activity.OverviewActivity
import org.openobservatory.ooniprobe.activity.runtests.RunTestsActivity
import org.openobservatory.ooniprobe.adapters.DashboardAdapter
@@ -85,6 +86,11 @@ class DashboardFragment : Fragment(), View.OnClickListener {
binding.lastTested.visibility = View.VISIBLE
}
}
+
+ binding.swipeRefresh.setOnRefreshListener {
+ (requireActivity() as MainActivity).fetchManualUpdate()
+ binding.swipeRefresh.isRefreshing = false
+ }
}
override fun onResume() {
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/fragment/dynamicprogressbar/OONIRunDynamicProgressBar.kt b/app/src/main/java/org/openobservatory/ooniprobe/fragment/dynamicprogressbar/OONIRunDynamicProgressBar.kt
index 91f9adaec..d8b5ef61e 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/fragment/dynamicprogressbar/OONIRunDynamicProgressBar.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/fragment/dynamicprogressbar/OONIRunDynamicProgressBar.kt
@@ -54,6 +54,7 @@ class OONIRunDynamicProgressBar : Fragment() {
ProgressType.REVIEW_LINK -> {
binding.progressBar.visibility = View.GONE
binding.iconButton.visibility = View.VISIBLE
+ binding.actionButton.text = "Review"
binding.progressText.text = "Link updates ready"
}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/model/database/TestDescriptor.kt b/app/src/main/java/org/openobservatory/ooniprobe/model/database/TestDescriptor.kt
index 8e66dbefa..daef1ca8e 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/model/database/TestDescriptor.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/model/database/TestDescriptor.kt
@@ -17,8 +17,8 @@ import org.openobservatory.ooniprobe.activity.runtests.models.ChildItem
import org.openobservatory.ooniprobe.activity.runtests.models.GroupItem
import org.openobservatory.ooniprobe.common.AbstractDescriptor
import org.openobservatory.ooniprobe.common.AppDatabase
-import org.openobservatory.ooniprobe.common.OONITests
import org.openobservatory.ooniprobe.common.PreferenceManager
+import org.openobservatory.ooniprobe.common.resolveStatus
import java.io.Serializable
import java.util.Date
import com.raizlabs.android.dbflow.annotation.TypeConverter as TypeConverterAnnotation
@@ -65,7 +65,19 @@ class TestDescriptor(
}
}
+/**
+ * Check if the descriptor should be updated based on the creation time of the descriptor and the creation time of the translations.
+ *
+ * @param updatedDescriptor The updated descriptor
+ * @return True if the descriptor should be updated based on the creation time of the descriptor and the creation time of the translations.
+ */
+fun TestDescriptor.shouldUpdate(updatedDescriptor: TestDescriptor): Boolean {
+ return (updatedDescriptor.descriptorCreationTime?.after(descriptorCreationTime) ?: true
+ || updatedDescriptor.translationCreationTime?.after(translationCreationTime) ?: true)
+}
+
private const val DESCRIPTOR_TEST_NAME = "ooni_run"
+
class InstalledDescriptor(
var testDescriptor: TestDescriptor
) : AbstractDescriptor(
@@ -89,7 +101,7 @@ class InstalledDescriptor(
false -> emptyList()
},
- descriptor = testDescriptor ) {
+ descriptor = testDescriptor) {
override fun isEnabled(preferenceManager: PreferenceManager): Boolean {
return !testDescriptor.isArchived
@@ -112,13 +124,13 @@ class InstalledDescriptor(
dataUsage = this.dataUsage,
nettests = this.nettests.map { nettest ->
ChildItem(
- selected = when (this.name == OONITests.EXPERIMENTAL.label) {
- true -> preferenceManager.isExperimentalOn
- false -> preferenceManager.resolveStatus(nettest.name)
- }, name = nettest.name, inputs = nettest.inputs
+ selected = preferenceManager.resolveStatus(
+ name = nettest.name,
+ prefix = preferencePrefix(),
+ ), name = nettest.name, inputs = nettest.inputs
)
},
- descriptor = testDescriptor
+ descriptor = this.testDescriptor
)
}
@@ -172,3 +184,61 @@ class NettestConverter : TypeConverter() {
).toList()
}
+class ITestDescriptor(
+
+ var runId: Long = 0,
+
+ var name: String = "",
+
+ var nameIntl: HashMap? = null,
+
+ var author: String = "",
+
+ var shortDescription: String = "",
+
+ var shortDescriptionIntl: HashMap? = null,
+
+ var description: String = "",
+
+ var descriptionIntl: HashMap? = null,
+
+ var icon: String? = null,
+
+ var color: String? = null,
+
+ var animation: String? = null,
+
+ var isArchived: Boolean = false,
+
+ var isAutoRun: Boolean = true,
+
+ var isAutoUpdate: Boolean = false,
+
+ var descriptorCreationTime: Date? = null,
+
+ var translationCreationTime: Date? = null,
+
+ var nettests: List? = emptyList()
+) : Serializable {
+ fun toTestDescriptor(): TestDescriptor {
+ return TestDescriptor(
+ runId = runId,
+ name = name,
+ nameIntl = nameIntl,
+ author = author,
+ shortDescription = shortDescription,
+ shortDescriptionIntl = shortDescriptionIntl,
+ description = description,
+ descriptionIntl = descriptionIntl,
+ icon = icon,
+ color = color,
+ animation = animation,
+ isArchived = isArchived,
+ isAutoRun = isAutoRun,
+ isAutoUpdate = isAutoUpdate,
+ descriptorCreationTime = descriptorCreationTime,
+ translationCreationTime = translationCreationTime,
+ nettests = nettests ?: emptyList()
+ )
+ }
+}
diff --git a/app/src/main/res/layout/activity_add_descriptor.xml b/app/src/main/res/layout/activity_add_descriptor.xml
index 73cd5198e..36fbf7298 100644
--- a/app/src/main/res/layout/activity_add_descriptor.xml
+++ b/app/src/main/res/layout/activity_add_descriptor.xml
@@ -51,7 +51,8 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:resource="@{viewmodel.descriptor.icon}"
- tools:src="@drawable/settings_icon" />
+ tools:src="@drawable/settings_icon"
+ app:color="@{viewmodel.color}" />
diff --git a/app/src/main/res/layout/activity_overview.xml b/app/src/main/res/layout/activity_overview.xml
index cb192a83f..fd0140a75 100644
--- a/app/src/main/res/layout/activity_overview.xml
+++ b/app/src/main/res/layout/activity_overview.xml
@@ -1,219 +1,242 @@
-
-
-
-
-
-
+
+
+
+
-
-
+ app:contentScrim="?attr/colorPrimary"
+ app:layout_scrollFlags="scroll|snap|exitUntilCollapsed"
+ app:titleEnabled="false">
+ android:layout_marginTop="?attr/actionBarSize"
+ android:gravity="center_horizontal"
+ android:orientation="vertical"
+ android:paddingBottom="16dp"
+ app:layout_collapseMode="parallax">
+
+
+ android:layout_margin="8dp"
+ android:orientation="horizontal"
+ android:transitionName="@string/transitionNameRuntime">
-
-
-
+
+
+
+
+
+
+
+ android:layout_marginStart="16dp"
+ android:orientation="vertical">
+
+
+
+
+
-
-
-
-
-
-
+ android:text="@string/Dashboard_Overview_ChooseWebsites"
+ android:textAllCaps="false"
+ android:textColor="@android:color/white"
+ app:cornerRadius="24dp"
+ app:rippleColor="@color/ripple_material_dark"
+ app:strokeColor="@android:color/white" />
+
-
-
-
-
+
-
-
+
+
-
-
-
+ android:layout_height="match_parent"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior">
-
-
-
+ android:layout_height="match_parent"
+ android:fillViewport="true">
-
+ android:layout_height="match_parent">
-
+ android:paddingHorizontal="16dp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
-
+
-
-
-
+
-
+
-
+
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+ android:layout_marginVertical="16dp"
+ android:layout_marginStart="16dp"
+ android:text="Uninstall Link"
+ android:textAllCaps="false"
+ android:textColor="@color/color_red9"
+ android:visibility="gone"
+ app:cornerRadius="24dp"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/expandable_list_view"
+ app:rippleColor="@color/ripple_material_dark"
+ app:strokeColor="@color/color_red9"
+ tools:visibility="visible" />
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_review_descriptor_updates.xml b/app/src/main/res/layout/activity_review_descriptor_updates.xml
new file mode 100644
index 000000000..80fa5b690
--- /dev/null
+++ b/app/src/main/res/layout/activity_review_descriptor_updates.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_dashboard.xml b/app/src/main/res/layout/fragment_dashboard.xml
index 02de950e0..aa4c63494 100644
--- a/app/src/main/res/layout/fragment_dashboard.xml
+++ b/app/src/main/res/layout/fragment_dashboard.xml
@@ -1,132 +1,141 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_descriptor_update.xml b/app/src/main/res/layout/fragment_descriptor_update.xml
new file mode 100644
index 000000000..651ad0184
--- /dev/null
+++ b/app/src/main/res/layout/fragment_descriptor_update.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/add_descriptor.xml b/app/src/main/res/menu/add_descriptor.xml
index ef6942339..632cc4035 100644
--- a/app/src/main/res/menu/add_descriptor.xml
+++ b/app/src/main/res/menu/add_descriptor.xml
@@ -1,8 +1,10 @@
\ No newline at end of file
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
+
+
diff --git a/app/src/main/res/menu/update_descriptor.xml b/app/src/main/res/menu/update_descriptor.xml
new file mode 100644
index 000000000..5f4a92417
--- /dev/null
+++ b/app/src/main/res/menu/update_descriptor.xml
@@ -0,0 +1,8 @@
+
+
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index bde634449..d263d6bb5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -20,6 +20,7 @@ androidxAppCompat = "1.6.1"
androidxConstraintlayout = "2.1.4"
androidxLifecycleProcess = "2.7.0"
androidxPreference = "1.2.1"
+workRuntime = "2.9.0"
androidxLocalbroadcastmanager = "1.1.0"
androidxLegacySupportV4 = "1.0.0"
androidxJunit = "1.1.5"
@@ -86,6 +87,7 @@ androidx-legacy-support-v4 = { module = "androidx.legacy:legacy-support-v4", ver
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycleProcess" }
androidx-localbroadcastmanager = { module = "androidx.localbroadcastmanager:localbroadcastmanager", version.ref = "androidxLocalbroadcastmanager" }
androidx-preference = { module = "androidx.preference:preference", version.ref = "androidxPreference" }
+androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "androidxEspressoCore" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspressoCore" }