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" /> + + + + + + + + + + + + + + + +