diff --git a/app/build.gradle b/app/build.gradle
index c0d282974..e32763ef6 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -95,6 +95,10 @@ android {
buildFeatures {
viewBinding = true
}
+ dataBinding {
+ enabled = true
+ enabledForTests = true
+ }
namespace 'org.openobservatory.ooniprobe'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d10cb50c3..05c043dd8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,171 +1,206 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/MainActivity.java b/app/src/main/java/org/openobservatory/ooniprobe/activity/MainActivity.java
index e0f79d624..2d418afe5 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/MainActivity.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/MainActivity.java
@@ -60,22 +60,21 @@ public static Intent newIntent(Context context, int resItem) {
return new Intent(context, MainActivity.class).putExtra(RES_ITEM, resItem).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
}
- public static Intent newIntent(Context context, int resItem, String message) {
- return new Intent(context, MainActivity.class)
- .putExtra(RES_ITEM, resItem)
- .putExtra(RES_SNACKBAR_MESSAGE, message)
- .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
- }
+ public static Intent newIntent(Context context, int resItem, String message) {
+ return new Intent(context, MainActivity.class)
+ .putExtra(RES_ITEM, resItem)
+ .putExtra(RES_SNACKBAR_MESSAGE, message)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ }
- @Override
+ @Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getActivityComponent().inject(this);
if (preferenceManager.isShowOnboarding()) {
startActivity(new Intent(MainActivity.this, OnboardingActivity.class));
finish();
- }
- else {
+ } else {
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
binding.bottomNavigation.setOnItemSelectedListener(item -> {
@@ -96,15 +95,15 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
/* TODO(aanorbel): Fix change in state(theme change from notification) changes the selected item.
The proper fix would be to track the selected item as well as other properties in a `ViewModel`. */
binding.bottomNavigation.setSelectedItemId(getIntent().getIntExtra(RES_ITEM, R.id.dashboard));
- /* Check if we are restoring the activity from a saved state first.
- * If we have a message to show, show it as a snackbar.
- * This is used to show the message from test completion.
- */
- if (savedInstanceState == null && getIntent().hasExtra(RES_SNACKBAR_MESSAGE)) {
- Snackbar.make(binding.getRoot(), getIntent().getStringExtra(RES_SNACKBAR_MESSAGE), Snackbar.LENGTH_SHORT)
- .setAnchorView(binding.bottomNavigation)
- .show();
- }
+ /* Check if we are restoring the activity from a saved state first.
+ * If we have a message to show, show it as a snackbar.
+ * This is used to show the message from test completion.
+ */
+ if (savedInstanceState == null && getIntent().hasExtra(RES_SNACKBAR_MESSAGE)) {
+ Snackbar.make(binding.getRoot(), getIntent().getStringExtra(RES_SNACKBAR_MESSAGE), Snackbar.LENGTH_SHORT)
+ .setAnchorView(binding.bottomNavigation)
+ .show();
+ }
if (notificationManager.shouldShowAutoTest()) {
new ConfirmDialogFragment.Builder()
.withTitle(getString(R.string.Modal_Autorun_Modal_Title))
@@ -128,9 +127,9 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
ThirdPartyServices.checkUpdates(this);
}
- if (android.os.Build.VERSION.SDK_INT >= 29){
+ if (android.os.Build.VERSION.SDK_INT >= 29) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
- } else{
+ } else {
if (preferenceManager.isDarkTheme()) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
} else {
@@ -150,20 +149,20 @@ private void requestNotificationPermission() {
binding.getRoot(),
"Please grant Notification permission from App Settings",
Snackbar.LENGTH_LONG
- ).setAction(R.string.Settings_Title, view -> {
- Intent intent = new Intent();
- intent.setAction("android.settings.APP_NOTIFICATION_SETTINGS");
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ ).setAction(R.string.Settings_Title, view -> {
+ Intent intent = new Intent();
+ intent.setAction("android.settings.APP_NOTIFICATION_SETTINGS");
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- //for Android 5-7
- intent.putExtra("app_package", getPackageName());
- intent.putExtra("app_uid", getApplicationInfo().uid);
+ //for Android 5-7
+ intent.putExtra("app_package", getPackageName());
+ intent.putExtra("app_uid", getApplicationInfo().uid);
- // for Android 8 and above
- intent.putExtra("android.provider.extra.APP_PACKAGE", getPackageName());
+ // for Android 8 and above
+ intent.putExtra("android.provider.extra.APP_PACKAGE", getPackageName());
- startActivity(intent);
- }).show();
+ startActivity(intent);
+ }).show();
}
}
);
@@ -181,10 +180,16 @@ private void requestNotificationPermission() {
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
- if (intent.getExtras() != null){
- if (intent.getExtras().containsKey(RES_ITEM))
+ /**
+ * Check if we are starting the activity with an intent extra.
+ * This is invoked when we are starting the activity from a notification or
+ * when the activity is launched from the onboarding fragment
+ * @see {@link org.openobservatory.ooniprobe.fragment.onboarding.Onboarding3Fragment#masterClick}.
+ */
+ if (intent.getExtras() != null) {
+ if (intent.getExtras().containsKey(RES_ITEM)) {
binding.bottomNavigation.setSelectedItemId(intent.getIntExtra(RES_ITEM, R.id.dashboard));
- else if (intent.getExtras().containsKey(NOTIFICATION_DIALOG)){
+ } else if (intent.getExtras().containsKey(NOTIFICATION_DIALOG)) {
new ConfirmDialogFragment.Builder()
.withTitle(intent.getExtras().getString("title"))
.withMessage(intent.getExtras().getString("message"))
@@ -202,34 +207,31 @@ public void onConfirmation(Serializable extra, int i) {
notificationManager.getUpdates(i == DialogInterface.BUTTON_POSITIVE);
//If positive answer reload consents and init notification
- if (i == DialogInterface.BUTTON_POSITIVE){
+ if (i == DialogInterface.BUTTON_POSITIVE) {
ThirdPartyServices.reloadConsents((Application) getApplication());
- }
- else if (i == DialogInterface.BUTTON_NEUTRAL){
+ } else if (i == DialogInterface.BUTTON_NEUTRAL) {
notificationManager.disableAskNotificationDialog();
}
}
if (extra.equals(AUTOTEST_DIALOG)) {
preferenceManager.setNotificationsFromDialog(i == DialogInterface.BUTTON_POSITIVE);
- if (i == DialogInterface.BUTTON_POSITIVE){
+ if (i == DialogInterface.BUTTON_POSITIVE) {
//For API < 23 we ignore battery optimization
boolean isIgnoringBatteryOptimizations = true;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
isIgnoringBatteryOptimizations = pm.isIgnoringBatteryOptimizations(getPackageName());
}
- if(!isIgnoringBatteryOptimizations){
+ if (!isIgnoringBatteryOptimizations) {
Intent intent = new Intent();
intent.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + getPackageName()));
startActivityForResult(intent, PreferenceManager.IGNORE_OPTIMIZATION_REQUEST);
- }
- else {
+ } else {
preferenceManager.enableAutomatedTesting();
ServiceUtil.scheduleJob(this);
}
- }
- else if (i == DialogInterface.BUTTON_NEUTRAL){
+ } else if (i == DialogInterface.BUTTON_NEUTRAL) {
preferenceManager.disableAskAutomaticTestDialog();
}
}
@@ -257,8 +259,7 @@ public void onActivityResult(int requestCode, int resultCode, @Nullable Intent d
if (isIgnoringBatteryOptimizations) {
preferenceManager.enableAutomatedTesting();
ServiceUtil.scheduleJob(this);
- }
- else {
+ } else {
new ConfirmDialogFragment.Builder()
.withMessage(getString(R.string.Modal_Autorun_BatteryOptimization))
.withPositiveButton(getString(R.string.Modal_OK))
@@ -266,8 +267,7 @@ public void onActivityResult(int requestCode, int resultCode, @Nullable Intent d
.withExtra(BATTERY_DIALOG)
.build().show(getSupportFragmentManager(), null);
}
- }
- else if (requestCode == PreferenceManager.ASK_UPDATE_APP) {
+ } else if (requestCode == PreferenceManager.ASK_UPDATE_APP) {
if (resultCode != RESULT_OK) {
//We don't need to check the result for now
}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/OverviewActivity.java b/app/src/main/java/org/openobservatory/ooniprobe/activity/OverviewActivity.java
index a71c368f8..344045758 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/OverviewActivity.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/OverviewActivity.java
@@ -14,7 +14,6 @@
import android.view.Window;
import androidx.annotation.Nullable;
-import androidx.core.content.ContextCompat;
import androidx.core.text.TextUtilsCompat;
import androidx.core.view.ViewCompat;
@@ -25,13 +24,16 @@
import org.openobservatory.ooniprobe.activity.customwebsites.CustomWebsiteActivity;
import org.openobservatory.ooniprobe.activity.overview.OverviewTestsExpandableListViewAdapter;
import org.openobservatory.ooniprobe.activity.overview.OverviewViewModel;
-import org.openobservatory.ooniprobe.common.OONIDescriptor;
+import org.openobservatory.ooniprobe.common.AbstractDescriptor;
import org.openobservatory.ooniprobe.common.OONITests;
import org.openobservatory.ooniprobe.common.PreferenceManager;
import org.openobservatory.ooniprobe.common.ReadMorePlugin;
import org.openobservatory.ooniprobe.databinding.ActivityOverviewBinding;
+import org.openobservatory.ooniprobe.model.database.InstalledDescriptor;
import org.openobservatory.ooniprobe.model.database.Result;
+import org.openobservatory.ooniprobe.model.database.TestDescriptor;
+import java.text.SimpleDateFormat;
import java.util.Locale;
import java.util.Objects;
@@ -52,9 +54,9 @@ public class OverviewActivity extends AbstractActivity {
OverviewTestsExpandableListViewAdapter adapter;
- private OONIDescriptor descriptor;
+ private AbstractDescriptor descriptor;
- public static Intent newIntent(Context context, OONIDescriptor descriptor) {
+ public static Intent newIntent(Context context, AbstractDescriptor descriptor) {
return new Intent(context, OverviewActivity.class).putExtra(TEST, descriptor);
}
@@ -62,14 +64,14 @@ public static Intent newIntent(Context context, OONIDescriptor desc
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getActivityComponent().inject(this);
- descriptor = (OONIDescriptor) getIntent().getSerializableExtra(TEST);
+ descriptor = (AbstractDescriptor) getIntent().getSerializableExtra(TEST);
binding = ActivityOverviewBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
viewModel.updateDescriptor(descriptor);
setSupportActionBar(binding.toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
setTitle(descriptor.getTitle());
- setThemeColor(ContextCompat.getColor(this, descriptor.getColor()));
+ setThemeColor(descriptor.getColor());
binding.icon.setImageResource(descriptor.getDisplayIcon(this));
binding.customUrl.setVisibility(descriptor.getName().equals(OONITests.WEBSITES.getLabel()) ? View.VISIBLE : View.GONE);
Markwon markwon = Markwon.builder(this)
@@ -81,7 +83,20 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
binding.desc.setTextDirection(View.TEXT_DIRECTION_RTL);
}
} else {
- markwon.setMarkdown(binding.desc, descriptor.getDescription());
+ if (descriptor instanceof InstalledDescriptor) {
+ TestDescriptor testDescriptor = ((InstalledDescriptor) descriptor).getTestDescriptor();
+ markwon.setMarkdown(
+ binding.desc,
+ String.format(
+ "Created by %s on %s\n\n%s",
+ testDescriptor.getAuthor(),
+ new SimpleDateFormat("MMM dd, yyyy", Locale.ENGLISH).format(testDescriptor.getDescriptorCreationTime()),
+ descriptor.getDescription()
+ )
+ );
+ } else {
+ markwon.setMarkdown(binding.desc, descriptor.getDescription());
+ }
}
Result lastResult = Result.getLastResult(descriptor.getName());
if (lastResult == null) {
@@ -127,6 +142,18 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
binding.expandableListView.expandGroup(i);
}
+ if (descriptor instanceof InstalledDescriptor) {
+ binding.uninstallLink.setVisibility(View.VISIBLE);
+ binding.automaticUpdatesContainer.setVisibility(View.VISIBLE);
+ binding.automaticUpdatesSwitch.setChecked(((InstalledDescriptor) descriptor).getTestDescriptor().isAutoUpdate());
+ } else {
+ binding.uninstallLink.setVisibility(View.GONE);
+ /**
+ * We need to set the height to 0 because the layout is broken when the view is gone
+ */
+ binding.automaticUpdatesContainer.getLayoutParams().height = 0;
+ }
+
setUpOnCLickListeners();
}
@@ -157,6 +184,8 @@ public void setThemeColor(int color) {
private void setUpOnCLickListeners() {
binding.customUrl.setOnClickListener(view -> customUrlClick());
+ binding.uninstallLink.setOnClickListener(view -> viewModel.uninstallLinkClicked(this, (InstalledDescriptor) descriptor));
+ binding.automaticUpdatesSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> viewModel.automaticUpdatesSwitchClicked(isChecked));
}
@Override
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/RunningActivity.java b/app/src/main/java/org/openobservatory/ooniprobe/activity/RunningActivity.java
index d5401b045..083fe917e 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/RunningActivity.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/RunningActivity.java
@@ -8,6 +8,7 @@
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
+import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
@@ -173,11 +174,17 @@ private void applyUIChanges(RunTestService service) {
binding.name.setText(service.task.currentTest.getName());
else
binding.name.setText(getString(service.task.currentTest.getLabelResId()));
- getWindow().setBackgroundDrawableResource(service.task.currentSuite.getColor());
- if (Build.VERSION.SDK_INT >= 21) {
- getWindow().setStatusBarColor(service.task.currentSuite.getColor());
+
+ getWindow().setBackgroundDrawable(new ColorDrawable(service.task.currentSuite.getColor()));
+ getWindow().setStatusBarColor(service.task.currentSuite.getColor());
+ if (service.task.currentSuite.getAnim() == null){
+ binding.animation.setImageResource(service.task.currentSuite.getIconGradient());
+ binding.animation.setColorFilter(getResources().getColor(R.color.color_gray2));
+ binding.animation.setPadding(0,100,0,100);
+ } else {
+ binding.animation.setAnimation(service.task.currentSuite.getAnim());
}
- binding.animation.setAnimation(service.task.currentSuite.getAnim());
+
binding.progress.setMax(service.task.getMax(preferenceManager));
}
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
new file mode 100644
index 000000000..5b2a0a8d8
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/AddDescriptorActivity.kt
@@ -0,0 +1,212 @@
+package org.openobservatory.ooniprobe.activity.adddescriptor
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build.VERSION
+import android.os.Build.VERSION_CODES
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.activity.viewModels
+import androidx.appcompat.widget.Toolbar
+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
+import org.openobservatory.ooniprobe.activity.adddescriptor.adapter.AddDescriptorExpandableListAdapter
+import org.openobservatory.ooniprobe.activity.adddescriptor.adapter.GroupedItem
+import org.openobservatory.ooniprobe.common.PreferenceManager
+import org.openobservatory.ooniprobe.common.ReadMorePlugin
+import org.openobservatory.ooniprobe.common.StringUtils
+import org.openobservatory.ooniprobe.common.TestDescriptorManager
+import org.openobservatory.ooniprobe.databinding.ActivityAddDescriptorBinding
+import org.openobservatory.ooniprobe.model.database.TestDescriptor
+import org.openobservatory.ooniprobe.model.database.getNettests
+import javax.inject.Inject
+
+/**
+ * This activity is used to add a new descriptor to the application. The activity shows the tests that are included in the descriptor.
+ * The user can select which tests to include, and if the descriptor should be automatically updated.
+ */
+class AddDescriptorActivity : AbstractActivity() {
+ companion object {
+ private const val DESCRIPTOR = "descriptor"
+
+ /**
+ * 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 descriptor is the descriptor to add
+ * @return an intent to start this activity
+ */
+ @JvmStatic
+ fun newIntent(context: Context, descriptor: TestDescriptor): Intent {
+ return Intent(context, AddDescriptorActivity::class.java).putExtra(
+ DESCRIPTOR,
+ descriptor
+ )
+ }
+
+ /**
+ * This method is used to set the text of a textview as markdown. The markdown is parsed using the markwon library.
+ * The textview must have the attribute app:richText set to the markdown text to parse.
+ * @param view is the textview that will contain the parsed text
+ * @param richText is the markdown text to parse
+ */
+ @JvmStatic
+ @BindingAdapter(value = ["richText"])
+ fun setRichText(view: TextView, richText: String?) {
+ richText?.let { textValue ->
+ val r = view.context.resources
+ val markwon = Markwon.builder(view.context)
+ .usePlugin(
+ ReadMorePlugin(
+ labelMore = r.getString(R.string.OONIRun_ReadMore),
+ labelLess = r.getString(R.string.OONIRun_ReadLess)
+ )
+ )
+ .build()
+ markwon.setMarkdown(view, textValue)
+ }
+ }
+
+ /**
+ * This method is used to set the image of an imageview as a drawable resource. The drawable is set using the name of the resource.
+ * The imageview must have the attribute app:resource set to the name of the resource to set.
+ * @param imageView is the imageview that will contain the drawable resource
+ * @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.*/
+ imageView.setImageResource(
+ imageView.context.resources.getIdentifier(
+ StringUtils.camelToSnake(
+ iconName
+ ), "drawable", imageView.context.packageName
+ )
+ )
+ }
+
+ }
+
+ @Inject
+ lateinit var preferenceManager: PreferenceManager
+
+ @Inject
+ lateinit var descriptorManager: TestDescriptorManager
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ activityComponent.inject(this)
+ val binding = ActivityAddDescriptorBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ setSupportActionBar(binding.toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(false)
+ supportActionBar?.setDisplayShowHomeEnabled(false)
+ supportActionBar?.title = "Add New Link"
+ val descriptorExtra = if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(DESCRIPTOR, TestDescriptor::class.java)
+ } else {
+ intent.getSerializableExtra(DESCRIPTOR) as TestDescriptor?
+ }
+ val viewModel: AddDescriptorViewModel by viewModels {
+ object : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return AddDescriptorViewModel(descriptorManager) as T
+ }
+ }
+ }
+ binding.viewmodel = viewModel
+ binding.lifecycleOwner = this
+ descriptorExtra?.let { descriptor ->
+ viewModel.onDescriptorChanged(descriptor)
+ val adapter = AddDescriptorExpandableListAdapter(
+ nettests = descriptor.getNettests().map { nettest ->
+ GroupedItem(
+ name = nettest.name,
+ inputs = nettest.inputs,
+ selected = true
+ )
+ },
+ viewModel = viewModel
+ )
+ binding.expandableListView.setAdapter(adapter)
+ for (i in 0 until adapter.groupCount) {
+ binding.expandableListView.expandGroup(i)
+ }
+ val bottomBarOnMenuItemClickListener: Toolbar.OnMenuItemClickListener =
+ Toolbar.OnMenuItemClickListener { item ->
+ when (item.itemId) {
+ R.id.add_descriptor -> {
+ viewModel.onAddButtonClicked(
+ disabledAutorunNettests = adapter.nettests.filter { it.selected },
+ automatedUpdates = binding.automaticUpdatesSwitch.isChecked
+ )
+ true
+ }
+
+ else -> false
+ }
+ }
+ binding.bottomBar.setOnMenuItemClickListener(bottomBarOnMenuItemClickListener)
+
+ viewModel.selectedAllBtnStatus.observe(this) { state ->
+ binding.testsCheckbox.checkedState = state;
+ }
+
+ // This observer is used to change the state of the "Select All" button when a checkbox is clicked.
+ binding.testsCheckbox.addOnCheckedStateChangedListener { checkBox, state ->
+ viewModel.setSelectedAllBtnStatus(state)
+ adapter.notifyDataSetChanged()
+ }
+
+ // This observer is used to finish the activity when the descriptor is added.
+ viewModel.finishActivity.observe(this) { shouldFinish ->
+ if (shouldFinish) {
+ finish()
+ }
+ }
+ } ?: run {
+ 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)
+ }
+ }
+
+ override fun onBackPressed() {
+ startActivity(MainActivity.newIntent(applicationContext, R.id.dashboard))
+ super.onBackPressed()
+ }
+
+ override fun finish() {
+ startActivity(MainActivity.newIntent(applicationContext, R.id.dashboard))
+ super.finish()
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..90d25ec08
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/AddDescriptorViewModel.kt
@@ -0,0 +1,110 @@
+package org.openobservatory.ooniprobe.activity.adddescriptor
+
+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
+import org.openobservatory.ooniprobe.model.database.TestDescriptor
+import org.openobservatory.ooniprobe.model.database.getValueForKey
+
+/**
+ * ViewModel for the AddDescriptorActivity. This class is responsible for preparing and managing the data for the AddDescriptorActivity.
+ * It handles the communication of the Activity with the rest of the application (e.g. calling business logic classes).
+ *
+ * @property descriptorManager Instance of TestDescriptorManager which is responsible for managing the test descriptors.
+ * @property selectedAllBtnStatus LiveData holding the state of the "Select All" button in the UI.
+ * @property descriptor LiveData holding the OONIRunDescriptor object that the user is currently interacting with in the UI.
+ */
+class AddDescriptorViewModel constructor(
+ var descriptorManager: TestDescriptorManager
+) : ViewModel() {
+ @CheckedState
+ val selectedAllBtnStatus: MutableLiveData =
+ MutableLiveData(MaterialCheckBox.STATE_CHECKED)
+ var descriptor: MutableLiveData = MutableLiveData()
+ val finishActivity: MutableLiveData = MutableLiveData()
+
+ /**
+ * This method is called when the activity is created.
+ * It sets the descriptor value of this ViewModel.
+ * @param descriptor is the new descriptor
+ */
+ fun onDescriptorChanged(descriptor: TestDescriptor) {
+ this.descriptor.value = descriptor
+ }
+
+ /**
+ * This method is used to get the name of the descriptor.
+ * Used by the UI during data binding.
+ * @return the name of the descriptor.
+ */
+ fun getName(): String {
+ return descriptor.value?.let { descriptor ->
+ descriptor.nameIntl.getValueForKey(LocaleUtils.getLocale().language) ?: descriptor.name
+ } ?: ""
+ }
+
+ /**
+ * This method is used to get the description of the descriptor.
+ * Used by the UI during data binding.
+ * @return the name of the descriptor.
+ */
+ fun getDescription(): String {
+ return descriptor.value?.let { descriptor ->
+ descriptor.descriptionIntl.getValueForKey(LocaleUtils.getLocale().language)
+ ?: descriptor.description
+ } ?: ""
+ }
+
+ /**
+ * This method is used to get the short description of the descriptor.
+ * Used by the UI during data binding.
+ * @return the short description of the descriptor.
+ */
+ fun getShortDescription(): String {
+ return descriptor.value?.let { descriptor ->
+ descriptor.shortDescriptionIntl.getValueForKey(LocaleUtils.getLocale().language)
+ ?: descriptor.shortDescription
+ } ?: ""
+ }
+
+ /**
+ * This method is used to set the state of the "Select All" button in the UI.
+ * @param selectedStatus is the new state of the "Select All" button.
+ */
+ fun setSelectedAllBtnStatus(@CheckedState selectedStatus: Int) {
+ selectedAllBtnStatus.postValue(selectedStatus)
+ }
+
+
+ /**
+ * This method is called when the "Add Link" button is clicked.
+ * It adds the descriptor to the descriptor manager and signals that the activity should finish.
+ * @param disabledAutorunNettests is the list of disabled nettests for autorun.
+ * @param automatedUpdates is a boolean indicating whether automated updates should be enabled.
+ */
+ fun onAddButtonClicked(disabledAutorunNettests: List, automatedUpdates: Boolean) {
+ descriptor.value?.let { descriptor ->
+ descriptorManager.addDescriptor(
+ descriptor = descriptor.apply {
+ isAutoUpdate = automatedUpdates
+ },
+ disabledAutorunNettests = disabledAutorunNettests
+ ).also {
+ finishActivity()
+ }
+ } ?: throw IllegalStateException("Descriptor is null")
+ }
+
+ /**
+ * This method is used to signal that the activity should finish.
+ */
+ fun finishActivity() {
+ finishActivity.value = true
+ }
+}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/adapter/AddDescriptorExpandableListAdapter.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/adapter/AddDescriptorExpandableListAdapter.kt
new file mode 100644
index 000000000..0f4461143
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/adddescriptor/adapter/AddDescriptorExpandableListAdapter.kt
@@ -0,0 +1,195 @@
+package org.openobservatory.ooniprobe.activity.adddescriptor.adapter
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.BaseExpandableListAdapter
+import android.widget.ImageView
+import android.widget.TextView
+import com.google.android.material.checkbox.MaterialCheckBox
+import com.google.android.material.checkbox.MaterialCheckBox.STATE_CHECKED
+import com.google.android.material.checkbox.MaterialCheckBox.STATE_INDETERMINATE
+import com.google.android.material.checkbox.MaterialCheckBox.STATE_UNCHECKED
+import org.openobservatory.engine.OONIRunNettest
+import org.openobservatory.ooniprobe.R
+import org.openobservatory.ooniprobe.activity.adddescriptor.AddDescriptorViewModel
+import org.openobservatory.ooniprobe.test.test.AbstractTest
+
+
+/**
+ * An extension of [OONIRunNettest] class
+ * used to track the selected state of nettests in the [ExpandableListView].
+ */
+class GroupedItem(
+ override var name: String,
+ override var inputs: List?,
+ var selected: Boolean = false
+) : OONIRunNettest(name = name, inputs = inputs)
+
+/**
+ * Adapter class for the [ExpandableListView] in [AddDescriptorActivity].
+ * @param nettests List of GroupedItem objects.
+ * @param viewModel AddDescriptorViewModel object.
+ */
+class AddDescriptorExpandableListAdapter(
+ val nettests: List,
+ val viewModel: AddDescriptorViewModel
+) : 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 [GroupedItem] object.
+ */
+ override fun getGroup(groupPosition: Int): GroupedItem = 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)
+ val selectedAllBtnStatus = viewModel.selectedAllBtnStatus.value
+ if (selectedAllBtnStatus == STATE_CHECKED) {
+ groupItem.selected = true
+ } else if (selectedAllBtnStatus == STATE_UNCHECKED) {
+ groupItem.selected = false
+ }
+
+ groupCheckBox.setOnClickListener {
+ if (groupItem.selected) {
+ groupItem.selected = false
+ notifyDataSetChanged()
+ if (isNotSelectedAnyGroupItem()) {
+ viewModel.setSelectedAllBtnStatus(STATE_UNCHECKED)
+ } else {
+ viewModel.setSelectedAllBtnStatus(STATE_INDETERMINATE)
+ }
+ } else {
+ groupItem.selected = true
+ notifyDataSetChanged()
+
+ if (isSelectedAllItems()) {
+ viewModel.setSelectedAllBtnStatus(STATE_CHECKED)
+ } else {
+ viewModel.setSelectedAllBtnStatus(STATE_INDETERMINATE)
+ }
+ }
+ }
+
+ groupCheckBox.isChecked = groupItem.selected
+
+ 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
+
+ fun isNotSelectedAnyGroupItem(): Boolean {
+ for (groupItem in nettests) {
+ if (groupItem.selected) {
+ return false
+ }
+ }
+ return true
+ }
+
+ fun isSelectedAllItems(): Boolean {
+ for (groupItem in nettests) {
+ if (!groupItem.selected) {
+ return false
+ }
+ }
+ return true
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..5b4dc61bc
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/oonirun/OoniRunV2Activity.kt
@@ -0,0 +1,187 @@
+package org.openobservatory.ooniprobe.activity.oonirun
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.widget.Toast
+import org.openobservatory.ooniprobe.R
+import org.openobservatory.ooniprobe.activity.AbstractActivity
+import org.openobservatory.ooniprobe.activity.adddescriptor.AddDescriptorActivity
+import org.openobservatory.ooniprobe.common.TaskExecutor
+import org.openobservatory.ooniprobe.common.TestDescriptorManager
+import org.openobservatory.ooniprobe.common.ThirdPartyServices
+import org.openobservatory.ooniprobe.databinding.ActivityOoniRunV2Binding
+import org.openobservatory.ooniprobe.model.database.TestDescriptor
+import javax.inject.Inject
+
+/**
+ * Activity to handle a v2 link.
+ *
+ * A v2 link has the following format:
+ *
+ * 1. ooni://runv2/link_id
+ * 2. https://run.test.ooni.org/v2/link_id
+ *
+ * The activity is started when the user clicks on `Open Link in OONI Probe` or
+ * when the system recognizes this app can open this link and launches the app when a link is clicked.
+ *
+ * It fetches the descriptor from the link and starts the `AddDescriptorActivity`.
+ * If the link is invalid, it shows an error message and ends the activity.
+ *
+ * @see {@link org.openobservatory.ooniprobe.activity.OoniRunActivity} for v1 links.
+ */
+class OoniRunV2Activity : AbstractActivity() {
+ lateinit var binding: ActivityOoniRunV2Binding
+
+ @Inject
+ lateinit var descriptorManager: TestDescriptorManager
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ activityComponent.inject(this)
+ binding = ActivityOoniRunV2Binding.inflate(layoutInflater)
+ setContentView(binding.root)
+ onNewIntent(intent)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ /**
+ * Check if we are starting the activity from a link [Intent.ACTION_VIEW].
+ * This is invoked when a v2 link is opened.
+ * @see {@link org.openobservatory.ooniprobe.activity.OoniRunActivity.newIntent} for v1 links.
+ */
+ if (Intent.ACTION_VIEW == intent.action) {
+ manageIntent(intent)
+ } else {
+ // If the intent action is not `Intent.ACTION_VIEW`, end activity.
+ Toast.makeText(this, getString(R.string.Modal_Error), Toast.LENGTH_LONG).show()
+ finish()
+ }
+ }
+
+ /**
+ * Parses the intent data to extract the link.
+ * If the intent does not contain a link, show an error message and end the activity.
+ * If the intent contains a link, but it is not a supported link or has a non-numerical `link_id`,
+ * show an error message and end the activity.
+ * If the intent contains a link, but the `link_id` is zero,
+ * show an error message and end the activity.
+ * If the intent contains a link with a valid `link_id`,
+ * fetch the descriptor from the link and start the `AddDescriptorActivity`.
+ *
+ * @param intent The intent data.
+ */
+ private fun manageIntent(intent: Intent) {
+ // If the intent does not contain a link, do nothing.
+ val uri = intent.data ?: finishWithError().run { return }
+ // If the intent contains a link, but it is not a supported link or has a non-numerical `link_id`.
+ val possibleRunId: Long = getRunId(uri) ?: finishWithError().run { return }
+
+ // If the intent contains a link, but the `link_id` is zero.
+ if (possibleRunId == 0L) {
+ finishWithError().run { return }
+ }
+ val executor = TaskExecutor()
+ binding.cancelButton.setOnClickListener {
+ executor.cancelTask()
+ finishWithError(message = getString(R.string.Modal_Cancel))
+ }
+ executor.executeTask({
+ try {
+ return@executeTask descriptorManager.fetchDescriptorFromRunId(
+ possibleRunId,
+ this
+ )
+ } catch (exception: Exception) {
+ exception.printStackTrace()
+ ThirdPartyServices.logException(exception)
+ return@executeTask null
+ }
+ }) { descriptorResponse: TestDescriptor? ->
+ fetchDescriptorComplete(
+ descriptorResponse
+ )
+ }
+ }
+
+ /**
+ * Shows an error message and ends the activity.
+ *
+ * @param message The error message to be shown.
+ */
+ private fun finishWithError(message: String = getString(R.string.Modal_Error)) {
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show()
+ finish()
+ }
+
+ /**
+ * The task to fetch the descriptor from the link is completed.
+ *
+ *
+ * This method is called when the `fetchDescriptorFromRunId` task is completed.
+ * The `descriptorResponse` is the result of the task.
+ * If the task is successful, the `descriptorResponse` is the descriptor.
+ * Otherwise, the `descriptorResponse` is null.
+ *
+ * If the `descriptorResponse` is not null, start the `AddDescriptorActivity`.
+ * Otherwise, show an error message.
+ *
+ * @param descriptorResponse The result of the task.
+ * @return null.
+ */
+ private fun fetchDescriptorComplete(descriptorResponse: TestDescriptor?) {
+ descriptorResponse?.let {
+ startActivity(AddDescriptorActivity.newIntent(this, descriptorResponse))
+ } ?: run {
+ Toast.makeText(this, getString(R.string.Modal_Error), Toast.LENGTH_LONG).show()
+ }
+ }
+
+ /**
+ * Extracts the run id from the provided Uri.
+ * The run id can be in two different formats:
+ *
+ * 1. ooni://runv2/link_id
+ * 2. https://run.test.ooni.org/v2/link_id
+ *
+ * The run id is the `link_id` in the link.
+ * If the Uri contains a link, but the `link_id` is not a number, null is returned.
+ * If the Uri contains a link, but it is not a supported link, null is returned.
+ *
+ * @param uri The Uri data.
+ * @return The run id if the Uri contains a link with a valid `link_id`, or null otherwise.
+ */
+ private fun getRunId(uri: Uri): Long? {
+ val host = uri.host
+ try {
+ when (host) {
+ "runv2" -> {
+ /*
+ * The run id is the first segment of the path.
+ * Launched when `Open Link in OONI Probe` is clicked.
+ * e.g. ooni://runv2/link_id
+ */
+ return uri.pathSegments[0].toLong()
+ }
+
+ "run.test.ooni.org" -> {
+ /*
+ * The run id is the second segment of the path.
+ * Launched when the system recognizes this app can open this link
+ * and launches the app when a link is clicked.
+ * e.g. https://run.test.ooni.org/v2/link_id
+ */
+ return uri.pathSegments[1].toLong()
+ }
+
+ else -> return null
+ }
+ } catch (e: Exception) {
+ // If the intent contains a link, but the `link_id` is not a number.
+ e.printStackTrace()
+ return null
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/overview/OverviewTestsExpandableListViewAdapter.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/overview/OverviewTestsExpandableListViewAdapter.kt
index 04ff44aea..5640144b2 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/overview/OverviewTestsExpandableListViewAdapter.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/overview/OverviewTestsExpandableListViewAdapter.kt
@@ -10,6 +10,7 @@ import com.google.android.material.checkbox.MaterialCheckBox
import org.openobservatory.ooniprobe.R
import org.openobservatory.ooniprobe.common.OONITests
import org.openobservatory.ooniprobe.test.test.AbstractTest
+import org.openobservatory.ooniprobe.test.test.Experimental
class OverviewTestsExpandableListViewAdapter(
private val items: List,
@@ -50,8 +51,10 @@ class OverviewTestsExpandableListViewAdapter(
else -> {
val testSuite = AbstractTest.getTestByName(groupItem.name)
- view.findViewById(R.id.group_name).text =
- parent.context.resources.getText(testSuite.labelResId)
+ view.findViewById(R.id.group_name).text = when (testSuite is Experimental) {
+ true -> testSuite.name
+ false -> parent.context.resources.getText(testSuite.labelResId)
+ }
when(testSuite.iconResId){
0 -> view.findViewById(R.id.group_icon).visibility = View.GONE
else -> {
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/overview/OverviewViewModel.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/overview/OverviewViewModel.kt
index 7725b0496..b19713d50 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/overview/OverviewViewModel.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/overview/OverviewViewModel.kt
@@ -3,11 +3,14 @@ package org.openobservatory.ooniprobe.activity.overview
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.openobservatory.engine.BaseNettest
-import org.openobservatory.ooniprobe.common.OONIDescriptor
+import org.openobservatory.ooniprobe.activity.AbstractActivity
+import org.openobservatory.ooniprobe.common.AbstractDescriptor
import org.openobservatory.ooniprobe.common.OONITests
import org.openobservatory.ooniprobe.common.PreferenceManager
+import org.openobservatory.ooniprobe.common.TestDescriptorManager
import org.openobservatory.ooniprobe.common.disableTest
import org.openobservatory.ooniprobe.common.enableTest
+import org.openobservatory.ooniprobe.model.database.InstalledDescriptor
import javax.inject.Inject
class TestGroupItem(
@@ -16,12 +19,14 @@ class TestGroupItem(
class OverviewViewModel() : ViewModel() {
- var descriptor: MutableLiveData> = MutableLiveData()
+ var descriptor: MutableLiveData> = MutableLiveData()
lateinit var preferenceManager: PreferenceManager
+ lateinit var descriptorManager: TestDescriptorManager
@Inject
- constructor(preferenceManager: PreferenceManager) : this() {
+ constructor(preferenceManager: PreferenceManager, descriptorManager: TestDescriptorManager) : this() {
this.preferenceManager = preferenceManager
+ this.descriptorManager = descriptorManager
}
@@ -97,13 +102,25 @@ class OverviewViewModel() : ViewModel() {
}
- fun updateDescriptor(descriptor: OONIDescriptor) {
+ fun updateDescriptor(descriptor: AbstractDescriptor) {
this.descriptor.postValue(descriptor)
}
+ fun uninstallLinkClicked(activity: AbstractActivity, descriptor: InstalledDescriptor) {
+ descriptorManager.delete(descriptor)
+ activity.finish()
+ }
+
+ fun automaticUpdatesSwitchClicked(isChecked: Boolean) {
+ descriptor.value?.let {
+ it.descriptor?.isAutoUpdate = isChecked
+ it.descriptor?.save()
+ }
+ }
+
companion object {
const val SELECT_ALL = "SELECT_ALL"
const val SELECT_SOME = "SELECT_SOME"
const val SELECT_NONE = "SELECT_NONE"
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/RunTestsActivity.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/RunTestsActivity.kt
index fc40b4733..b7a33f03d 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/RunTestsActivity.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/RunTestsActivity.kt
@@ -17,11 +17,11 @@ import org.openobservatory.ooniprobe.activity.runtests.RunTestsViewModel.Compani
import org.openobservatory.ooniprobe.activity.runtests.adapter.RunTestsExpandableListViewAdapter
import org.openobservatory.ooniprobe.activity.runtests.models.ChildItem
import org.openobservatory.ooniprobe.activity.runtests.models.GroupItem
-import org.openobservatory.ooniprobe.common.OONIDescriptor
+import org.openobservatory.ooniprobe.common.AbstractDescriptor
import org.openobservatory.ooniprobe.common.OONITests
import org.openobservatory.ooniprobe.common.PreferenceManager
-import org.openobservatory.ooniprobe.common.enableTest
import org.openobservatory.ooniprobe.common.disableTest
+import org.openobservatory.ooniprobe.common.enableTest
import org.openobservatory.ooniprobe.databinding.ActivityRunTestsBinding
import java.io.Serializable
import javax.inject.Inject
@@ -41,7 +41,7 @@ class RunTestsActivity : AbstractActivity() {
const val TESTS: String = "tests"
@JvmStatic
- fun newIntent(context: Context, testSuites: List>): Intent {
+ fun newIntent(context: Context, testSuites: List>): Intent {
return Intent(context, RunTestsActivity::class.java).putExtras(Bundle().apply {
putSerializable(TESTS, testSuites as Serializable)
})
@@ -58,8 +58,8 @@ class RunTestsActivity : AbstractActivity() {
activityComponent?.inject(this)
- val descriptors: List>? =
- intent.extras?.getSerializable(TESTS) as List>?
+ val descriptors: List>? =
+ intent.extras?.getSerializable(TESTS) as List>?
descriptors?.let { _descriptors ->
adapter = RunTestsExpandableListViewAdapter(
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/models/GroupItems.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/models/GroupItems.kt
index 4904e1ad6..79105c06a 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/models/GroupItems.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/runtests/models/GroupItems.kt
@@ -1,7 +1,11 @@
package org.openobservatory.ooniprobe.activity.runtests.models
import org.openobservatory.engine.BaseNettest
+import org.openobservatory.ooniprobe.common.AbstractDescriptor
import org.openobservatory.ooniprobe.common.OONIDescriptor
+import org.openobservatory.ooniprobe.common.PreferenceManager
+import org.openobservatory.ooniprobe.model.database.TestDescriptor
+import org.openobservatory.ooniprobe.test.suite.DynamicTestSuite
class ChildItem(
var selected: Boolean,
@@ -17,10 +21,11 @@ class GroupItem(
override var description: String,
override var icon: String,
override var color: Int,
- override var animation: String,
+ override var animation: String?,
override var dataUsage: Int,
- override var nettests: List
-) : OONIDescriptor(
+ override var nettests: List,
+ override var descriptor: TestDescriptor? = null
+) : AbstractDescriptor(
name = name,
title = title,
shortDescription = shortDescription,
@@ -29,5 +34,6 @@ class GroupItem(
color = color,
animation = animation,
dataUsage = dataUsage,
- nettests = nettests
+ nettests = nettests,
+ descriptor = descriptor
)
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 c1aea434b..c01ce9a16 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/adapters/DashboardAdapter.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/adapters/DashboardAdapter.kt
@@ -8,7 +8,7 @@ import android.view.ViewGroup
import androidx.cardview.widget.CardView
import androidx.recyclerview.widget.RecyclerView
import org.openobservatory.ooniprobe.R
-import org.openobservatory.ooniprobe.common.OONIDescriptor
+import org.openobservatory.ooniprobe.common.AbstractDescriptor
import org.openobservatory.ooniprobe.common.PreferenceManager
import org.openobservatory.ooniprobe.databinding.ItemSeperatorBinding
import org.openobservatory.ooniprobe.databinding.ItemTestsuiteBinding
@@ -54,7 +54,7 @@ class DashboardAdapter(
VIEW_TYPE_CARD -> {
val cardHolder = holder as CardViewHolder
- if (item is OONIDescriptor<*>) {
+ if (item is AbstractDescriptor<*>) {
cardHolder.binding.apply {
title.setText(item.title)
desc.setText(item.shortDescription)
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/common/AppDatabase.java b/app/src/main/java/org/openobservatory/ooniprobe/common/AppDatabase.java
index a7255f9c6..4cb5a7db5 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/common/AppDatabase.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/common/AppDatabase.java
@@ -11,7 +11,11 @@
@Database(name = AppDatabase.NAME, version = AppDatabase.VERSION, foreignKeyConstraintsEnforced = true)
public class AppDatabase {
public static final String NAME = "v2";
- public static final int VERSION = 3;
+ /**
+ * Version 4: Add `descriptor_runId` foreign key to Result
+ * Version 5: Add `TestDescriptor` model.
+ */
+ public static final int VERSION = 5;
@Migration(version = 2, database = AppDatabase.class)
public static class Migration2 extends AlterTableMigration {
@@ -41,4 +45,19 @@ public void onPreMigrate() {
}
+ @Migration(version = 4, database = AppDatabase.class)
+ public static class Migration4 extends AlterTableMigration {
+
+ public Migration4(Class table) {
+ super(table);
+ }
+
+ @Override
+ public void onPreMigrate() {
+ addForeignKeyColumn(SQLiteType.INTEGER, "descriptor_runId", "TestDescriptor (`runId`)");
+ }
+
+ }
+
+
}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/common/LocaleUtils.java b/app/src/main/java/org/openobservatory/ooniprobe/common/LocaleUtils.java
index 5b1ac9bf7..8f6a50520 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/common/LocaleUtils.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/common/LocaleUtils.java
@@ -9,7 +9,6 @@
public class LocaleUtils {
-
private static Locale sLocale;
public static void setLocale(Locale locale) {
@@ -37,4 +36,8 @@ public static void updateConfig(Application app, Configuration configuration) {
res.updateConfiguration(config, res.getDisplayMetrics());
}
}
+
+ public static Locale getLocale() {
+ return sLocale;
+ }
}
\ No newline at end of file
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 b1f827636..7d446fdb9 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/common/OONIDescriptor.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/common/OONIDescriptor.kt
@@ -3,8 +3,10 @@ package org.openobservatory.ooniprobe.common
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
+import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.StringRes
+import androidx.core.content.ContextCompat
import org.openobservatory.engine.BaseDescriptor
import org.openobservatory.engine.BaseNettest
import org.openobservatory.ooniprobe.R
@@ -12,22 +14,24 @@ import org.openobservatory.ooniprobe.activity.overview.TestGroupItem
import org.openobservatory.ooniprobe.activity.runtests.RunTestsActivity
import org.openobservatory.ooniprobe.activity.runtests.models.ChildItem
import org.openobservatory.ooniprobe.activity.runtests.models.GroupItem
+import org.openobservatory.ooniprobe.model.database.TestDescriptor
import org.openobservatory.ooniprobe.test.suite.DynamicTestSuite
import org.openobservatory.ooniprobe.test.test.*
import java.io.Serializable
-open class OONIDescriptor(
+abstract class AbstractDescriptor(
override var name: String,
open var title: String,
open var shortDescription: String,
open var description: String,
open var icon: String,
- @ColorRes open var color: Int,
- open var animation: String,
+ @ColorInt open var color: Int,
+ open var animation: String?,
@StringRes open var dataUsage: Int,
override var nettests: List,
- var longRunningTests: List? = null
-) : Serializable, BaseDescriptor(name = name, nettests = nettests) {
+ open var longRunningTests: List? = null,
+ open var descriptor: TestDescriptor? = null
+) : BaseDescriptor(name = name, nettests = nettests) {
/**
* This function is used to determine if the current descriptor is enabled.
@@ -38,7 +42,7 @@ open class OONIDescriptor(
* @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.
*/
- 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
@@ -51,13 +55,28 @@ open class OONIDescriptor(
}
}
+ /**
+ * Returns the runtime of the current descriptor.
+ *
+ * @return Int representing the runtime of the current descriptor.
+ */
+ open fun getRuntime(context: Context, preferenceManager: PreferenceManager): Int {
+ return getTest(context).getRuntime(preferenceManager)
+ }
+
/**
* Returns the display icon for the current descriptor.
*
* @return Int representing the display icon for the current descriptor.
*/
- fun getDisplayIcon(context: Context): Int {
- return context.resources.getIdentifier(icon, "drawable", context.packageName)
+ open fun getDisplayIcon(context: Context): Int {
+ return context.resources.getIdentifier(
+ StringUtils.camelToSnake(
+ icon
+ ), "drawable", context.packageName
+ ).let {
+ if (it == 0) R.drawable.ooni_empty_state else it
+ }
}
/**
@@ -86,14 +105,15 @@ open class OONIDescriptor(
*
* @return [GroupItem] representing the current descriptor.
*/
- fun toRunTestsGroupItem(preferenceManager: PreferenceManager): GroupItem {
- return GroupItem(selected = false,
+ open fun toRunTestsGroupItem(preferenceManager: PreferenceManager): GroupItem {
+ return GroupItem(
+ selected = false,
name = this.name,
title = this.title,
shortDescription = this.shortDescription,
description = this.description,
icon = this.icon,
- color = this.color,
+ color = color,
animation = this.animation,
dataUsage = this.dataUsage,
nettests = this.nettests.map { nettest ->
@@ -115,16 +135,9 @@ open class OONIDescriptor(
inputs = nettest.inputs,
)
} ?: listOf())
- })
- }
-
- /**
- * Returns the runtime of the current descriptor.
- *
- * @return Int representing the runtime of the current descriptor.
- */
- fun getRuntime(context: Context, preferenceManager: PreferenceManager): Int {
- return getTest(context).getRuntime(preferenceManager)
+ },
+ descriptor = descriptor
+ )
}
/**
@@ -132,7 +145,7 @@ open class OONIDescriptor(
*
* @return [DynamicTestSuite] representing the test suite for the current descriptor.
*/
- fun getTest(context: Context): DynamicTestSuite {
+ open fun getTest(context: Context): DynamicTestSuite {
return DynamicTestSuite(
name = this.name,
title = this.title,
@@ -143,7 +156,8 @@ open class OONIDescriptor(
color = this.color,
animation = this.animation,
dataUsage = this.dataUsage,
- nettest = this.nettests
+ nettest = this.nettests,
+ descriptor = descriptor
)
}
@@ -157,7 +171,10 @@ open class OONIDescriptor(
* @return String representing the preference prefix.
*/
fun preferencePrefix(): String {
- return OONITests.values().find { it.label == name }?.let { "" } ?: "descriptor_id_"
+ return when (descriptor?.runId != null) {
+ true -> descriptor?.preferencePrefix() ?: ""
+ else -> ""
+ }
}
/**
@@ -172,6 +189,29 @@ open class OONIDescriptor(
}
}
+open class OONIDescriptor(
+ override var name: String,
+ override var title: String,
+ override var shortDescription: String,
+ override var description: String,
+ override var icon: String,
+ @ColorInt override var color: Int,
+ override var animation: String?,
+ @StringRes override var dataUsage: Int,
+ override var nettests: List,
+ override var longRunningTests: List? = null
+) : Serializable, AbstractDescriptor(
+ name = name,
+ title = title,
+ shortDescription = shortDescription,
+ description = description,
+ icon = icon,
+ color = color,
+ animation = animation,
+ dataUsage = dataUsage,
+ nettests = nettests
+)
+
/**
* Enum class representing the OONI tests.
* Rational
@@ -193,7 +233,7 @@ enum class OONITests(
@StringRes val shortDescription: Int,
@StringRes val description: Int,
val icon: String,
- val color: Int,
+ @ColorRes val color: Int,
val animation: String,
@StringRes val dataUsage: Int,
var nettests: List,
@@ -301,7 +341,7 @@ enum class OONITests(
else -> context.getString(description)
},
icon = icon,
- color = color,
+ color = ContextCompat.getColor(context, color),
animation = animation,
dataUsage = dataUsage,
nettests = nettests,
@@ -320,7 +360,7 @@ enum class OONITests(
* [STUN Reachability](https://github.com/ooni/spec/blob/master/nettests/ts-025-stun-reachability.md)
* [DNS Check](https://github.com/ooni/spec/blob/master/nettests/ts-028-dnscheck.md)
-
+
* [RiseupVPN](https://ooni.org/nettest/riseupvpn/)
* [ECH Check](https://github.com/ooni/spec/blob/master/nettests/ts-039-echcheck.md)
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/common/StringUtils.java b/app/src/main/java/org/openobservatory/ooniprobe/common/StringUtils.java
new file mode 100644
index 000000000..0de33c475
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/common/StringUtils.java
@@ -0,0 +1,28 @@
+package org.openobservatory.ooniprobe.common;
+
+public class StringUtils {
+ public static String camelToSnake(String camelCase) {
+
+ try {
+ final char[] name = camelCase.toCharArray();
+ final StringBuilder builder = new StringBuilder();
+
+ for (int i = 0; i < name.length; i++) {
+ if (Character.isUpperCase(name[i]) || name[i] == '.' || name[i] == '$') {
+ if (i != 0 && name[i - 1] != '.' && name[i - 1] != '$') {
+ builder.append('_');
+ }
+ if (name[i] != '.' && name[i] != '$') {
+ builder.append(Character.toLowerCase(name[i]));
+ }
+ } else {
+ builder.append(name[i]);
+ }
+ }
+
+ return builder.toString();
+ } catch (Exception e){
+ return "ooni_empty_state";
+ }
+ }
+}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/common/TaskExecutor.kt b/app/src/main/java/org/openobservatory/ooniprobe/common/TaskExecutor.kt
new file mode 100644
index 000000000..8b821654d
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/common/TaskExecutor.kt
@@ -0,0 +1,95 @@
+package org.openobservatory.ooniprobe.common
+
+import android.os.Handler
+import android.os.Looper
+import java.util.concurrent.Callable
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+
+/**
+ * ProgressTask is an abstract class that represents a task that reports progress.
+ * @param P The type of the progress token.
+ * @param R The type of the result.
+ */
+abstract class ProgressTask
{
+ abstract fun runTask(progressToken: OnTaskProgressUpdate
): R
+}
+
+/**
+ * Task is an alias for the java.util.concurrent.Callable interface.
+ * @param R The type of the result.
+ */
+typealias Task = Callable
+
+/**
+ * OnTaskProgressUpdate is a typealias for a callback that is invoked when a task reports progress.
+ * @param P The type of the progress token.
+ */
+typealias OnTaskProgressUpdate
= (P) -> Unit
+
+/**
+ * OnTaskComplete is a typealias for a callback that is invoked when a task is completed.
+ * @param R The type of the result.
+ */
+typealias OnTaskComplete = (R) -> Unit
+
+/**
+ * TaskExecutor is a utility class that provides methods to execute tasks in a separate thread and post results on the main thread.
+ * It uses a single thread executor to run tasks and a Handler to post results on the main thread.
+ *
+ * @property executor The executor service that runs tasks in a separate thread.
+ * @property handler The handler that posts results on the main thread.
+ * @property future The future that represents the result of a task.
+ */
+class TaskExecutor {
+ private val executor = Executors.newSingleThreadExecutor()
+ private val handler = Handler(Looper.getMainLooper())
+ private var future: Future<*>? = null
+
+ /**
+ * Executes a task in a separate thread and posts the result on the main thread.
+ * @param task The task to be executed.
+ * @param onComplete The callback to be invoked when the task is completed.
+ */
+ fun executeTask(task: Task, onComplete: OnTaskComplete) {
+ future = executor.submit {
+ val result = task.call()
+ handler.post {
+ onComplete(result)
+ }
+ }
+ }
+
+ /**
+ * Executes a task that reports progress in a separate thread and posts the result and progress updates on the main thread.
+ * @param progressTask The task to be executed.
+ * @param onProgress The callback to be invoked when the task reports progress.
+ * @param onComplete The callback to be invoked when the task is completed.
+ */
+ fun
executeProgressTask(
+ progressTask: ProgressTask
,
+ onProgress: OnTaskProgressUpdate
,
+ onComplete: OnTaskComplete
+ ) {
+ future = executor.submit {
+ val result = progressTask.runTask(
+ progressToken = { progress ->
+ handler.post {
+ onProgress(progress)
+ }
+ }
+ )
+
+ handler.post {
+ onComplete(result)
+ }
+ }
+ }
+
+ /**
+ * Cancels the currently running task.
+ */
+ fun cancelTask() {
+ future?.cancel(true)
+ }
+}
\ No newline at end of file
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 70d4b4524..d0e301ec8 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/common/TestDescriptorManager.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/common/TestDescriptorManager.kt
@@ -1,13 +1,33 @@
package org.openobservatory.ooniprobe.common
import android.content.Context
+import com.raizlabs.android.dbflow.sql.language.SQLite
import org.openobservatory.engine.BaseNettest
+import org.openobservatory.engine.LoggerArray
+import org.openobservatory.engine.OONIRunFetchResponse
+import org.openobservatory.ooniprobe.BuildConfig
+import org.openobservatory.ooniprobe.activity.adddescriptor.adapter.GroupedItem
+import org.openobservatory.ooniprobe.model.database.InstalledDescriptor
+import org.openobservatory.ooniprobe.model.database.Result
+import org.openobservatory.ooniprobe.model.database.Result_Table
+import org.openobservatory.ooniprobe.model.database.TestDescriptor
+import org.openobservatory.ooniprobe.model.database.TestDescriptor_Table
+import org.openobservatory.ooniprobe.model.database.Url
+import org.openobservatory.ooniprobe.model.database.getNettests
+import org.openobservatory.ooniprobe.test.EngineProvider
import org.openobservatory.ooniprobe.test.suite.DynamicTestSuite
+import org.openobservatory.ooniprobe.test.test.WebConnectivity
import javax.inject.Inject
import javax.inject.Singleton
+/**
+ * This class is responsible for managing the test descriptors
+ */
@Singleton
-class TestDescriptorManager @Inject constructor(private val context: Context) {
+class TestDescriptorManager @Inject constructor(
+ private val context: Context,
+ private val preferenceManager: PreferenceManager
+) {
private val descriptors: List> = ooniDescriptors(context)
fun getDescriptors(): List> {
@@ -21,4 +41,80 @@ class TestDescriptorManager @Inject constructor(private val context: Context) {
fun getTestByDescriptorName(name: String): DynamicTestSuite? {
return getDescriptorByName(name)?.getTest(context)
}
+
+ /**
+ * Fetches the descriptor from the ooni server using the run id.
+ * @param runId the run id of the descriptor to fetch
+ * @param context the context to use for the request
+ */
+ fun fetchDescriptorFromRunId(runId: Long, context: Context): TestDescriptor {
+ val session = EngineProvider.get().newSession(
+ EngineProvider.get().getDefaultSessionConfig(
+ context,
+ BuildConfig.SOFTWARE_NAME,
+ BuildConfig.VERSION_NAME,
+ LoggerArray(),
+ (context.applicationContext as Application).preferenceManager.proxyURL
+ )
+ )
+ val ooniContext = session.newContextWithTimeout(300)
+
+ val response: OONIRunFetchResponse = session.ooniRunFetch(ooniContext, runId)
+ return TestDescriptor(
+ runId = runId,
+ name = response.descriptor.name,
+ nameIntl = response.descriptor.nameIntl,
+ author = response.descriptor.author,
+ shortDescription = response.descriptor.shortDescription,
+ shortDescriptionIntl = response.descriptor.shortDescriptionIntl,
+ description = response.descriptor.description,
+ descriptionIntl = response.descriptor.descriptionIntl,
+ icon = response.descriptor.icon,
+ color = response.descriptor.color,
+ animation = response.descriptor.animation,
+ isArchived = response.archived,
+ descriptorCreationTime = response.creationTime,
+ translationCreationTime = response.translationCreationTime,
+ nettests = response.descriptor.nettests
+ )
+ }
+
+ fun addDescriptor(
+ descriptor: TestDescriptor,
+ disabledAutorunNettests: List
+ ): Boolean {
+ descriptor.getNettests()
+ .filter { it.name == WebConnectivity.NAME }
+ .forEach {
+ it.inputs?.forEach { url -> Url.checkExistingUrl(url) }
+ }
+ descriptor.getNettests().map { it.name }
+ .filter { item -> disabledAutorunNettests.map { it.name }.contains(item) }
+ .forEach { item ->
+ preferenceManager.enableTest(
+ name = item,
+ prefix = descriptor.preferencePrefix(),
+ autoRun = true
+ )
+ }
+ return descriptor.save()
+ }
+
+ fun getRunV2Descriptors(): List {
+ return SQLite.select().from(TestDescriptor::class.java)
+ .where(TestDescriptor_Table.isArchived.eq(false)).queryList()
+ }
+
+ fun delete(descriptor: InstalledDescriptor): Boolean {
+ preferenceManager.sp.all.entries.forEach {entry ->
+ if (entry.key.contains(descriptor.testDescriptor.runId.toString())) {
+ preferenceManager.sp.edit().remove(entry.key).apply()
+ }
+ }
+ return SQLite.select().from(Result::class.java)
+ .where(Result_Table.descriptor_runId.eq(descriptor.testDescriptor.runId))
+ .queryList().forEach { it.delete(context) }.run {
+ descriptor.testDescriptor.delete()
+ }
+ }
}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/common/views/CustomExpandableListView.kt b/app/src/main/java/org/openobservatory/ooniprobe/common/views/CustomExpandableListView.kt
new file mode 100644
index 000000000..3029bb0e0
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/common/views/CustomExpandableListView.kt
@@ -0,0 +1,21 @@
+package org.openobservatory.ooniprobe.common.views
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.ExpandableListView
+
+/**
+ * This class is needed to allow the ExpandableListView to be placed inside a ScrollView.
+ * Without this, the ExpandableListView will not expand to its full size and will not be scrollable.
+ */
+class CustomExpandableListView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : ExpandableListView(context, attrs, defStyleAttr) {
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(
+ widthMeasureSpec,
+ MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE shr 2, MeasureSpec.AT_MOST)
+ )
+ }
+}
\ No newline at end of file
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 d6010d982..4ed40d976 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/di/ActivityComponent.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/di/ActivityComponent.java
@@ -1,6 +1,7 @@
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;
@@ -9,6 +10,7 @@
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;
@@ -19,6 +21,8 @@
@PerActivity
@Subcomponent()
public interface ActivityComponent {
+ void inject(OoniRunV2Activity activity);
+ void inject(AddDescriptorActivity activity);
void inject(CustomWebsiteActivity activity);
void inject(MainActivity activity);
void inject(ProxyActivity activity);
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/domain/GetTestSuite.java b/app/src/main/java/org/openobservatory/ooniprobe/domain/GetTestSuite.java
index ee9961395..126e35c8f 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/domain/GetTestSuite.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/domain/GetTestSuite.java
@@ -13,7 +13,6 @@
import org.openobservatory.ooniprobe.model.database.Result;
import org.openobservatory.ooniprobe.model.database.Url;
import org.openobservatory.ooniprobe.test.suite.AbstractSuite;
-import org.openobservatory.ooniprobe.test.suite.DynamicTestSuite;
import org.openobservatory.ooniprobe.test.test.WebConnectivity;
import java.util.List;
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 4ef973b80..da7ab2827 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/fragment/DashboardFragment.kt
@@ -16,6 +16,7 @@ import org.openobservatory.ooniprobe.activity.OverviewActivity
import org.openobservatory.ooniprobe.activity.RunningActivity
import org.openobservatory.ooniprobe.activity.runtests.RunTestsActivity
import org.openobservatory.ooniprobe.adapters.DashboardAdapter
+import org.openobservatory.ooniprobe.common.AbstractDescriptor
import org.openobservatory.ooniprobe.common.Application
import org.openobservatory.ooniprobe.common.OONIDescriptor
import org.openobservatory.ooniprobe.common.PreferenceManager
@@ -34,7 +35,7 @@ class DashboardFragment : Fragment(), View.OnClickListener {
@Inject
lateinit var viewModel: DashboardViewModel
- private var descriptors: ArrayList> = ArrayList()
+ private var descriptors: ArrayList> = ArrayList()
private lateinit var binding: FragmentDashboardBinding
@@ -60,13 +61,14 @@ class DashboardFragment : Fragment(), View.OnClickListener {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ lifecycle.addObserver(viewModel)
viewModel.getGroupedItemList().observe(viewLifecycleOwner) { items ->
binding.recycler.layoutManager = LinearLayoutManager(requireContext())
adapter = DashboardAdapter(items, this, preferenceManager)
binding.recycler.adapter = adapter
}
- viewModel.items.observe(viewLifecycleOwner) { items ->
+ viewModel.getItemList().observe(viewLifecycleOwner) { items ->
descriptors.apply {
clear()
addAll(items)
@@ -110,11 +112,11 @@ class DashboardFragment : Fragment(), View.OnClickListener {
}
override fun onClick(v: View) {
- val descriptor = v.tag as OONIDescriptor
+ val descriptor = v.tag as AbstractDescriptor
ActivityCompat.startActivity(
requireActivity(),
OverviewActivity.newIntent(activity, descriptor),
null
)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/fragment/ResultListFragment.java b/app/src/main/java/org/openobservatory/ooniprobe/fragment/ResultListFragment.java
index 583e75b28..fd26d44f9 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/fragment/ResultListFragment.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/fragment/ResultListFragment.java
@@ -42,6 +42,7 @@
import org.openobservatory.ooniprobe.item.FailedItem;
import org.openobservatory.ooniprobe.item.InstantMessagingItem;
import org.openobservatory.ooniprobe.item.PerformanceItem;
+import org.openobservatory.ooniprobe.item.RunItem;
import org.openobservatory.ooniprobe.item.WebsiteItem;
import org.openobservatory.ooniprobe.model.database.Network;
import org.openobservatory.ooniprobe.model.database.Result;
@@ -195,6 +196,8 @@ void queryList() {
items.add(new CircumventionItem(result, this, this));
} else if (result.test_group_name.equals(OONITests.EXPERIMENTAL.toString())) {
items.add(new ExperimentalItem(result, this, this));
+ } else if (result.descriptor!=null) {
+ items.add(new RunItem(result, this, this));
} else {
items.add(new FailedItem(result, this, this));
}
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
index d95467354..73689f43e 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/fragment/dashboard/DashboardViewModel.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/fragment/dashboard/DashboardViewModel.kt
@@ -1,22 +1,41 @@
package org.openobservatory.ooniprobe.fragment.dashboard
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.openobservatory.engine.BaseNettest
-import org.openobservatory.ooniprobe.common.OONIDescriptor
+import org.openobservatory.ooniprobe.common.AbstractDescriptor
import org.openobservatory.ooniprobe.common.PreferenceManager
import org.openobservatory.ooniprobe.common.TestDescriptorManager
+import org.openobservatory.ooniprobe.model.database.InstalledDescriptor
import javax.inject.Inject
class DashboardViewModel @Inject constructor(
private val preferenceManager: PreferenceManager,
private val descriptorManager: TestDescriptorManager
-) : ViewModel() {
+) : ViewModel(), DefaultLifecycleObserver {
+ private var ooniRunDescriptors: List = emptyList()
private val oonTestsTitle: String = "OONI Tests"
+ private val oonRunLinksTitle: String = "OONI RUN Links"
private val oonTests = descriptorManager.getDescriptors()
private val groupedItemList = MutableLiveData>()
- val items = MutableLiveData>>(oonTests)
+ private val items = MutableLiveData>>(oonTests)
+
+ init {
+ ooniRunDescriptors = descriptorManager.getRunV2Descriptors().map {
+ InstalledDescriptor(it)
+ }
+ }
+
+ override fun onResume(owner: LifecycleOwner) {
+ super.onResume(owner)
+ ooniRunDescriptors = descriptorManager.getRunV2Descriptors().map {
+ InstalledDescriptor(it)
+ }
+ fetchItemList()
+ }
fun getGroupedItemList(): LiveData> {
if (groupedItemList.value == null) {
@@ -25,12 +44,16 @@ class DashboardViewModel @Inject constructor(
return groupedItemList
}
+ fun getItemList(): LiveData>> {
+ return items.value?.let { MutableLiveData(it + ooniRunDescriptors) } ?: MutableLiveData()
+ }
+
private fun fetchItemList() {
val groupedItems = items.value!!.sortedBy { !it.isEnabled(preferenceManager) }
.groupBy {
return@groupBy if (oonTests.contains(it)) {
- oonTestsTitle
+ oonTestsTitle
} else {
""
}
@@ -38,10 +61,13 @@ class DashboardViewModel @Inject constructor(
val groupedItemList = mutableListOf()
groupedItems.forEach { (status, itemList) ->
- groupedItemList.add(status)
+ groupedItemList.add(status)
groupedItemList.addAll(itemList)
}
-
+ if (ooniRunDescriptors.isNotEmpty()) {
+ groupedItemList.add(oonRunLinksTitle)
+ groupedItemList.addAll(ooniRunDescriptors)
+ }
this.groupedItemList.value = groupedItemList
}
}
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
new file mode 100644
index 000000000..91f9adaec
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/fragment/dynamicprogressbar/OONIRunDynamicProgressBar.kt
@@ -0,0 +1,118 @@
+package org.openobservatory.ooniprobe.fragment.dynamicprogressbar
+
+import android.os.Bundle
+import androidx.fragment.app.Fragment
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import org.openobservatory.ooniprobe.R
+import org.openobservatory.ooniprobe.databinding.RunDynamicProgressBarBinding
+
+/**
+ * A [Fragment] subclass that displays a dynamic progress bar and handles user actions.
+ * The progress bar can be in one of the following states: [ProgressType.ADD_LINK], [ProgressType.UPDATE_LINK], [ProgressType.REVIEW_LINK].
+ * The user actions are handled through the OnActionListener interface.
+ * Use the [OONIRunDynamicProgressBar.newInstance] factory method to create an instance of this fragment.
+ */
+class OONIRunDynamicProgressBar : Fragment() {
+ private var progressType: ProgressType? = null
+ private var onActionListener: OnActionListener? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ arguments?.let {
+ progressType =
+ ProgressType.valueOf(it.getString(PROGRESS_TYPE) ?: ProgressType.ADD_LINK.name)
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val binding = RunDynamicProgressBarBinding.inflate(inflater, container, false)
+ binding.actionButton.setOnClickListener {
+ onActionListener?.onActionButtonCLicked()
+ }
+ binding.iconButton.setOnClickListener {
+ onActionListener?.onCloseButtonClicked()
+ }
+ when (progressType) {
+ ProgressType.ADD_LINK -> {
+ binding.progressBar.visibility = View.VISIBLE
+ binding.iconButton.visibility = View.GONE
+ binding.actionButton.text = getString(R.string.Modal_Cancel)
+ binding.progressText.text = "Link Loading"
+ }
+
+ ProgressType.UPDATE_LINK -> {
+ binding.progressBar.visibility = View.VISIBLE
+ binding.iconButton.visibility = View.GONE
+ binding.progressText.text = "Link updates Loading"
+ }
+
+ ProgressType.REVIEW_LINK -> {
+ binding.progressBar.visibility = View.GONE
+ binding.iconButton.visibility = View.VISIBLE
+ binding.progressText.text = "Link updates ready"
+ }
+
+ else -> {
+ binding.progressBar.visibility = View.GONE
+ binding.iconButton.visibility = View.GONE
+ binding.progressText.text = getString(R.string.Modal_Error)
+ }
+ }
+ return binding.root
+ }
+
+ companion object {
+ private const val PROGRESS_TYPE = "PROGRESS_TYPE"
+
+ @JvmStatic
+ var TAG: String = OONIRunDynamicProgressBar::class.java.name
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @param progressType The type of progress to be displayed.
+ * @param onActionListener The listener for user actions.
+ * @return A new instance of DynamicProgressFragment.
+ */
+ @JvmStatic
+ fun newInstance(
+ progressType: ProgressType,
+ onActionListener: OnActionListener?
+ ): OONIRunDynamicProgressBar {
+ return OONIRunDynamicProgressBar().apply {
+ arguments = Bundle().apply {
+ putString(PROGRESS_TYPE, progressType.name)
+ }
+ this.onActionListener = onActionListener
+ }
+ }
+ }
+}
+
+/**
+ * Enum representing the type of progress to be displayed in the DynamicProgressFragment.
+ */
+enum class ProgressType {
+ ADD_LINK, UPDATE_LINK, REVIEW_LINK
+}
+
+/**
+ * Interface for handling user actions in the DynamicProgressFragment.
+ */
+interface OnActionListener {
+ /**
+ * Called when the action button is clicked.
+ */
+ fun onActionButtonCLicked()
+
+ /**
+ * Called when the icon button is clicked.
+ */
+ fun onCloseButtonClicked()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/item/FailedItem.java b/app/src/main/java/org/openobservatory/ooniprobe/item/FailedItem.java
index 47d7f766f..ada4548f7 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/item/FailedItem.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/item/FailedItem.java
@@ -15,7 +15,7 @@
import org.openobservatory.engine.BaseNettest;
import org.openobservatory.ooniprobe.R;
-import org.openobservatory.ooniprobe.common.OONIDescriptor;
+import org.openobservatory.ooniprobe.common.AbstractDescriptor;
import org.openobservatory.ooniprobe.databinding.ItemFailedBinding;
import org.openobservatory.ooniprobe.model.database.Result;
@@ -44,9 +44,9 @@ public FailedItem(Result extra, View.OnClickListener onClickListener, View.OnLon
viewHolder.itemView.setOnLongClickListener(onLongClickListener);
viewHolder.itemView.setBackgroundColor(ContextCompat.getColor(viewHolder.itemView.getContext(), R.color.color_gray2));
viewHolder.binding.testName.setTextColor(ContextCompat.getColor(viewHolder.itemView.getContext(), R.color.color_gray6));
- Optional> possibleDescriptor = extra.getDescriptor(viewHolder.itemView.getContext());
+ Optional> possibleDescriptor = extra.getDescriptor(viewHolder.itemView.getContext());
if (possibleDescriptor.isPresent()) {
- OONIDescriptor descriptor = possibleDescriptor.get();
+ AbstractDescriptor descriptor = possibleDescriptor.get();
viewHolder.binding.icon.setImageResource(descriptor.getDisplayIcon(viewHolder.itemView.getContext()));
viewHolder.binding.testName.setText(descriptor.getTitle());
} else {
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/item/RunItem.kt b/app/src/main/java/org/openobservatory/ooniprobe/item/RunItem.kt
new file mode 100644
index 000000000..67090104b
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/item/RunItem.kt
@@ -0,0 +1,21 @@
+package org.openobservatory.ooniprobe.item
+
+import android.view.View
+import org.openobservatory.ooniprobe.model.database.Result
+
+
+class RunItem(
+ var result: Result,
+ var onClickListener: View.OnClickListener,
+ var onLongClickListener: View.OnLongClickListener
+) : ExperimentalItem(result, onClickListener, onLongClickListener) {
+ override fun onBindViewHolder(viewHolder: ViewHolder?) {
+ super.onBindViewHolder(viewHolder)
+ viewHolder?.itemView?.context?.let { context ->
+ extra.getDescriptor(context).get().let { descriptor ->
+ viewHolder.binding?.icon?.setImageResource(descriptor.getDisplayIcon(context))
+ viewHolder.binding?.name?.text = descriptor.title
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/model/database/Result.java b/app/src/main/java/org/openobservatory/ooniprobe/model/database/Result.java
index d6793fe51..898cf761a 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/model/database/Result.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/model/database/Result.java
@@ -7,6 +7,7 @@
import com.raizlabs.android.dbflow.annotation.ForeignKey;
import com.raizlabs.android.dbflow.annotation.PrimaryKey;
import com.raizlabs.android.dbflow.annotation.Table;
+import com.raizlabs.android.dbflow.config.DatabaseDefinition;
import com.raizlabs.android.dbflow.config.FlowManager;
import com.raizlabs.android.dbflow.sql.language.Delete;
import com.raizlabs.android.dbflow.sql.language.SQLite;
@@ -14,6 +15,7 @@
import org.apache.commons.io.FileUtils;
import org.openobservatory.engine.BaseNettest;
+import org.openobservatory.ooniprobe.common.AbstractDescriptor;
import org.openobservatory.ooniprobe.common.AppDatabase;
import org.openobservatory.ooniprobe.common.OONIDescriptor;
import org.openobservatory.ooniprobe.common.OONITests;
@@ -56,6 +58,10 @@ public class Result extends BaseModel implements Serializable {
@ForeignKey(saveForeignKeyModel = true)
public Network network;
+
+ @ForeignKey(saveForeignKeyModel = true)
+ public TestDescriptor descriptor;
+
private List measurements;
public Result() {
@@ -82,6 +88,17 @@ public static Result getLastResult(String test_group_name) {
return SQLite.select().from(Result.class).where(Result_Table.test_group_name.eq(test_group_name)).orderBy(Result_Table.start_time, false).limit(1).querySingle();
}
+ /**
+ * Delete all results and related files.
+ * Previously had a call to {@link DatabaseDefinition#reset} which was removed
+ * because it reset the database to the initial state.
+ *
+ * This meant that the user would lose all the installed descriptors.
+ *
+ * Previously, this was not a problem because the descriptors were not installed.
+ *
+ * @param c Context
+ */
public static void deleteAll(Context c) {
try {
FileUtils.cleanDirectory(Measurement.getMeasurementDir(c));
@@ -90,7 +107,6 @@ public static void deleteAll(Context c) {
}
Delete.tables(Measurement.class, Result.class, Network.class);
FlowManager.getDatabase(AppDatabase.class).close();
- FlowManager.getDatabase(AppDatabase.class).reset();
}
public List getMeasurements() {
@@ -184,7 +200,7 @@ public String getFormattedDataUsageDown() {
}
public Optional getTestSuite(Context context) {
- Optional> descriptor = getDescriptor(context);
+ Optional> descriptor = getDescriptor(context);
if (descriptor.isPresent()) {
return Optional.of(descriptor.get().getTest(context));
} else {
@@ -216,10 +232,28 @@ public void delete(Context c) {
}
}
- public Optional> getDescriptor(Context context) {
+ public Optional> getDescriptor(Context context) {
try {
+ /**
+ * If the descriptor exists, then this is an OONI Run v2 measurement result.
+ * We return an {@link InstalledDescriptor} object which implements {@link AbstractDescriptor}.
+ */
+ if (descriptor != null) {
+ return Optional.of(new InstalledDescriptor(descriptor));
+ }
+ /**
+ * If the descriptor does not exist, then this is an OONI Provided test or an OONI Run v1 measurement result.
+ * We return an {@link OONIDescriptor} object which implements {@link AbstractDescriptor}.
+ */
return Optional.of(OONITests.valueOf(test_group_name.toUpperCase()).toOONIDescriptor(context));
} catch (IllegalArgumentException e) {
+ /**
+ * If there is an {@link IllegalArgumentException}
+ * This should only happen when the test_group_name is not a valid {@link OONITests} value,
+ * Which means the `test_group_name` is not an OONI provided test or an installed `Descriptor`.
+ * Orphan resulta for an uninstalled OONI Run v2 descriptor would fall into this category and thus should not exist.
+ * We return an {@link Optional#absent()} object.
+ */
return Optional.absent();
}
}
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
new file mode 100644
index 000000000..8e66dbefa
--- /dev/null
+++ b/app/src/main/java/org/openobservatory/ooniprobe/model/database/TestDescriptor.kt
@@ -0,0 +1,174 @@
+package org.openobservatory.ooniprobe.model.database
+
+import android.content.Context
+import android.graphics.Color
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import com.raizlabs.android.dbflow.annotation.Column
+import com.raizlabs.android.dbflow.annotation.PrimaryKey
+import com.raizlabs.android.dbflow.annotation.Table
+import com.raizlabs.android.dbflow.converter.TypeConverter
+import com.raizlabs.android.dbflow.structure.BaseModel
+import org.openobservatory.engine.BaseNettest
+import org.openobservatory.engine.OONIRunNettest
+import org.openobservatory.ooniprobe.R
+import org.openobservatory.ooniprobe.activity.adddescriptor.adapter.GroupedItem
+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 java.io.Serializable
+import java.util.Date
+import com.raizlabs.android.dbflow.annotation.TypeConverter as TypeConverterAnnotation
+
+@Table(database = AppDatabase::class)
+class TestDescriptor(
+ @PrimaryKey
+ var runId: Long = 0,
+ @Column
+ var name: String = "",
+ @Column(name = "name_intl", typeConverter = MapConverter::class)
+ var nameIntl: Any? = null,
+ @Column
+ var author: String = "",
+ @Column(name = "short_description")
+ var shortDescription: String = "",
+ @Column(name = "short_description_intl", typeConverter = MapConverter::class)
+ var shortDescriptionIntl: Any? = null,
+ @Column
+ var description: String = "",
+ @Column(name = "description_intl", typeConverter = MapConverter::class)
+ var descriptionIntl: Any? = null,
+ @Column
+ var icon: String? = null,
+ @Column
+ var color: String? = null,
+ @Column
+ var animation: String? = null,
+ @Column
+ var isArchived: Boolean = false,
+ @Column(name = "auto_run")
+ var isAutoRun: Boolean = true,
+ @Column(name = "auto_update")
+ var isAutoUpdate: Boolean = false,
+ @Column(name = "descriptor_creation_time")
+ var descriptorCreationTime: Date? = null,
+ @Column(name = "translation_creation_time")
+ var translationCreationTime: Date? = null,
+ @Column(typeConverter = NettestConverter::class)
+ var nettests: Any = emptyList()
+) : BaseModel(), Serializable {
+ fun preferencePrefix(): String {
+ return "${runId}_"
+ }
+}
+
+private const val DESCRIPTOR_TEST_NAME = "ooni_run"
+class InstalledDescriptor(
+ var testDescriptor: TestDescriptor
+) : AbstractDescriptor(
+ name = DESCRIPTOR_TEST_NAME,
+ title = testDescriptor.name,
+ shortDescription = testDescriptor.shortDescription,
+ description = testDescriptor.description,
+ icon = testDescriptor.icon ?: "settings_icon",
+ color = Color.parseColor(testDescriptor.color ?: "#495057"),
+ animation = testDescriptor.animation,
+ dataUsage = R.string.TestResults_NotAvailable,
+ nettests = when (testDescriptor.nettests is List<*>) {
+ true -> (testDescriptor.nettests as List<*>)
+ .filterIsInstance()
+ .map { nettest: OONIRunNettest ->
+ return@map BaseNettest(
+ name = nettest.name,
+ inputs = nettest.inputs,
+ )
+ }
+
+ false -> emptyList()
+ },
+ descriptor = testDescriptor ) {
+
+ override fun isEnabled(preferenceManager: PreferenceManager): Boolean {
+ return !testDescriptor.isArchived
+ }
+
+ override fun getRuntime(context: Context, preferenceManager: PreferenceManager): Int {
+ return R.string.TestResults_NotAvailable
+ }
+
+ override fun toRunTestsGroupItem(preferenceManager: PreferenceManager): GroupItem {
+ return GroupItem(
+ selected = false,
+ name = this.name,
+ title = this.title,
+ shortDescription = this.shortDescription,
+ description = this.description,
+ icon = this.icon,
+ color = Color.parseColor(testDescriptor.color ?: "#495057"),
+ animation = this.animation,
+ 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
+ )
+ },
+ descriptor = testDescriptor
+ )
+ }
+
+}
+
+fun TestDescriptor.getNettests(): List {
+ return when (nettests is List<*>) {
+ true -> (nettests as List<*>)
+ .filterIsInstance()
+ .map { nettest: OONIRunNettest ->
+ return@map GroupedItem(
+ name = nettest.name,
+ inputs = nettest.inputs,
+ selected = true
+ )
+ }
+
+ false -> emptyList()
+ }
+}
+
+fun Any?.getValueForKey(language: String): String? {
+ return if (this is Map<*, *>) {
+ this[language] as String?
+ } else {
+ null
+ }
+}
+
+
+@TypeConverterAnnotation
+class MapConverter : TypeConverter() {
+ override fun getDBValue(model: Any?): String? {
+ return Gson().toJson(model)
+ }
+
+ override fun getModelValue(json: String): HashMap {
+ val gson = Gson()
+ val type = object : TypeToken>() {}.type
+ return gson.fromJson(json, type)
+ }
+}
+
+
+@TypeConverterAnnotation
+class NettestConverter : TypeConverter() {
+ override fun getDBValue(model: Any): String = Gson().toJson(model)
+
+ override fun getModelValue(data: String): List<*> = Gson().fromJson(
+ data, Array::class.java
+ ).toList()
+}
+
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/receiver/TestRunBroadRequestReceiver.java b/app/src/main/java/org/openobservatory/ooniprobe/receiver/TestRunBroadRequestReceiver.java
index 76a0687b1..8a79365b6 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/receiver/TestRunBroadRequestReceiver.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/receiver/TestRunBroadRequestReceiver.java
@@ -117,7 +117,9 @@ public void onServiceConnected(ComponentName cname, IBinder binder) {
RunTestService.TestBinder b = (RunTestService.TestBinder) binder;
service = b.getService();
isBound = true;
- if (listener != null) listener.onStart(service);
+ if (listener != null) {
+ listener.onStart(service);
+ }
runtime = ListUtility.sum(Lists.transform(service.task.testSuites, input -> input.getRuntime(preferenceManager)));
}
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/test/suite/AbstractSuite.java b/app/src/main/java/org/openobservatory/ooniprobe/test/suite/AbstractSuite.java
index 1adfbbd9c..286da6ef4 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/test/suite/AbstractSuite.java
+++ b/app/src/main/java/org/openobservatory/ooniprobe/test/suite/AbstractSuite.java
@@ -3,7 +3,7 @@
import android.os.Build;
import androidx.annotation.CallSuper;
-import androidx.annotation.ColorRes;
+import androidx.annotation.ColorInt;
import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
@@ -31,7 +31,7 @@ public abstract class AbstractSuite implements Serializable {
private Result result;
private boolean autoRun;
- AbstractSuite(String name, String title, String cardDesc, @DrawableRes int icon, @DrawableRes int icon_24, @ColorRes int color, @StyleRes int themeLight, @StyleRes int themeDark, String desc1, String anim, int dataUsage) {
+ AbstractSuite(String name, String title, String cardDesc, @DrawableRes int icon, @DrawableRes int icon_24, @ColorInt int color, @StyleRes int themeLight, @StyleRes int themeDark, String desc1, String anim, int dataUsage) {
this.title = title;
this.cardDesc = cardDesc;
this.icon = icon;
diff --git a/app/src/main/java/org/openobservatory/ooniprobe/test/suite/DynamicTestSuite.kt b/app/src/main/java/org/openobservatory/ooniprobe/test/suite/DynamicTestSuite.kt
index 6fa5b3ae9..faff03e87 100644
--- a/app/src/main/java/org/openobservatory/ooniprobe/test/suite/DynamicTestSuite.kt
+++ b/app/src/main/java/org/openobservatory/ooniprobe/test/suite/DynamicTestSuite.kt
@@ -1,10 +1,12 @@
package org.openobservatory.ooniprobe.test.suite
-import androidx.annotation.ColorRes
+import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import org.openobservatory.engine.BaseNettest
import org.openobservatory.ooniprobe.R
import org.openobservatory.ooniprobe.common.PreferenceManager
+import org.openobservatory.ooniprobe.model.database.Result
+import org.openobservatory.ooniprobe.model.database.TestDescriptor
import org.openobservatory.ooniprobe.test.test.AbstractTest
/**
@@ -18,11 +20,12 @@ class DynamicTestSuite(
shortDescription: String,
@DrawableRes icon: Int,
@DrawableRes icon_24: Int,
- @ColorRes color: Int,
+ @ColorInt color: Int,
description: String,
- animation: String,
+ animation: String?,
dataUsage: Int,
- var nettest: List
+ var nettest: List,
+ var descriptor: TestDescriptor? = null
) : AbstractSuite(
name,
title,
@@ -42,6 +45,7 @@ class DynamicTestSuite(
if (autoRun) {
setOrigin(AbstractTest.AUTORUN)
}
+ inputs = it.inputs
}
}.toTypedArray()
@@ -50,4 +54,10 @@ class DynamicTestSuite(
}
return super.getTestList(pm)
}
+
+ override fun getResult(): Result? {
+ val result = super.getResult()
+ result.descriptor = descriptor
+ return result
+ }
}
diff --git a/app/src/main/res/drawable/fa_anchor.xml b/app/src/main/res/drawable/fa_anchor.xml
new file mode 100644
index 000000000..d4c71aa43
--- /dev/null
+++ b/app/src/main/res/drawable/fa_anchor.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_award.xml b/app/src/main/res/drawable/fa_award.xml
new file mode 100644
index 000000000..81260d336
--- /dev/null
+++ b/app/src/main/res/drawable/fa_award.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_bed.xml b/app/src/main/res/drawable/fa_bed.xml
new file mode 100644
index 000000000..45b7c6ebb
--- /dev/null
+++ b/app/src/main/res/drawable/fa_bed.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_bone.xml b/app/src/main/res/drawable/fa_bone.xml
new file mode 100644
index 000000000..dae5e661d
--- /dev/null
+++ b/app/src/main/res/drawable/fa_bone.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_book_reader.xml b/app/src/main/res/drawable/fa_book_reader.xml
new file mode 100644
index 000000000..1f17693a4
--- /dev/null
+++ b/app/src/main/res/drawable/fa_book_reader.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_brush.xml b/app/src/main/res/drawable/fa_brush.xml
new file mode 100644
index 000000000..47a019528
--- /dev/null
+++ b/app/src/main/res/drawable/fa_brush.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_building.xml b/app/src/main/res/drawable/fa_building.xml
new file mode 100644
index 000000000..4254f9646
--- /dev/null
+++ b/app/src/main/res/drawable/fa_building.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_cake_candles.xml b/app/src/main/res/drawable/fa_cake_candles.xml
new file mode 100644
index 000000000..b31b7f87f
--- /dev/null
+++ b/app/src/main/res/drawable/fa_cake_candles.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_car.xml b/app/src/main/res/drawable/fa_car.xml
new file mode 100644
index 000000000..bfcc15eb6
--- /dev/null
+++ b/app/src/main/res/drawable/fa_car.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_carrot.xml b/app/src/main/res/drawable/fa_carrot.xml
new file mode 100644
index 000000000..4b4321cf8
--- /dev/null
+++ b/app/src/main/res/drawable/fa_carrot.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_cloud_showers_heavy.xml b/app/src/main/res/drawable/fa_cloud_showers_heavy.xml
new file mode 100644
index 000000000..c6b759dd3
--- /dev/null
+++ b/app/src/main/res/drawable/fa_cloud_showers_heavy.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_cloud_sun.xml b/app/src/main/res/drawable/fa_cloud_sun.xml
new file mode 100644
index 000000000..51055dad4
--- /dev/null
+++ b/app/src/main/res/drawable/fa_cloud_sun.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_compass.xml b/app/src/main/res/drawable/fa_compass.xml
new file mode 100644
index 000000000..e56c503e9
--- /dev/null
+++ b/app/src/main/res/drawable/fa_compass.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_computer.xml b/app/src/main/res/drawable/fa_computer.xml
new file mode 100644
index 000000000..20d95ed3e
--- /dev/null
+++ b/app/src/main/res/drawable/fa_computer.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_crown.xml b/app/src/main/res/drawable/fa_crown.xml
new file mode 100644
index 000000000..db056c763
--- /dev/null
+++ b/app/src/main/res/drawable/fa_crown.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_cube.xml b/app/src/main/res/drawable/fa_cube.xml
new file mode 100644
index 000000000..e0dc78136
--- /dev/null
+++ b/app/src/main/res/drawable/fa_cube.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_dove.xml b/app/src/main/res/drawable/fa_dove.xml
new file mode 100644
index 000000000..02fd67b06
--- /dev/null
+++ b/app/src/main/res/drawable/fa_dove.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_dragon.xml b/app/src/main/res/drawable/fa_dragon.xml
new file mode 100644
index 000000000..523b2add4
--- /dev/null
+++ b/app/src/main/res/drawable/fa_dragon.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_droplet.xml b/app/src/main/res/drawable/fa_droplet.xml
new file mode 100644
index 000000000..8fabc656f
--- /dev/null
+++ b/app/src/main/res/drawable/fa_droplet.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_faucet.xml b/app/src/main/res/drawable/fa_faucet.xml
new file mode 100644
index 000000000..31ec4b0da
--- /dev/null
+++ b/app/src/main/res/drawable/fa_faucet.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_feather.xml b/app/src/main/res/drawable/fa_feather.xml
new file mode 100644
index 000000000..43fa96de7
--- /dev/null
+++ b/app/src/main/res/drawable/fa_feather.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_film.xml b/app/src/main/res/drawable/fa_film.xml
new file mode 100644
index 000000000..8611526cb
--- /dev/null
+++ b/app/src/main/res/drawable/fa_film.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_fire.xml b/app/src/main/res/drawable/fa_fire.xml
new file mode 100644
index 000000000..12374376d
--- /dev/null
+++ b/app/src/main/res/drawable/fa_fire.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_fish.xml b/app/src/main/res/drawable/fa_fish.xml
new file mode 100644
index 000000000..e6abd769a
--- /dev/null
+++ b/app/src/main/res/drawable/fa_fish.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_flag_checkered.xml b/app/src/main/res/drawable/fa_flag_checkered.xml
new file mode 100644
index 000000000..29b560fd7
--- /dev/null
+++ b/app/src/main/res/drawable/fa_flag_checkered.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_gauge.xml b/app/src/main/res/drawable/fa_gauge.xml
new file mode 100644
index 000000000..398f619e1
--- /dev/null
+++ b/app/src/main/res/drawable/fa_gauge.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_ghost.xml b/app/src/main/res/drawable/fa_ghost.xml
new file mode 100644
index 000000000..e4db79a04
--- /dev/null
+++ b/app/src/main/res/drawable/fa_ghost.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_gift.xml b/app/src/main/res/drawable/fa_gift.xml
new file mode 100644
index 000000000..a403ac6b8
--- /dev/null
+++ b/app/src/main/res/drawable/fa_gift.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_glasses.xml b/app/src/main/res/drawable/fa_glasses.xml
new file mode 100644
index 000000000..67e221708
--- /dev/null
+++ b/app/src/main/res/drawable/fa_glasses.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_graduation_cap.xml b/app/src/main/res/drawable/fa_graduation_cap.xml
new file mode 100644
index 000000000..4730971eb
--- /dev/null
+++ b/app/src/main/res/drawable/fa_graduation_cap.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_guitar.xml b/app/src/main/res/drawable/fa_guitar.xml
new file mode 100644
index 000000000..fe7247ad1
--- /dev/null
+++ b/app/src/main/res/drawable/fa_guitar.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_hammer.xml b/app/src/main/res/drawable/fa_hammer.xml
new file mode 100644
index 000000000..430262cc0
--- /dev/null
+++ b/app/src/main/res/drawable/fa_hammer.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_hand.xml b/app/src/main/res/drawable/fa_hand.xml
new file mode 100644
index 000000000..df2d00345
--- /dev/null
+++ b/app/src/main/res/drawable/fa_hand.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_handshake.xml b/app/src/main/res/drawable/fa_handshake.xml
new file mode 100644
index 000000000..fc7c60c98
--- /dev/null
+++ b/app/src/main/res/drawable/fa_handshake.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_hippo.xml b/app/src/main/res/drawable/fa_hippo.xml
new file mode 100644
index 000000000..fa35d6ffe
--- /dev/null
+++ b/app/src/main/res/drawable/fa_hippo.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_ice_cream.xml b/app/src/main/res/drawable/fa_ice_cream.xml
new file mode 100644
index 000000000..d50d59cc6
--- /dev/null
+++ b/app/src/main/res/drawable/fa_ice_cream.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_industry.xml b/app/src/main/res/drawable/fa_industry.xml
new file mode 100644
index 000000000..9b57b66f9
--- /dev/null
+++ b/app/src/main/res/drawable/fa_industry.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_kiwi_bird.xml b/app/src/main/res/drawable/fa_kiwi_bird.xml
new file mode 100644
index 000000000..025b1d1da
--- /dev/null
+++ b/app/src/main/res/drawable/fa_kiwi_bird.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_magnet.xml b/app/src/main/res/drawable/fa_magnet.xml
new file mode 100644
index 000000000..9a3a6bd91
--- /dev/null
+++ b/app/src/main/res/drawable/fa_magnet.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_map.xml b/app/src/main/res/drawable/fa_map.xml
new file mode 100644
index 000000000..f40243a69
--- /dev/null
+++ b/app/src/main/res/drawable/fa_map.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_map_signs.xml b/app/src/main/res/drawable/fa_map_signs.xml
new file mode 100644
index 000000000..9dca18713
--- /dev/null
+++ b/app/src/main/res/drawable/fa_map_signs.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_meteor.xml b/app/src/main/res/drawable/fa_meteor.xml
new file mode 100644
index 000000000..0e1ea7044
--- /dev/null
+++ b/app/src/main/res/drawable/fa_meteor.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_microscope.xml b/app/src/main/res/drawable/fa_microscope.xml
new file mode 100644
index 000000000..efed4ff46
--- /dev/null
+++ b/app/src/main/res/drawable/fa_microscope.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_moon.xml b/app/src/main/res/drawable/fa_moon.xml
new file mode 100644
index 000000000..1d0cb335d
--- /dev/null
+++ b/app/src/main/res/drawable/fa_moon.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_mountain_sun.xml b/app/src/main/res/drawable/fa_mountain_sun.xml
new file mode 100644
index 000000000..99caa011a
--- /dev/null
+++ b/app/src/main/res/drawable/fa_mountain_sun.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_mug_hot.xml b/app/src/main/res/drawable/fa_mug_hot.xml
new file mode 100644
index 000000000..4a3c8a442
--- /dev/null
+++ b/app/src/main/res/drawable/fa_mug_hot.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_music.xml b/app/src/main/res/drawable/fa_music.xml
new file mode 100644
index 000000000..41e0a80e2
--- /dev/null
+++ b/app/src/main/res/drawable/fa_music.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_paw.xml b/app/src/main/res/drawable/fa_paw.xml
new file mode 100644
index 000000000..dad4cbb75
--- /dev/null
+++ b/app/src/main/res/drawable/fa_paw.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_pen_nib.xml b/app/src/main/res/drawable/fa_pen_nib.xml
new file mode 100644
index 000000000..0927a15ce
--- /dev/null
+++ b/app/src/main/res/drawable/fa_pen_nib.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_pepper_hot.xml b/app/src/main/res/drawable/fa_pepper_hot.xml
new file mode 100644
index 000000000..7890e7f97
--- /dev/null
+++ b/app/src/main/res/drawable/fa_pepper_hot.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_person_biking.xml b/app/src/main/res/drawable/fa_person_biking.xml
new file mode 100644
index 000000000..d971ab9e1
--- /dev/null
+++ b/app/src/main/res/drawable/fa_person_biking.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_pills.xml b/app/src/main/res/drawable/fa_pills.xml
new file mode 100644
index 000000000..81a8a3278
--- /dev/null
+++ b/app/src/main/res/drawable/fa_pills.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_plane.xml b/app/src/main/res/drawable/fa_plane.xml
new file mode 100644
index 000000000..fc5a16a07
--- /dev/null
+++ b/app/src/main/res/drawable/fa_plane.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_plug.xml b/app/src/main/res/drawable/fa_plug.xml
new file mode 100644
index 000000000..115302dd1
--- /dev/null
+++ b/app/src/main/res/drawable/fa_plug.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_radio.xml b/app/src/main/res/drawable/fa_radio.xml
new file mode 100644
index 000000000..e3508650b
--- /dev/null
+++ b/app/src/main/res/drawable/fa_radio.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_rainbow.xml b/app/src/main/res/drawable/fa_rainbow.xml
new file mode 100644
index 000000000..2e3bd8a31
--- /dev/null
+++ b/app/src/main/res/drawable/fa_rainbow.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_ribbon.xml b/app/src/main/res/drawable/fa_ribbon.xml
new file mode 100644
index 000000000..efe92f454
--- /dev/null
+++ b/app/src/main/res/drawable/fa_ribbon.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_road.xml b/app/src/main/res/drawable/fa_road.xml
new file mode 100644
index 000000000..60f571f83
--- /dev/null
+++ b/app/src/main/res/drawable/fa_road.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_rocket.xml b/app/src/main/res/drawable/fa_rocket.xml
new file mode 100644
index 000000000..5fbcec2a4
--- /dev/null
+++ b/app/src/main/res/drawable/fa_rocket.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_sack_dollar.xml b/app/src/main/res/drawable/fa_sack_dollar.xml
new file mode 100644
index 000000000..39e4fba30
--- /dev/null
+++ b/app/src/main/res/drawable/fa_sack_dollar.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_school.xml b/app/src/main/res/drawable/fa_school.xml
new file mode 100644
index 000000000..9e0d3d294
--- /dev/null
+++ b/app/src/main/res/drawable/fa_school.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_scissors.xml b/app/src/main/res/drawable/fa_scissors.xml
new file mode 100644
index 000000000..4ad5a1163
--- /dev/null
+++ b/app/src/main/res/drawable/fa_scissors.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_seedling.xml b/app/src/main/res/drawable/fa_seedling.xml
new file mode 100644
index 000000000..dc04ab510
--- /dev/null
+++ b/app/src/main/res/drawable/fa_seedling.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_shield.xml b/app/src/main/res/drawable/fa_shield.xml
new file mode 100644
index 000000000..667bd6b62
--- /dev/null
+++ b/app/src/main/res/drawable/fa_shield.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_skull.xml b/app/src/main/res/drawable/fa_skull.xml
new file mode 100644
index 000000000..db0d0b3cd
--- /dev/null
+++ b/app/src/main/res/drawable/fa_skull.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_smile.xml b/app/src/main/res/drawable/fa_smile.xml
new file mode 100644
index 000000000..03ee29dba
--- /dev/null
+++ b/app/src/main/res/drawable/fa_smile.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_snowflake.xml b/app/src/main/res/drawable/fa_snowflake.xml
new file mode 100644
index 000000000..b6c51a6e9
--- /dev/null
+++ b/app/src/main/res/drawable/fa_snowflake.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_socks.xml b/app/src/main/res/drawable/fa_socks.xml
new file mode 100644
index 000000000..1768e9cac
--- /dev/null
+++ b/app/src/main/res/drawable/fa_socks.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_spider.xml b/app/src/main/res/drawable/fa_spider.xml
new file mode 100644
index 000000000..ae82070ae
--- /dev/null
+++ b/app/src/main/res/drawable/fa_spider.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_suitcase_medical.xml b/app/src/main/res/drawable/fa_suitcase_medical.xml
new file mode 100644
index 000000000..697ebf040
--- /dev/null
+++ b/app/src/main/res/drawable/fa_suitcase_medical.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_sun.xml b/app/src/main/res/drawable/fa_sun.xml
new file mode 100644
index 000000000..5e051a84e
--- /dev/null
+++ b/app/src/main/res/drawable/fa_sun.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_temperature_three_quarters.xml b/app/src/main/res/drawable/fa_temperature_three_quarters.xml
new file mode 100644
index 000000000..e5abcbc54
--- /dev/null
+++ b/app/src/main/res/drawable/fa_temperature_three_quarters.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_tractor.xml b/app/src/main/res/drawable/fa_tractor.xml
new file mode 100644
index 000000000..46737d840
--- /dev/null
+++ b/app/src/main/res/drawable/fa_tractor.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_tree.xml b/app/src/main/res/drawable/fa_tree.xml
new file mode 100644
index 000000000..e466e8fc8
--- /dev/null
+++ b/app/src/main/res/drawable/fa_tree.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_trophy.xml b/app/src/main/res/drawable/fa_trophy.xml
new file mode 100644
index 000000000..29ca3332a
--- /dev/null
+++ b/app/src/main/res/drawable/fa_trophy.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_umbrella.xml b/app/src/main/res/drawable/fa_umbrella.xml
new file mode 100644
index 000000000..48e9420fa
--- /dev/null
+++ b/app/src/main/res/drawable/fa_umbrella.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_user_astronaut.xml b/app/src/main/res/drawable/fa_user_astronaut.xml
new file mode 100644
index 000000000..e39692128
--- /dev/null
+++ b/app/src/main/res/drawable/fa_user_astronaut.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_utensils.xml b/app/src/main/res/drawable/fa_utensils.xml
new file mode 100644
index 000000000..1bf4c8bec
--- /dev/null
+++ b/app/src/main/res/drawable/fa_utensils.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_volcano.xml b/app/src/main/res/drawable/fa_volcano.xml
new file mode 100644
index 000000000..5ebebe8b7
--- /dev/null
+++ b/app/src/main/res/drawable/fa_volcano.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/fa_wine_glass.xml b/app/src/main/res/drawable/fa_wine_glass.xml
new file mode 100644
index 000000000..9d19cd03d
--- /dev/null
+++ b/app/src/main/res/drawable/fa_wine_glass.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_add_descriptor.xml b/app/src/main/res/layout/activity_add_descriptor.xml
new file mode 100644
index 000000000..73cd5198e
--- /dev/null
+++ b/app/src/main/res/layout/activity_add_descriptor.xml
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index e2e9030ae..0cb487282 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -17,6 +17,12 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_overview.xml b/app/src/main/res/layout/activity_overview.xml
index 226f9a5bc..4efea4b0a 100644
--- a/app/src/main/res/layout/activity_overview.xml
+++ b/app/src/main/res/layout/activity_overview.xml
@@ -132,12 +132,34 @@
android:layout_height="wrap_content"
android:paddingVertical="32dp" />
+
+
+
+
+
+
+
+
-
+
diff --git a/app/src/main/res/layout/item_experimental.xml b/app/src/main/res/layout/item_experimental.xml
index c3eedd18a..6f1669c42 100644
--- a/app/src/main/res/layout/item_experimental.xml
+++ b/app/src/main/res/layout/item_experimental.xml
@@ -26,6 +26,7 @@
android:src="@drawable/test_experimental" />
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/nettest_group_list_item.xml b/app/src/main/res/layout/nettest_group_list_item.xml
new file mode 100644
index 000000000..9cf03582b
--- /dev/null
+++ b/app/src/main/res/layout/nettest_group_list_item.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/run_dynamic_progress_bar.xml b/app/src/main/res/layout/run_dynamic_progress_bar.xml
new file mode 100644
index 000000000..69ab51616
--- /dev/null
+++ b/app/src/main/res/layout/run_dynamic_progress_bar.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
new file mode 100644
index 000000000..ef6942339
--- /dev/null
+++ b/app/src/main/res/menu/add_descriptor.xml
@@ -0,0 +1,8 @@
+
+
\ No newline at end of file
diff --git a/engine/build.gradle b/engine/build.gradle
index 73ca7270e..f10a91813 100644
--- a/engine/build.gradle
+++ b/engine/build.gradle
@@ -32,6 +32,7 @@ android {
}
dependencies {
+ implementation libs.google.gson
// For the stable and dev app flavours we're using the library
// build published at Maven Central.
stableImplementation libs.ooni.oonimkall
diff --git a/engine/src/main/java/org/openobservatory/engine/OONIRunFetchResponse.kt b/engine/src/main/java/org/openobservatory/engine/OONIRunFetchResponse.kt
new file mode 100644
index 000000000..345e6927b
--- /dev/null
+++ b/engine/src/main/java/org/openobservatory/engine/OONIRunFetchResponse.kt
@@ -0,0 +1,88 @@
+package org.openobservatory.engine
+
+import com.google.gson.annotations.SerializedName
+import java.io.Serializable
+import java.util.Date
+import java.util.HashMap
+
+/**
+ * This class represents the response from a fetch request to the OONI API.
+ *
+ * @property archived Whether the descriptor is archived.
+ * @property creationTime The creation time of the descriptor.
+ * @property translationCreationTime The translation creation time of the descriptor.
+ * @property descriptor The descriptor.
+ */
+data class OONIRunFetchResponse(
+ @JvmField
+ val archived: Boolean,
+
+ @JvmField
+ @SerializedName("descriptor_creation_time")
+ val creationTime: Date,
+
+ @JvmField
+ @SerializedName("translation_creation_time")
+ val translationCreationTime: Date,
+
+ @JvmField
+ val descriptor: OONIRunDescriptor
+) : Serializable
+
+/**
+ * Data class representing the OONI Run Descriptor.
+ *
+ * @property author The author of the nettest.
+ * @property description The description of the nettest in English.
+ * @property descriptionIntl The description of the nettest in other languages. The key is the language code and the value is the description in that language.
+ * @property icon The URL of the icon representing the nettest.
+ * @property color The color associated with the nettest in hexadecimal format.
+ * @property animation The URL of the animation representing the nettest.
+ * @property name The name of the nettest in English.
+ * @property nameIntl The name of the nettest in other languages. The key is the language code and the value is the name in that language.
+ * @property shortDescription The short description of the nettest in English.
+ * @property shortDescriptionIntl The short description of the nettest in other languages. The key is the language code and the value is the short description in that language.
+ * @property nettests A list of nettests associated with the run descriptor.
+ *
+ * @see [https://github.com/ooni/spec/blob/master/backends/bk-005-ooni-run-v2.md]
+ */
+data class OONIRunDescriptor(
+ val author: String,
+
+ val description: String,
+
+ @SerializedName("description_intl")
+ val descriptionIntl: HashMap,
+
+ val icon: String,
+
+ val color: String,
+
+ val animation: String,
+
+ var name: String,
+
+ @SerializedName("name_intl")
+ val nameIntl: HashMap,
+
+ @SerializedName("short_description")
+ val shortDescription: String,
+
+ @SerializedName("short_description_intl")
+ val shortDescriptionIntl: HashMap,
+
+ var nettests: List
+) : Serializable
+
+/**
+ * Class representing a single OONI Run Nettest.
+ *
+ * @property name The name of the nettest.
+ * @property inputs The inputs of the nettest.
+ */
+open class OONIRunNettest(
+ @SerializedName("test_name")
+ open var name: String,
+
+ open var inputs: List?
+) : Serializable
diff --git a/engine/src/main/java/org/openobservatory/engine/OONISession.java b/engine/src/main/java/org/openobservatory/engine/OONISession.java
index aedb51b3d..cbb85b44b 100644
--- a/engine/src/main/java/org/openobservatory/engine/OONISession.java
+++ b/engine/src/main/java/org/openobservatory/engine/OONISession.java
@@ -22,4 +22,14 @@ public interface OONISession {
/** checkIn function is called by probes asking if there are tests to be run. */
OONICheckInResults checkIn(OONIContext ctx, OONICheckInConfig config) throws Exception;
+
+ /**
+ * Fetches a specific ooni run descriptor.
+ *
+ * @param ctx OONIContext instance
+ * @param id ooni run id
+ * @return [OONIRunFetchResponse] with the contents of the ooni run descriptor.
+ */
+ OONIRunFetchResponse ooniRunFetch(OONIContext ctx, long id) throws Exception;
+
}
diff --git a/engine/src/main/java/org/openobservatory/engine/PESession.java b/engine/src/main/java/org/openobservatory/engine/PESession.java
index b022d2fad..86a3f3ade 100644
--- a/engine/src/main/java/org/openobservatory/engine/PESession.java
+++ b/engine/src/main/java/org/openobservatory/engine/PESession.java
@@ -1,5 +1,7 @@
package org.openobservatory.engine;
+import android.util.Log;
+import com.google.gson.Gson;
import oonimkall.Oonimkall;
import oonimkall.Session;
@@ -25,4 +27,11 @@ public OONISubmitResults submit(OONIContext ctx, String measurement) throws Exce
public OONICheckInResults checkIn(OONIContext ctx, OONICheckInConfig config) throws Exception {
return new OONICheckInResults(session.checkIn(ctx.ctx, config.toOonimkallCheckInConfig()));
}
+
+ @Override
+ public OONIRunFetchResponse ooniRunFetch(OONIContext ctx, long id) throws Exception {
+ String response = session.ooniRunFetch(ctx.ctx,id);
+ Log.d(PESession.class.getName(), response);
+ return new Gson().fromJson(response, OONIRunFetchResponse.class);
+ }
}