From 09613d902c91e24da22d7ada1f6b1cc2b91575c0 Mon Sep 17 00:00:00 2001 From: Norbel AMBANUMBEN Date: Thu, 21 Mar 2024 15:50:04 +0100 Subject: [PATCH] [New Designs] Implement Dashboard Link update loading and Review Updates (#655) Fixes https://github.com/ooni/probe/issues/2594 ## Proposed Changes - Add a worker to automatically update `descriptors` with `autoUpdate` enabled every 24 hrs. - Add a worker to fetch updates for `descriptors` with `autoUpdate` disabled and return to caller. - Show progress update for descriptors being fetched and prompt to review updates for descriptors with `autoUpdate` disabled in `MainActivity` |.|.|.| |-|-|-| | ![Screenshot_20240123_151744](https://github.com/ooni/probe-android/assets/17911892/69ab0101-1474-4eeb-9921-16151bfd5e1e) | ![Screenshot_20240123_151752](https://github.com/ooni/probe-android/assets/17911892/4bb9197b-8381-447a-8544-87032f399d9a) | ![Screenshot_20240123_151808](https://github.com/ooni/probe-android/assets/17911892/0ade0d66-f4df-43e2-ab6a-cdb8d1739e3a) | | ![Screenshot_20240124_160104](https://github.com/ooni/probe-android/assets/17911892/6d8d67e0-2b3e-4970-9ef1-87ed07f56563) | ![Screenshot_20240124_160101](https://github.com/ooni/probe-android/assets/17911892/53b007d6-9c13-4f26-a31f-a3beb47a78f4) | ![Screenshot_20240124_161236](https://github.com/ooni/probe-android/assets/17911892/e7151462-8395-40f3-8111-91f0b05e2b83) | --------- Co-authored-by: Simone Basso --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 5 + .../ooniprobe/activity/AbstractActivity.java | 17 + .../ooniprobe/activity/MainActivity.java | 142 ++++++- .../ooniprobe/activity/OverviewActivity.java | 84 +++- .../activity/ReviewUpdatesAbstractActivity.kt | 31 ++ .../adddescriptor/AddDescriptorActivity.kt | 15 +- .../adddescriptor/AddDescriptorViewModel.kt | 8 +- .../activity/oonirun/OoniRunV2Activity.kt | 2 +- .../ReviewDescriptorUpdatesActivity.kt | 364 +++++++++++++++++ .../RunTestsExpandableListViewAdapter.kt | 7 +- .../ooniprobe/adapters/DashboardAdapter.kt | 4 +- .../ooniprobe/common/OONIDescriptor.kt | 8 +- .../ooniprobe/common/TestDescriptorManager.kt | 30 ++ .../common/worker/UpdateDescriptorsWorker.kt | 173 ++++++++ .../ooniprobe/di/ActivityComponent.java | 10 +- .../ooniprobe/di/ServiceComponent.java | 2 + .../ooniprobe/fragment/DashboardFragment.kt | 6 + .../OONIRunDynamicProgressBar.kt | 1 + .../model/database/TestDescriptor.kt | 84 +++- .../res/layout/activity_add_descriptor.xml | 3 +- app/src/main/res/layout/activity_main.xml | 2 +- app/src/main/res/layout/activity_overview.xml | 375 ++++++++++-------- .../activity_review_descriptor_updates.xml | 45 +++ .../main/res/layout/fragment_dashboard.xml | 271 +++++++------ .../res/layout/fragment_descriptor_update.xml | 83 ++++ app/src/main/res/menu/add_descriptor.xml | 14 +- app/src/main/res/menu/update_descriptor.xml | 8 + gradle/libs.versions.toml | 2 + 29 files changed, 1445 insertions(+), 352 deletions(-) create mode 100644 app/src/main/java/org/openobservatory/ooniprobe/activity/ReviewUpdatesAbstractActivity.kt create mode 100644 app/src/main/java/org/openobservatory/ooniprobe/activity/reviewdescriptorupdates/ReviewDescriptorUpdatesActivity.kt create mode 100644 app/src/main/java/org/openobservatory/ooniprobe/common/worker/UpdateDescriptorsWorker.kt create mode 100644 app/src/main/res/layout/activity_review_descriptor_updates.xml create mode 100644 app/src/main/res/layout/fragment_descriptor_update.xml create mode 100644 app/src/main/res/menu/update_descriptor.xml diff --git a/app/build.gradle b/app/build.gradle index e32763ef6..42e3e8050 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -112,6 +112,7 @@ dependencies { implementation libs.androidx.preference implementation libs.androidx.localbroadcastmanager implementation libs.androidx.legacy.support.v4 + implementation libs.androidx.work.runtime // Google implementation libs.google.material diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 05c043dd8..4903fd7ed 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -69,6 +69,11 @@ android:exported="false" android:parentActivityName=".activity.MainActivity" android:theme="@style/Theme.MaterialComponents.Light.DarkActionBar.App.NoActionBar" /> + + * This method is called when the task is completed. + */ + protected void removeProgressFragment(@IdRes int id) { + Fragment fragment = getSupportFragmentManager().findFragmentById(id); + if (fragment != null && fragment.isAdded()) { + getSupportFragmentManager().beginTransaction().remove(fragment).commit(); + } + findViewById(id).setVisibility(View.GONE); + } } 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 2d418afe5..e656e059b 100644 --- a/app/src/main/java/org/openobservatory/ooniprobe/activity/MainActivity.java +++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/MainActivity.java @@ -1,6 +1,7 @@ package org.openobservatory.ooniprobe.activity; import static org.openobservatory.ooniprobe.common.service.RunTestService.CHANNEL_ID; +import static org.openobservatory.ooniprobe.common.worker.UpdateDescriptorsWorkerKt.PROGRESS; import android.Manifest; import android.content.Context; @@ -12,34 +13,51 @@ import android.os.Bundle; import android.os.PowerManager; import android.provider.Settings; +import android.view.View; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatDelegate; import androidx.core.content.ContextCompat; +import androidx.work.Constraints; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.ExistingWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; import com.google.android.material.snackbar.Snackbar; import org.openobservatory.ooniprobe.R; +import org.openobservatory.ooniprobe.activity.reviewdescriptorupdates.ReviewDescriptorUpdatesActivity; import org.openobservatory.ooniprobe.common.Application; import org.openobservatory.ooniprobe.common.NotificationUtility; import org.openobservatory.ooniprobe.common.PreferenceManager; +import org.openobservatory.ooniprobe.common.TestDescriptorManager; import org.openobservatory.ooniprobe.common.ThirdPartyServices; import org.openobservatory.ooniprobe.common.service.ServiceUtil; +import org.openobservatory.ooniprobe.common.worker.AutoUpdateDescriptorsWorker; +import org.openobservatory.ooniprobe.common.worker.ManualUpdateDescriptorsWorker; import org.openobservatory.ooniprobe.databinding.ActivityMainBinding; import org.openobservatory.ooniprobe.domain.UpdatesNotificationManager; import org.openobservatory.ooniprobe.fragment.DashboardFragment; import org.openobservatory.ooniprobe.fragment.PreferenceGlobalFragment; import org.openobservatory.ooniprobe.fragment.ResultListFragment; +import org.openobservatory.ooniprobe.fragment.dynamicprogressbar.OONIRunDynamicProgressBar; +import org.openobservatory.ooniprobe.fragment.dynamicprogressbar.OnActionListener; +import org.openobservatory.ooniprobe.fragment.dynamicprogressbar.ProgressType; import java.io.Serializable; +import java.util.concurrent.TimeUnit; import javax.inject.Inject; import localhost.toolkit.app.fragment.ConfirmDialogFragment; -public class MainActivity extends AbstractActivity implements ConfirmDialogFragment.OnConfirmedListener { +public class MainActivity extends ReviewUpdatesAbstractActivity implements ConfirmDialogFragment.OnConfirmedListener { private static final String RES_ITEM = "resItem"; private static final String RES_SNACKBAR_MESSAGE = "resSnackbarMessage"; public static final String NOTIFICATION_DIALOG = "notification"; @@ -54,6 +72,9 @@ public class MainActivity extends AbstractActivity implements ConfirmDialogFragm @Inject PreferenceManager preferenceManager; + @Inject + TestDescriptorManager descriptorManager; + private ActivityResultLauncher requestPermissionLauncher; public static Intent newIntent(Context context, int resItem) { @@ -137,6 +158,115 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } } requestNotificationPermission(); + scheduleWorkers(); + onNewIntent(getIntent()); + } + + private void scheduleWorkers() { + WorkManager.getInstance(this) + .enqueueUniquePeriodicWork( + AutoUpdateDescriptorsWorker.UPDATED_DESCRIPTORS_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + new PeriodicWorkRequest.Builder(AutoUpdateDescriptorsWorker.class, 24, TimeUnit.HOURS) + .setConstraints( + new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ).build() + ); + // TODO(aanorbel): add rules before checking updates + fetchManualUpdate(); + registerReviewLauncher(binding.bottomNavigation, () -> null); + } + + public void fetchManualUpdate() { + OneTimeWorkRequest manualWorkRequest = new OneTimeWorkRequest.Builder(ManualUpdateDescriptorsWorker.class) + .setConstraints( + new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ).build(); + + WorkManager.getInstance(this) + .beginUniqueWork( + ManualUpdateDescriptorsWorker.UPDATED_DESCRIPTORS_WORK_NAME, + ExistingWorkPolicy.REPLACE, + manualWorkRequest + ).enqueue(); + + WorkManager.getInstance(this) + .getWorkInfoByIdLiveData(manualWorkRequest.getId()) + .observe(this, this::onManualUpdatesFetchComplete); + } + + + /** + * Listens to updates from the {@link ManualUpdateDescriptorsWorker}. + *

+ * This method is called after the {@link ManualUpdateDescriptorsWorker} is enqueued. + * The {@link ManualUpdateDescriptorsWorker} task is to fetch updates for the descriptors. + *

+ * If the task is successful, the {@link WorkInfo} object will contain the updated descriptors. + * Otherwise, the {@link WorkInfo} object will be null. + * + * @param workInfo The {@link WorkInfo} of the task. + */ + private void onManualUpdatesFetchComplete(WorkInfo workInfo) { + if (workInfo != null) { + if (workInfo.getProgress().getInt(PROGRESS,-1) >= 0) { + binding.reviewUpdateNotificationFragment.setVisibility(View.VISIBLE); + } + switch (workInfo.getState()) { + case SUCCEEDED -> { + String descriptor = workInfo.getOutputData().getString(ManualUpdateDescriptorsWorker.KEY_UPDATED_DESCRIPTORS); + if (descriptor == null) { + removeProgressFragment(R.id.review_update_notification_fragment); + return; + } + getSupportFragmentManager() + .beginTransaction() + .add( + R.id.review_update_notification_fragment, + OONIRunDynamicProgressBar.newInstance(ProgressType.REVIEW_LINK, new OnActionListener() { + @Override + public void onActionButtonCLicked() { + + getReviewUpdatesLauncher().launch( + ReviewDescriptorUpdatesActivity.newIntent( + MainActivity.this, + descriptor + ) + ); + removeProgressFragment(R.id.review_update_notification_fragment); + } + + @Override + public void onCloseButtonClicked() { + removeProgressFragment(R.id.review_update_notification_fragment); + } + }), + OONIRunDynamicProgressBar.getTAG() + "_review_update_success_notification" + ).commit(); + } + + case ENQUEUED -> getSupportFragmentManager() + .beginTransaction() + .add( + R.id.review_update_notification_fragment, + OONIRunDynamicProgressBar.newInstance(ProgressType.UPDATE_LINK, null), + OONIRunDynamicProgressBar.getTAG() + "_review_update_enqueued_notification" + ).commit(); + + case FAILED -> Snackbar.make( + binding.getRoot(), + R.string.Modal_Error, + Snackbar.LENGTH_LONG + ).setAnchorView(binding.bottomNavigation).show(); + + default -> { + } + } + } } private void requestNotificationPermission() { @@ -191,11 +321,11 @@ protected void onNewIntent(Intent intent) { binding.bottomNavigation.setSelectedItemId(intent.getIntExtra(RES_ITEM, R.id.dashboard)); } else if (intent.getExtras().containsKey(NOTIFICATION_DIALOG)) { new ConfirmDialogFragment.Builder() - .withTitle(intent.getExtras().getString("title")) - .withMessage(intent.getExtras().getString("message")) - .withNegativeButton("") - .withPositiveButton(getString(R.string.Modal_OK)) - .build().show(getSupportFragmentManager(), null); + .withTitle(intent.getExtras().getString("title")) + .withMessage(intent.getExtras().getString("message")) + .withNegativeButton("") + .withPositiveButton(getString(R.string.Modal_OK)) + .build().show(getSupportFragmentManager(), null); } } } 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 344045758..f318f64d1 100644 --- a/app/src/main/java/org/openobservatory/ooniprobe/activity/OverviewActivity.java +++ b/app/src/main/java/org/openobservatory/ooniprobe/activity/OverviewActivity.java @@ -16,18 +16,28 @@ import androidx.annotation.Nullable; import androidx.core.text.TextUtilsCompat; import androidx.core.view.ViewCompat; +import androidx.work.Constraints; +import androidx.work.Data; +import androidx.work.ExistingWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; import com.google.android.material.checkbox.MaterialCheckBox; +import com.google.android.material.snackbar.Snackbar; import org.openobservatory.engine.BaseNettest; import org.openobservatory.ooniprobe.R; 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.activity.reviewdescriptorupdates.ReviewDescriptorUpdatesActivity; 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.common.worker.ManualUpdateDescriptorsWorker; import org.openobservatory.ooniprobe.databinding.ActivityOverviewBinding; import org.openobservatory.ooniprobe.model.database.InstalledDescriptor; import org.openobservatory.ooniprobe.model.database.Result; @@ -41,7 +51,7 @@ import io.noties.markwon.Markwon; -public class OverviewActivity extends AbstractActivity { +public class OverviewActivity extends ReviewUpdatesAbstractActivity { private static final String TEST = "test"; ActivityOverviewBinding binding; @@ -155,6 +165,10 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } setUpOnCLickListeners(); + registerReviewLauncher(binding.getRoot(), () -> { + binding.reviewUpdates.setVisibility(View.GONE); + return null; + }); } private void selectAllBtnStatusObserver(String selectAllBtnStatus) { @@ -186,6 +200,74 @@ 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)); + if (descriptor instanceof InstalledDescriptor) { + binding.swipeRefresh.setOnRefreshListener(() -> { + Data.Builder data = new Data.Builder(); + data.putLongArray(ManualUpdateDescriptorsWorker.KEY_DESCRIPTOR_IDS, new long[]{Objects.requireNonNull(descriptor.getDescriptor()).getRunId()}); + OneTimeWorkRequest manualWorkRequest = new OneTimeWorkRequest.Builder(ManualUpdateDescriptorsWorker.class) + .setConstraints( + new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ).setInputData(data.build()) + .build(); + + WorkManager.getInstance(this) + .beginUniqueWork( + ManualUpdateDescriptorsWorker.UPDATED_DESCRIPTORS_WORK_NAME, + ExistingWorkPolicy.REPLACE, + manualWorkRequest + ).enqueue(); + + WorkManager.getInstance(this) + .getWorkInfoByIdLiveData(manualWorkRequest.getId()) + .observe(this, this::onManualUpdatesFetchComplete); + + }); + } else { + binding.swipeRefresh.setEnabled(false); + } + } + + + /** + * Listens to updates from the {@link ManualUpdateDescriptorsWorker}. + *

+ * This method is called after the {@link ManualUpdateDescriptorsWorker} is enqueued. + * The {@link ManualUpdateDescriptorsWorker} task is to fetch updates for the descriptors. + *

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