diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a3e217c42..526b1dc58 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -95,11 +95,11 @@ + android:theme="@style/Theme.MaterialComponents.Light.DarkActionBar.App.NoActionBar"> diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/CustomWebsiteActivity.java b/app/src/main/java/org/openobservatory/ooniprobe/activity/CustomWebsiteActivity.java deleted file mode 100644 index bef139f13..000000000 --- a/app/src/main/java/org/openobservatory/ooniprobe/activity/CustomWebsiteActivity.java +++ /dev/null @@ -1,124 +0,0 @@ -package org.openobservatory.ooniprobe.activity; - -import android.content.DialogInterface; -import android.os.Bundle; -import android.util.Patterns; -import android.view.View; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.ImageButton; -import androidx.annotation.Nullable; -import localhost.toolkit.app.fragment.ConfirmDialogFragment; -import org.openobservatory.ooniprobe.R; -import org.openobservatory.ooniprobe.common.PreferenceManager; -import org.openobservatory.ooniprobe.databinding.ActivityCustomwebsiteBinding; -import org.openobservatory.ooniprobe.model.database.Url; -import org.openobservatory.ooniprobe.test.suite.WebsitesSuite; - -import javax.inject.Inject; -import java.io.Serializable; -import java.util.ArrayList; - -public class CustomWebsiteActivity extends AbstractActivity implements ConfirmDialogFragment.OnConfirmedListener { - private ArrayList editTexts; - private ArrayList deletes; - - @Inject - PreferenceManager preferenceManager; - private ActivityCustomwebsiteBinding binding; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - getActivityComponent().inject(this); - binding = ActivityCustomwebsiteBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - editTexts = new ArrayList<>(); - deletes = new ArrayList<>(); - binding.bottomBar.inflateMenu(R.menu.run); - binding.bottomBar.setOnMenuItemClickListener(item -> { - if (!checkPrefix()) - return false; - ArrayList urls = new ArrayList<>(editTexts.size()); - for (EditText editText : editTexts) { - String value = editText.getText().toString(); - String sanitizedUrl = value.replaceAll("\\r\\n|\\r|\\n", " "); - //https://support.microsoft.com/en-us/help/208427/maximum-url-length-is-2-083-characters-in-internet-explorer - if (Patterns.WEB_URL.matcher(sanitizedUrl).matches() && sanitizedUrl.length() < 2084) - urls.add(Url.checkExistingUrl(sanitizedUrl).toString()); - } - WebsitesSuite suite = new WebsitesSuite(); - suite.getTestList(preferenceManager)[0].setInputs(urls); - - RunningActivity.runAsForegroundService(CustomWebsiteActivity.this, suite.asArray(), this::finish, preferenceManager); - return true; - }); - binding.add.setOnClickListener(v -> add()); - add(); - } - - @Override - public void onBackPressed() { - String base = getString(R.string.http); - boolean edited = false; - for (EditText editText : editTexts) - if (!editText.getText().toString().equals(base)) { - edited = true; - break; - } - if (edited) - new ConfirmDialogFragment.Builder() - .withMessage(getString(R.string.Modal_CustomURL_NotSaved)) - .build().show(getSupportFragmentManager(), null); - else - super.onBackPressed(); - } - - public boolean checkPrefix(){ - boolean prefix = true; - for (EditText editText : editTexts) - if (!editText.getText().toString().contains("http://") - && !editText.getText().toString().contains("https://")) { - prefix = false; - editText.setError(getString(R.string.Settings_Websites_CustomURL_NoURLEntered)); - } - return prefix; - } - - @Override - public boolean onSupportNavigateUp() { - onBackPressed(); - return true; - } - - void add() { - ViewGroup urlBox = (ViewGroup) getLayoutInflater().inflate(R.layout.edittext_url, binding.urlContainer, false); - EditText editText = urlBox.findViewById(R.id.editText); - editTexts.add(editText); - binding.urlContainer.addView(urlBox); - ImageButton delete = urlBox.findViewById(R.id.delete); - deletes.add(delete); - delete.setTag(editText); - delete.setOnClickListener(v -> { - EditText tag = (EditText) v.getTag(); - ((View) v.getParent()).setVisibility(View.GONE); - editTexts.remove(tag); - deletes.remove(v); - binding.bottomBar.setTitle(getString(R.string.OONIRun_URLs, Integer.toString(editTexts.size()))); - setVisibilityDelete(); - }); - setVisibilityDelete(); - binding.bottomBar.setTitle(getString(R.string.OONIRun_URLs, Integer.toString(editTexts.size()))); - } - - private void setVisibilityDelete() { - for (ImageButton delete : deletes) - delete.setVisibility(deletes.size() > 1 ? View.VISIBLE : View.INVISIBLE); - } - - @Override - public void onConfirmation(Serializable serializable, int i) { - if (i == DialogInterface.BUTTON_POSITIVE) - super.onBackPressed(); - } -} 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 92b952b5b..8d78c2a48 100644 --- a/app/src/main/java/org/openobservatory/ooniprobe/activity/OverviewActivity.java +++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/OverviewActivity.java @@ -11,6 +11,7 @@ import androidx.core.view.ViewCompat; import org.openobservatory.ooniprobe.R; +import org.openobservatory.ooniprobe.activity.customwebsites.CustomWebsiteActivity; import org.openobservatory.ooniprobe.common.PreferenceManager; import org.openobservatory.ooniprobe.common.ReadMorePlugin; import org.openobservatory.ooniprobe.databinding.ActivityOverviewBinding; diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/customwebsites/CustomWebsiteActivity.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/customwebsites/CustomWebsiteActivity.kt new file mode 100644 index 000000000..e7b71d319 --- /dev/null +++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/customwebsites/CustomWebsiteActivity.kt @@ -0,0 +1,171 @@ +package org.openobservatory.ooniprobe.activity.customwebsites + +import android.content.DialogInterface +import android.os.Bundle +import android.os.Parcelable +import android.util.Patterns +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.activity.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import org.openobservatory.ooniprobe.R +import org.openobservatory.ooniprobe.activity.AbstractActivity +import org.openobservatory.ooniprobe.activity.RunningActivity +import org.openobservatory.ooniprobe.activity.customwebsites.adapter.CustomWebsiteRecyclerViewAdapter +import org.openobservatory.ooniprobe.activity.customwebsites.adapter.ItemChangedListener +import org.openobservatory.ooniprobe.common.PreferenceManager +import org.openobservatory.ooniprobe.databinding.ActivityCustomwebsiteBinding +import org.openobservatory.ooniprobe.fragment.ConfirmDialogFragment +import org.openobservatory.ooniprobe.model.database.Url +import org.openobservatory.ooniprobe.test.suite.WebsitesSuite +import java.io.Serializable +import javax.inject.Inject + +/** This activity allows a user to test a custom website. */ +class CustomWebsiteActivity : AbstractActivity(), ConfirmDialogFragment.OnClickListener { + @Inject + lateinit var preferenceManager: PreferenceManager + + val viewModel: CustomWebsiteViewModel by viewModels() + + private lateinit var adapter: CustomWebsiteRecyclerViewAdapter + private lateinit var binding: ActivityCustomwebsiteBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + activityComponent.inject(this) + binding = ActivityCustomwebsiteBinding.inflate( + layoutInflater + ) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + val layoutManager = LinearLayoutManager(this) + binding.urlContainer.layoutManager = layoutManager + adapter = CustomWebsiteRecyclerViewAdapter( + onItemChangedListener = object : ItemChangedListener { + override fun onItemRemoved(position: Int) { + binding.bottomBar.title = getString( + R.string.OONIRun_URLs, adapter.itemCount.toString() + ) + viewModel.onItemRemoved(position) + } + + override fun onItemUpdated(position: Int, item: String) { + viewModel.updateUrlAt(position, item) + } + }, + ) + viewModel.urls.observe(this) { urls -> + binding.bottomBar.title = getString( + R.string.OONIRun_URLs, urls.size.toString() + ) + } + + binding.bottomBar.setOnMenuItemClickListener { item: MenuItem? -> runTests() } + binding.add.setOnClickListener { add() } + + binding.urlContainer.adapter = adapter + if (viewModel.urls.value == null) { + add() + } + } + + override fun onResume() { + super.onResume() + viewModel.urls.value?.let { urls -> + adapter.submitList(urls) + binding.urlContainer.post { adapter.notifyDataSetChanged() } + } + } + + /** + * This function will run the tests if the list of urls is not empty. + * If the list is empty, it will not run the tests. + * This function will also sanitize the url and remove any new lines. + * It will also check if the url is valid and not too long. + * If the url is not valid or too long, it will not be added to the tests. + */ + private fun runTests(): Boolean { + val items = viewModel.urls.value ?: listOf() + if (items.isEmpty()) { + return false + } + val urls = ArrayList(items.size) + for (value in items) { + val sanitizedUrl = value.replace("\\r\\n|\\r|\\n".toRegex(), " ") + //https://support.microsoft.com/en-us/help/208427/maximum-url-length-is-2-083-characters-in-internet-explorer + if (Patterns.WEB_URL.matcher(sanitizedUrl) + .matches() && sanitizedUrl.length < 2084 + ) urls.add( + Url.checkExistingUrl(sanitizedUrl).toString() + ) + } + val suite = WebsitesSuite() + suite.getTestList(preferenceManager)[0].inputs = urls + RunningActivity.runAsForegroundService( + this@CustomWebsiteActivity, suite.asArray(), { finish() }, preferenceManager + ) + return true + } + + /** + * This function will show a dialog if the user has edited the list of urls. + * If the user has edited the list of urls, it will show a dialog asking if the user wants to save the changes. + * If the user has not edited the list of urls, it will just call super.onBackPressed() + */ + override fun onBackPressed() { + val base = getString(R.string.http) + val edited = adapter.itemCount > 0 && viewModel.urls.value?.get(0) != base + if (edited) { + ConfirmDialogFragment( + title = getString(R.string.Modal_CustomURL_Title_NotSaved), + message = getString(R.string.Modal_CustomURL_NotSaved), + ).show(supportFragmentManager, null) + } else { + super.onBackPressed() + } + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } + + 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 -> { + onSupportNavigateUp() + true + } + + else -> super.onOptionsItemSelected(item) + } + } + + /** + * This function will add a new url to the list of urls. + * It will also scroll to the bottom of the list. + */ + fun add() { + viewModel.addUrl(getString(R.string.http)) + binding.urlContainer.layoutManager?.scrollToPosition(adapter.itemCount - 1) + } + + /** + * This function will be called when the user clicks on a button in the dialog. + * If the user clicks on the positive button, it will call super.onBackPressed() + */ + override fun onConfirmDialogClick( + serializable: Serializable?, parcelable: Parcelable?, buttonClicked: Int + ) { + if (buttonClicked == DialogInterface.BUTTON_POSITIVE) super.onBackPressed() + } +} diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/customwebsites/CustomWebsiteViewModel.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/customwebsites/CustomWebsiteViewModel.kt new file mode 100644 index 000000000..1cc96d1a1 --- /dev/null +++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/customwebsites/CustomWebsiteViewModel.kt @@ -0,0 +1,51 @@ +package org.openobservatory.ooniprobe.activity.customwebsites + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +/** + * This class is used to store the data for the CustomWebsiteActivity. + * The data is stored in a ViewModel so that it can survive configuration changes (like rotation). + * This class shound not be injected to the activity using a DI framework. + * Dagger forces the recreation of the [ViewModel] on configuration + * as oposed to using `by viewModels()` which remembers the last state. + */ +class CustomWebsiteViewModel : ViewModel() { + + val urls = MutableLiveData>() + + /** + * This function will add a new url to the list of urls. + * If the list is null, it will create a new list. + */ + fun addUrl(url: String) { + val currentUrls = urls.value ?: ArrayList() + currentUrls.add(url) + urls.value = currentUrls + } + + /** + * This function will remove a url from the list of urls. + * If the list is null, it will not do anything. + */ + fun onItemRemoved(position: Int) { + val currentList = urls.value ?: mutableListOf() + if (position < currentList.size) { + currentList.removeAt(position) + urls.value = currentList + } + } + + /** + * This function will update the url at the given position. + * If the list is null, it will not do anything. + */ + fun updateUrlAt(position: Int, newUrl: String) { + val currentList = urls.value ?: mutableListOf() + if (position < currentList.size) { + currentList[position] = newUrl + urls.value = currentList + } + } + +} diff --git a/app/src/main/java/org/openobservatory/ooniprobe/activity/customwebsites/adapter/CustomWebsiteRecyclerViewAdapter.kt b/app/src/main/java/org/openobservatory/ooniprobe/activity/customwebsites/adapter/CustomWebsiteRecyclerViewAdapter.kt new file mode 100644 index 000000000..de3123aec --- /dev/null +++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/customwebsites/adapter/CustomWebsiteRecyclerViewAdapter.kt @@ -0,0 +1,91 @@ +package org.openobservatory.ooniprobe.activity.customwebsites.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.widget.addTextChangedListener +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.openobservatory.ooniprobe.databinding.EdittextUrlBinding +import org.openobservatory.ooniprobe.activity.customwebsites.CustomWebsiteActivity + + +/** + * [RecyclerView.Adapter] that can display an editable list of [String] on the [CustomWebsiteActivity]. + */ +class CustomWebsiteRecyclerViewAdapter( + private val onItemChangedListener: ItemChangedListener, +) : ListAdapter(URL_DIFF_CALLBACK) { + + companion object { + /** + * Used to calculate the difference between two lists. + * This is used by the adapter to figure out if it needs to update the UI + * The adapter will update the UI if this function returns false + */ + private val URL_DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem == newItem + } + } + } + + /** + * Called (by the layout manager) when RecyclerView needs a new [ViewHolder] of the given type to represent an item. + * This new ViewHolder should be constructed with a new View that can represent the items of the given type. + * You can either create a new View manually or inflate it from an XML layout file. + */ + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + EdittextUrlBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + /** + * Called by RecyclerView to display the data at the specified position. + * This method should update the contents of the [ViewHolder.itemView] to reflect the item at the given position. + */ + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.binding.editText.setText(getItem(position)) + holder.binding.delete.visibility = View.VISIBLE + holder.binding.delete.setOnClickListener { + onItemChangedListener.onItemRemoved(holder.adapterPosition) + notifyItemRemoved(holder.adapterPosition) + } + holder.binding.editText.addTextChangedListener { + onItemChangedListener.onItemUpdated(position, it.toString()) + } + } + + /** + * A ViewHolder describes an item view and metadata about its place within the RecyclerView. + */ + class ViewHolder(val binding: EdittextUrlBinding) : RecyclerView.ViewHolder(binding.root) +} + +/** + * Interface to listen for changes in the list of [CustomWebsiteActivity] + */ +interface ItemChangedListener { + /** + * Called when an item is removed from the list + * @param position The position of the item in the list + */ + fun onItemRemoved(position: Int) + + /** + * Called when an item is updated in the list + * @param position The position of the item in the list + * @param item The updated string + */ + fun onItemUpdated(position: Int, item: String) +} \ 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 f88835ebf..7595e7ba1 100644 --- a/app/src/main/java/org/openobservatory/ooniprobe/di/ActivityComponent.java +++ b/app/src/main/java/org/openobservatory/ooniprobe/di/ActivityComponent.java @@ -1,7 +1,7 @@ package org.openobservatory.ooniprobe.di; -import org.openobservatory.ooniprobe.activity.CustomWebsiteActivity; +import org.openobservatory.ooniprobe.activity.customwebsites.CustomWebsiteActivity; import org.openobservatory.ooniprobe.activity.LogActivity; import org.openobservatory.ooniprobe.activity.MainActivity; import org.openobservatory.ooniprobe.activity.MeasurementDetailActivity; diff --git a/app/src/main/java/org/openobservatory/ooniprobe/fragment/ConfirmDialogFragment.kt b/app/src/main/java/org/openobservatory/ooniprobe/fragment/ConfirmDialogFragment.kt new file mode 100644 index 000000000..b951c6fd4 --- /dev/null +++ b/app/src/main/java/org/openobservatory/ooniprobe/fragment/ConfirmDialogFragment.kt @@ -0,0 +1,105 @@ +package org.openobservatory.ooniprobe.fragment + +import android.content.DialogInterface +import android.os.Bundle +import android.os.Parcelable +import androidx.annotation.IntDef +import androidx.core.text.HtmlCompat +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.openobservatory.ooniprobe.R +import java.io.Serializable + +class ConfirmDialogFragment( + serializable: Serializable? = null, + parcelable: Parcelable? = null, + title: String? = null, + message: String? = null, + positiveButton: String? = null, + negativeButton: String? = null, + neutralButton: String? = null +) : DialogFragment(), DialogInterface.OnClickListener { + companion object { + private const val MESSAGE = "MESSAGE" + private const val TITLE = "TITLE" + private const val POSITIVE_BUTTON = "POSITIVE_BUTTON" + private const val NEGATIVE_BUTTON = "NEGATIVE_BUTTON" + private const val NEUTRAL_BUTTON = "NEUTRAL_BUTTON" + private const val SERIALIZABLE = "SERIALIZABLE" + private const val PARCELABLE = "PARCELABLE" + } + + private val listener: OnClickListener + get() = parentFragment as? OnClickListener ?: requireActivity() as OnClickListener + + init { + Bundle().apply { + title?.let { putString(TITLE, it) } + message?.let { putString(MESSAGE, it) } + positiveButton?.let { putString(POSITIVE_BUTTON, it) } + negativeButton?.let { putString(NEGATIVE_BUTTON, it) } + neutralButton?.let { putString(NEUTRAL_BUTTON, it) } + serializable?.let { putSerializable(SERIALIZABLE, it) } + parcelable?.let { putParcelable(PARCELABLE, it) } + }.let { + if (!it.isEmpty) arguments = it + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + isCancelable = false + } + + override fun onCreateDialog(savedInstanceState: Bundle?) = + MaterialAlertDialogBuilder(requireContext(), R.style.Theme_App_MaterialDialogAlert).apply { + setTitle(requireArguments().getString(TITLE)) + if (requireArguments().containsKey(MESSAGE)) setMessage( + HtmlCompat.fromHtml( + requireArguments().getString(MESSAGE)!!, + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + ) + setPositiveButton( + requireArguments().getString( + POSITIVE_BUTTON, + getString(android.R.string.ok) + ), this@ConfirmDialogFragment + ) + setNegativeButton( + requireArguments().getString( + NEGATIVE_BUTTON, + getString(android.R.string.cancel) + ), this@ConfirmDialogFragment + ) + if (requireArguments().containsKey(NEUTRAL_BUTTON)) setNeutralButton( + requireArguments().getString( + NEUTRAL_BUTTON + ), this@ConfirmDialogFragment + ) + }.create() + + override fun onClick(dialog: DialogInterface, which: Int) { + listener.onConfirmDialogClick( + requireArguments().getSerializable(SERIALIZABLE), + requireArguments().getParcelable(PARCELABLE), + which + ) + } + + interface OnClickListener { + fun onConfirmDialogClick( + serializable: Serializable?, + parcelable: Parcelable?, + @ConfirmDialogButton buttonClicked: Int + ) + } +} + +@Retention(AnnotationRetention.SOURCE) +@IntDef( + DialogInterface.BUTTON_POSITIVE, + DialogInterface.BUTTON_NEGATIVE, + DialogInterface.BUTTON_NEUTRAL +) +internal annotation class ConfirmDialogButton diff --git a/app/src/main/res/drawable/add_circle_outline.xml b/app/src/main/res/drawable/add_circle_outline.xml new file mode 100644 index 000000000..9b10da783 --- /dev/null +++ b/app/src/main/res/drawable/add_circle_outline.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_remove_circle_24.xml b/app/src/main/res/drawable/ic_baseline_remove_circle_24.xml deleted file mode 100644 index 3ce4ba381..000000000 --- a/app/src/main/res/drawable/ic_baseline_remove_circle_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_customwebsite.xml b/app/src/main/res/layout/activity_customwebsite.xml index 2c0786840..e140b50e3 100644 --- a/app/src/main/res/layout/activity_customwebsite.xml +++ b/app/src/main/res/layout/activity_customwebsite.xml @@ -1,9 +1,23 @@ + android:orientation="vertical" + tools:context=".activity.customwebsites.CustomWebsiteActivity"> + + + + - + tools:listitem="@layout/edittext_url"/>