From ecbff7e3b8c84b5d40f33bdae3369da090ef0e48 Mon Sep 17 00:00:00 2001 From: Ashish Date: Thu, 8 Apr 2021 18:29:07 +0530 Subject: [PATCH] Fixes #3790- Use WorkManagers to upload contributions (#4298) * Fixes #3790 Use WorkManagers to process upload contributions ** Removed UploadService and Added UploadWorker to process contributions Upload ** Made nescessary changes to remove the usages of the Service from the classes ** UI Fxies- Minor changes in the retry and cancel uplaod icons to give them a clickable area of 48 dp * Fixes #3790 Use WorkManagers to process upload contributions ** Removed UploadService and Added UploadWorker to process contributions Upload ** Made nescessary changes to remove the usages of the Service from the classes ** UI Fxies- Minor changes in the retry and cancel uplaod icons to give them a clickable area of 48 dp * Updated JavaDocs in UploadWorker, Fixed Test cases * Updated JavaDocs in UploadWorker, Fixed Test cases * Updated gradle * Revert "Updated gradle" This reverts commit c8979fe6dc7d97d49c108e99bccb28c92991a706. * rolledback to compileSDKVersion 28, fixed tests * Don't call the show notifications on the main thread * Bug Fix- Duplicate contributions, handle upload stash errors --- app/build.gradle | 6 + app/src/main/AndroidManifest.xml | 3 +- .../free/nrw/commons/CommonsApplication.java | 9 +- .../free/nrw/commons/auth/SessionManager.java | 10 + .../nrw/commons/contributions/ChunkInfo.kt | 2 +- .../contributions/ContributionController.java | 6 +- .../contributions/ContributionDao.java | 13 +- .../contributions/ContributionsContract.java | 4 + .../contributions/ContributionsFragment.java | 106 +--- .../ContributionsLocalDataSource.java | 31 +- .../contributions/ContributionsPresenter.java | 32 +- .../ContributionsRepository.java | 4 + .../commons/contributions/MainActivity.java | 26 +- .../WikipediaInstructionsDialogFragment.kt | 2 +- .../commons/di/ApplicationlessInjection.java | 9 +- .../di/CommonsApplicationComponent.java | 3 + .../commons/di/CommonsApplicationModule.java | 2 +- .../nrw/commons/di/ServiceBuilderModule.java | 4 - .../commons/repository/UploadRepository.java | 23 +- .../nrw/commons/upload/UploadActivity.java | 15 +- .../free/nrw/commons/upload/UploadClient.java | 28 +- .../nrw/commons/upload/UploadContract.java | 2 + .../nrw/commons/upload/UploadController.java | 75 +-- .../nrw/commons/upload/UploadPresenter.java | 6 +- .../free/nrw/commons/upload/UploadResult.kt | 8 +- .../nrw/commons/upload/UploadService.java | 488 ------------------ .../nrw/commons/upload/worker/UploadWorker.kt | 480 +++++++++++++++++ .../commons/wikidata/WikidataEditService.java | 21 +- .../main/res/layout/layout_contribution.xml | 14 +- app/src/main/res/values-vi/strings.xml | 7 +- app/src/main/res/values/strings.xml | 10 +- .../pictures/BookmarkPictureDaoTest.kt | 2 +- .../ContributionsPresenterTest.kt | 9 +- .../recentsearches/RecentSearchesDaoTest.kt | 2 +- .../commons/upload/UploadControllerTest.kt | 30 +- gradle.properties | 2 +- 36 files changed, 692 insertions(+), 802 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt diff --git a/app/build.gradle b/app/build.gradle index 55b8a6078..b60447c5a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -67,9 +67,11 @@ dependencies { implementation "com.squareup.okhttp3:logging-interceptor:$OKHTTP_VERSION" // Dependency injector + implementation "com.google.dagger:dagger-android:$DAGGER_VERSION" implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION" kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION" kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION" + annotationProcessor "com.google.dagger:dagger-android-processor:$DAGGER_VERSION" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION" implementation "org.jetbrains.kotlin:kotlin-reflect:$KOTLIN_VERSION" @@ -145,6 +147,10 @@ dependencies { implementation "androidx.preference:preference-ktx:$PREFERENCE_VERSION" implementation "androidx.multidex:multidex:$MULTIDEX_VERSION" + + def work_version = "2.4.0" + // Kotlin + coroutines + implementation "androidx.work:work-runtime-ktx:$work_version" } android { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 40974f29b..a1e81be2e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -135,8 +135,7 @@ android:name=".review.ReviewActivity" android:label="@string/title_activity_review" /> - - diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java index 9a1d8187b..aad8e4321 100644 --- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java @@ -47,7 +47,9 @@ import io.reactivex.internal.functions.Functions; import io.reactivex.plugins.RxJavaPlugins; import io.reactivex.schedulers.Schedulers; import java.io.File; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; import javax.inject.Inject; import javax.inject.Named; @@ -126,7 +128,12 @@ public class CommonsApplication extends MultiDexApplication { @Inject ContributionDao contributionDao; - /** + /** + * In memory list of contributios whose uploads ahve been paused by the user + */ + public static Map pauseUploads = new HashMap<>(); + + /** * Used to declare and initialize various components and dependencies */ @Override diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java index 9861a3007..a7905f8ea 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java +++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java @@ -136,4 +136,14 @@ public class SessionManager { currentAccount = null; }); } + + /** + * Return a corresponding boolean preference + * + * @param key + * @return + */ + public boolean getPreference(String key) { + return defaultKvStore.getBoolean(key); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt index 8312820d9..0ef4066a2 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ChunkInfo.kt @@ -5,7 +5,7 @@ import android.os.Parcelable import fr.free.nrw.commons.upload.UploadResult data class ChunkInfo( - val uploadResult: UploadResult, + val uploadResult: UploadResult?, val indexOfNextChunkToUpload: Int, val totalChunks: Int ) : Parcelable { diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java index 6f5fca967..09b92672b 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java @@ -1,13 +1,11 @@ package fr.free.nrw.commons.contributions; -import static fr.free.nrw.commons.upload.UploadService.EXTRA_FILES; import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; import android.Manifest; import android.app.Activity; import android.content.Context; import android.content.Intent; -import android.util.Log; import androidx.annotation.NonNull; import fr.free.nrw.commons.R; import fr.free.nrw.commons.filepicker.DefaultCallback; @@ -29,7 +27,6 @@ import javax.inject.Singleton; public class ContributionController { public static final String ACTION_INTERNAL_UPLOADS = "internalImageUploads"; - private final JsonKvStore defaultKvStore; @Inject @@ -130,7 +127,8 @@ public class ContributionController { List imagesFiles) { Intent shareIntent = new Intent(context, UploadActivity.class); shareIntent.setAction(ACTION_INTERNAL_UPLOADS); - shareIntent.putParcelableArrayListExtra(EXTRA_FILES, new ArrayList<>(imagesFiles)); + shareIntent + .putParcelableArrayListExtra(UploadActivity.EXTRA_FILES, new ArrayList<>(imagesFiles)); Place place = defaultKvStore.getJson(PLACE_OBJECT, Place.class); if (place != null) { diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java index 7aeb7558b..a5b3c6a1b 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java @@ -39,12 +39,6 @@ public abstract class ContributionDao { saveSynchronous(newContribution); } - public Completable saveAndDelete(final Contribution oldContribution, - final Contribution newContribution) { - return Completable - .fromAction(() -> deleteAndSaveContribution(oldContribution, newContribution)); - } - @Insert(onConflict = OnConflictStrategy.REPLACE) public abstract Single> save(List contribution); @@ -62,11 +56,8 @@ public abstract class ContributionDao { @Query("SELECT * from contribution WHERE pageId=:pageId") public abstract Contribution getContribution(String pageId); - @Query("SELECT * from contribution WHERE state=:state") - public abstract Single> getContribution(int state); - - @Query("UPDATE contribution SET state=:state WHERE state in (:toUpdateStates)") - public abstract Single updateStates(int state, int[] toUpdateStates); + @Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC") + public abstract Single> getContribution(List states); @Query("SELECT COUNT(*) from contribution WHERE state in (:toUpdateStates)") public abstract Single getPendingUploads(int[] toUpdateStates); diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java index f73ce5501..b8a2488b2 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.contributions; +import android.content.Context; import fr.free.nrw.commons.BasePresenter; /** @@ -10,6 +11,8 @@ public class ContributionsContract { public interface View { void showMessage(String localizedMessage); + + Context getContext(); } public interface UserActionListener extends BasePresenter { @@ -18,5 +21,6 @@ public class ContributionsContract { void deleteUpload(Contribution contribution); + void saveContribution(Contribution contribution); } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java index 78d5b6ccf..8753e443c 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java @@ -6,20 +6,14 @@ import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween; import android.Manifest; import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.ComponentName; import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; import android.os.Bundle; -import android.os.IBinder; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.MenuItem.OnMenuItemClickListener; import android.view.View; -import android.view.View.OnClickListener; import android.view.ViewGroup; import android.widget.CheckBox; import android.widget.LinearLayout; @@ -27,28 +21,15 @@ import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.widget.SwitchCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager.OnBackStackChangedListener; import androidx.fragment.app.FragmentTransaction; - -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.MediaDataExtractor; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.notification.Notification; -import fr.free.nrw.commons.notification.NotificationController; -import fr.free.nrw.commons.theme.BaseActivity; -import fr.free.nrw.commons.upload.UploadService.ServiceCallback; -import io.reactivex.disposables.Disposable; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Named; - import butterknife.BindView; import butterknife.ButterKnife; +import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; +import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.campaigns.Campaign; import fr.free.nrw.commons.campaigns.CampaignView; import fr.free.nrw.commons.campaigns.CampaignsPresenter; @@ -66,8 +47,10 @@ import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; import fr.free.nrw.commons.nearby.NearbyController; import fr.free.nrw.commons.nearby.NearbyNotificationCardView; import fr.free.nrw.commons.nearby.Place; +import fr.free.nrw.commons.notification.Notification; import fr.free.nrw.commons.notification.NotificationActivity; -import fr.free.nrw.commons.upload.UploadService; +import fr.free.nrw.commons.notification.NotificationController; +import fr.free.nrw.commons.theme.BaseActivity; import fr.free.nrw.commons.utils.ConfigUtils; import fr.free.nrw.commons.utils.DialogUtil; import fr.free.nrw.commons.utils.NetworkUtils; @@ -77,6 +60,9 @@ import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.schedulers.Schedulers; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; import timber.log.Timber; public class ContributionsFragment @@ -85,7 +71,7 @@ public class ContributionsFragment OnBackStackChangedListener, LocationUpdateListener, MediaDetailProvider, - ICampaignsView, ContributionsContract.View, Callback , ServiceCallback { + ICampaignsView, ContributionsContract.View, Callback{ @Inject @Named("default_preferences") JsonKvStore store; @Inject NearbyController nearbyController; @Inject OkHttpJsonApiClient okHttpJsonApiClient; @@ -93,8 +79,6 @@ public class ContributionsFragment @Inject LocationServiceManager locationManager; @Inject NotificationController notificationController; - private UploadService uploadService; - private boolean isUploadServiceConnected; private CompositeDisposable compositeDisposable = new CompositeDisposable(); private ContributionsListFragment contributionsListFragment; @@ -127,33 +111,7 @@ public class ContributionsFragment return fragment; } - /** - * Since we will need to use parent activity on onAuthCookieAcquired, we have to wait - * fragment to be attached. Latch will be responsible for this sync. - */ - private ServiceConnection uploadServiceConnection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName componentName, IBinder binder) { - uploadService = (UploadService) ((UploadService.UploadServiceLocalBinder) binder) - .getService(); - uploadService.setServiceCallback(ContributionsFragment.this); - isUploadServiceConnected = true; - } - - @Override - public void onServiceDisconnected(ComponentName componentName) { - // this should never happen - Timber.e(new RuntimeException("UploadService died but the rest of the process did not!")); - isUploadServiceConnected = false; - } - - @Override - public void onBindingDied(final ComponentName name) { - isUploadServiceConnected = false; - } - }; private boolean shouldShowMediaDetailsFragment; - private boolean isAuthCookieAcquired; @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -272,7 +230,6 @@ public class ContributionsFragment until fragment life time ends. */ if (!isFragmentAttachedBefore && getActivity() != null) { - onAuthCookieAcquired(); isFragmentAttachedBefore = true; } } @@ -312,19 +269,6 @@ public class ContributionsFragment fetchCampaigns(); } - /** - * Called when onAuthCookieAcquired is called on authenticated parent activity - */ - void onAuthCookieAcquired() { - // Since we call onAuthCookieAcquired method from onAttach, isAdded is still false. So don't use it - isAuthCookieAcquired=true; - if (getActivity() != null) { // If fragment is attached to parent activity - getActivity().bindService(getUploadServiceIntent(), uploadServiceConnection, Context.BIND_AUTO_CREATE); - isUploadServiceConnected = true; - } - - } - private void initFragments() { if (null == contributionsListFragment) { contributionsListFragment = new ContributionsListFragment(); @@ -381,13 +325,6 @@ public class ContributionsFragment getChildFragmentManager().executePendingTransactions(); } - - public Intent getUploadServiceIntent(){ - Intent intent = new Intent(getActivity(), UploadService.class); - intent.setAction(UploadService.ACTION_START_SERVICE); - return intent; - } - @SuppressWarnings("ConstantConditions") private void setUploadCount() { compositeDisposable.add(okHttpJsonApiClient @@ -524,14 +461,6 @@ public class ContributionsFragment locationManager.unregisterLocationManager(); locationManager.removeLocationListener(this); super.onDestroy(); - - if (isUploadServiceConnected) { - if (getActivity() != null) { - uploadService.setServiceCallback(null); - getActivity().unbindService(uploadServiceConnection); - isUploadServiceConnected = false; - } - } } catch (IllegalArgumentException | IllegalStateException exception) { Timber.e(exception); } @@ -594,7 +523,6 @@ public class ContributionsFragment @Override public void onDestroyView() { super.onDestroyView(); - isUploadServiceConnected = false; presenter.onDetachView(); } @@ -606,8 +534,9 @@ public class ContributionsFragment @Override public void retryUpload(Contribution contribution) { if (NetworkUtils.isInternetConnectionEstablished(getContext())) { - if (contribution.getState() == STATE_FAILED || contribution.getState() == STATE_PAUSED || contribution.getState()==Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE && null != uploadService) { - uploadService.queue(contribution); + if (contribution.getState() == STATE_FAILED || contribution.getState() == STATE_PAUSED || contribution.getState()==Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE) { + contribution.setState(Contribution.STATE_QUEUED); + contributionsPresenter.saveContribution(contribution); Timber.d("Restarting for %s", contribution.toString()); } else { Timber.d("Skipping re-upload for non-failed %s", contribution.toString()); @@ -624,7 +553,11 @@ public class ContributionsFragment */ @Override public void pauseUpload(Contribution contribution) { - uploadService.pauseUpload(contribution); + //Pause the upload in the global singleton + CommonsApplication.pauseUploads.put(contribution.getPageId(), true); + //Retain the paused state in DB + contribution.setState(STATE_PAUSED); + contributionsPresenter.saveContribution(contribution); } /** @@ -677,10 +610,5 @@ public class ContributionsFragment public MediaDetailPagerFragment getMediaDetailPagerFragment() { return mediaDetailPagerFragment; } - - @Override - public void updateUploadCount() { - setUploadCount(); - } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java index 93499e363..dcfca2519 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java @@ -3,7 +3,6 @@ package fr.free.nrw.commons.contributions; import androidx.paging.DataSource.Factory; import io.reactivex.Completable; import java.util.ArrayList; -import java.util.Date; import java.util.List; import javax.inject.Inject; @@ -22,8 +21,8 @@ class ContributionsLocalDataSource { @Inject public ContributionsLocalDataSource( - @Named("default_preferences") JsonKvStore defaultKVStore, - ContributionDao contributionDao) { + @Named("default_preferences") final JsonKvStore defaultKVStore, + final ContributionDao contributionDao) { this.defaultKVStore = defaultKVStore; this.contributionDao = contributionDao; } @@ -31,14 +30,14 @@ class ContributionsLocalDataSource { /** * Fetch default number of contributions to be show, based on user preferences */ - public String getString(String key) { + public String getString(final String key) { return defaultKVStore.getString(key); } /** * Fetch default number of contributions to be show, based on user preferences */ - public long getLong(String key) { + public long getLong(final String key) { return defaultKVStore.getLong(key); } @@ -47,8 +46,8 @@ class ContributionsLocalDataSource { * @param uri * @return */ - public Contribution getContributionWithFileName(String uri) { - List contributionWithUri = contributionDao.getContributionWithTitle(uri); + public Contribution getContributionWithFileName(final String uri) { + final List contributionWithUri = contributionDao.getContributionWithTitle(uri); if(!contributionWithUri.isEmpty()){ return contributionWithUri.get(0); } @@ -60,7 +59,7 @@ class ContributionsLocalDataSource { * @param contribution * @return */ - public Completable deleteContribution(Contribution contribution) { + public Completable deleteContribution(final Contribution contribution) { return contributionDao.delete(contribution); } @@ -68,10 +67,10 @@ class ContributionsLocalDataSource { return contributionDao.fetchContributions(); } - public Single> saveContributions(List contributions) { - List contributionList = new ArrayList<>(); - for(Contribution contribution: contributions) { - Contribution oldContribution = contributionDao.getContribution(contribution.getPageId()); + public Single> saveContributions(final List contributions) { + final List contributionList = new ArrayList<>(); + for(final Contribution contribution: contributions) { + final Contribution oldContribution = contributionDao.getContribution(contribution.getPageId()); if(oldContribution != null) { contribution.setWikidataPlace(oldContribution.getWikidataPlace()); } @@ -80,11 +79,15 @@ class ContributionsLocalDataSource { return contributionDao.save(contributionList); } - public void set(String key, long value) { + public Completable saveContributions(Contribution contribution) { + return contributionDao.save(contribution); + } + + public void set(final String key, final long value) { defaultKVStore.putLong(key,value); } - public Completable updateContribution(Contribution contribution) { + public Completable updateContribution(final Contribution contribution) { return contributionDao.update(contribution); } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java index 2cae4f04c..002e8bc95 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java @@ -1,10 +1,18 @@ package fr.free.nrw.commons.contributions; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; import fr.free.nrw.commons.MediaDataExtractor; import fr.free.nrw.commons.contributions.ContributionsContract.UserActionListener; import fr.free.nrw.commons.di.CommonsApplicationModule; +import fr.free.nrw.commons.upload.worker.UploadWorker; import io.reactivex.Scheduler; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.functions.Action; +import io.reactivex.functions.Consumer; +import java.util.Collections; +import java.util.List; import javax.inject.Inject; import javax.inject.Named; @@ -14,7 +22,6 @@ import javax.inject.Named; public class ContributionsPresenter implements UserActionListener { private final ContributionsRepository repository; - private final Scheduler mainThreadScheduler; private final Scheduler ioThreadScheduler; private CompositeDisposable compositeDisposable; private ContributionsContract.View view; @@ -23,9 +30,9 @@ public class ContributionsPresenter implements UserActionListener { MediaDataExtractor mediaDataExtractor; @Inject - ContributionsPresenter(ContributionsRepository repository, @Named(CommonsApplicationModule.MAIN_THREAD) Scheduler mainThreadScheduler,@Named(CommonsApplicationModule.IO_THREAD) Scheduler ioThreadScheduler) { + ContributionsPresenter(ContributionsRepository repository, + @Named(CommonsApplicationModule.IO_THREAD) Scheduler ioThreadScheduler) { this.repository = repository; - this.mainThreadScheduler=mainThreadScheduler; this.ioThreadScheduler=ioThreadScheduler; } @@ -57,4 +64,23 @@ public class ContributionsPresenter implements UserActionListener { .subscribeOn(ioThreadScheduler) .subscribe()); } + + /** + * Update the contribution's state in the databse, upon completion, trigger the workmanager to + * process this contribution + * + * @param contribution + */ + @Override + public void saveContribution(Contribution contribution) { + compositeDisposable.add(repository + .save(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe(() -> { + WorkManager.getInstance(view.getContext().getApplicationContext()) + .enqueueUniqueWork( + UploadWorker.class.getSimpleName(), + ExistingWorkPolicy.KEEP, OneTimeWorkRequest.from(UploadWorker.class)); + })); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java index 17b004802..8054cfb4a 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java @@ -53,6 +53,10 @@ public class ContributionsRepository { return localDataSource.saveContributions(contributions); } + public Completable save(Contribution contributions){ + return localDataSource.saveContributions(contributions); + } + public void set(String key, long value) { localDataSource.set(key,value); } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java index c9221b8f0..51b1042a1 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java @@ -13,13 +13,15 @@ import androidx.annotation.Nullable; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; import butterknife.BindView; import butterknife.ButterKnife; import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.R; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.bookmarks.BookmarkFragment; -import fr.free.nrw.commons.category.CategoryImagesCallback; import fr.free.nrw.commons.explore.ExploreFragment; import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.location.LocationServiceManager; @@ -29,7 +31,6 @@ import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment; import fr.free.nrw.commons.navtab.NavTab; import fr.free.nrw.commons.navtab.NavTabLayout; import fr.free.nrw.commons.navtab.NavTabLoggedOut; -import fr.free.nrw.commons.nearby.NearbyNotificationCardView; import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment; import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.NearbyParentFragmentInstanceReadyCallback; @@ -37,7 +38,7 @@ import fr.free.nrw.commons.notification.NotificationActivity; import fr.free.nrw.commons.notification.NotificationController; import fr.free.nrw.commons.quiz.QuizChecker; import fr.free.nrw.commons.theme.BaseActivity; -import fr.free.nrw.commons.upload.UploadService; +import fr.free.nrw.commons.upload.worker.UploadWorker; import fr.free.nrw.commons.utils.ViewUtilWrapper; import javax.inject.Inject; import javax.inject.Named; @@ -122,7 +123,6 @@ public class MainActivity extends BaseActivity loadFragment(ContributionsFragment.newInstance(),false); } setUpPager(); - initMain(); } } @@ -257,13 +257,6 @@ public class MainActivity extends BaseActivity } } - private void initMain() { - //Do not remove this, this triggers the sync service - Intent uploadServiceIntent = new Intent(this, UploadService.class); - uploadServiceIntent.setAction(UploadService.ACTION_START_SERVICE); - startService(uploadServiceIntent); - } - @Override public void onBackPressed() { if (contributionsFragment != null && activeFragment == ActiveFragment.CONTRIBUTIONS) { @@ -323,13 +316,10 @@ public class MainActivity extends BaseActivity viewUtilWrapper .showShortToast(getBaseContext(), getString(R.string.limited_connection_enabled)); } else { - Intent intent = new Intent(this, UploadService.class); - intent.setAction(UploadService.PROCESS_PENDING_LIMITED_CONNECTION_MODE_UPLOADS); - if (VERSION.SDK_INT >= VERSION_CODES.O) { - startForegroundService(intent); - } else { - startService(intent); - } + WorkManager.getInstance(getApplicationContext()).enqueueUniqueWork( + UploadWorker.class.getSimpleName(), + ExistingWorkPolicy.KEEP, OneTimeWorkRequest.from(UploadWorker.class)); + viewUtilWrapper .showShortToast(getBaseContext(), getString(R.string.limited_connection_disabled)); } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt index 21f2c26d4..67f4024e0 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt @@ -39,7 +39,7 @@ class WikipediaInstructionsDialogFragment : DialogFragment() { callback?.onConfirmClicked(contribution, checkbox_copy_wikicode.isChecked) } - dialog!!.window.setSoftInputMode( + dialog!!.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN ) } diff --git a/app/src/main/java/fr/free/nrw/commons/di/ApplicationlessInjection.java b/app/src/main/java/fr/free/nrw/commons/di/ApplicationlessInjection.java index b4f4e5db8..f2bff5db7 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/ApplicationlessInjection.java +++ b/app/src/main/java/fr/free/nrw/commons/di/ApplicationlessInjection.java @@ -8,6 +8,7 @@ import android.content.Context; import androidx.fragment.app.Fragment; +import dagger.android.HasAndroidInjector; import javax.inject.Inject; import dagger.android.AndroidInjector; @@ -25,6 +26,7 @@ import dagger.android.support.HasSupportFragmentInjector; */ public class ApplicationlessInjection implements + HasAndroidInjector, HasActivityInjector, HasFragmentInjector, HasSupportFragmentInjector, @@ -34,6 +36,7 @@ public class ApplicationlessInjection private static ApplicationlessInjection instance = null; + @Inject DispatchingAndroidInjector androidInjector; @Inject DispatchingAndroidInjector activityInjector; @Inject DispatchingAndroidInjector broadcastReceiverInjector; @Inject DispatchingAndroidInjector fragmentInjector; @@ -49,6 +52,11 @@ public class ApplicationlessInjection commonsApplicationComponent.inject(this); } + @Override + public AndroidInjector androidInjector() { + return androidInjector; + } + @Override public DispatchingAndroidInjector activityInjector() { return activityInjector; @@ -94,5 +102,4 @@ public class ApplicationlessInjection return instance; } - } diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java index 888967b48..33896f768 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java @@ -6,6 +6,7 @@ import fr.free.nrw.commons.explore.categories.CategoriesModule; import fr.free.nrw.commons.navtab.MoreBottomSheetFragment; import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment; import fr.free.nrw.commons.navtab.NavTabLayout; +import fr.free.nrw.commons.upload.worker.UploadWorker; import javax.inject.Singleton; import dagger.Component; @@ -47,6 +48,8 @@ import fr.free.nrw.commons.widget.PicOfDayAppWidget; public interface CommonsApplicationComponent extends AndroidInjector { void inject(CommonsApplication application); + void inject(UploadWorker worker); + void inject(LoginActivity activity); void inject(SettingsFragment fragment); diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java index 753836a6c..28d1292ba 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java @@ -165,7 +165,7 @@ public class CommonsApplicationModule { @Provides public UploadController providesUploadController(SessionManager sessionManager, @Named("default_preferences") JsonKvStore kvStore, - Context context) { + Context context, ContributionDao contributionDao) { return new UploadController(sessionManager, context, kvStore); } diff --git a/app/src/main/java/fr/free/nrw/commons/di/ServiceBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/ServiceBuilderModule.java index 89e65e1d8..1fb52c937 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/ServiceBuilderModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/ServiceBuilderModule.java @@ -3,7 +3,6 @@ package fr.free.nrw.commons.di; import dagger.Module; import dagger.android.ContributesAndroidInjector; import fr.free.nrw.commons.auth.WikiAccountAuthenticatorService; -import fr.free.nrw.commons.upload.UploadService; /** * This Class Represents the Module for dependency injection (using dagger) @@ -14,9 +13,6 @@ import fr.free.nrw.commons.upload.UploadService; @SuppressWarnings({"WeakerAccess", "unused"}) public abstract class ServiceBuilderModule { - @ContributesAndroidInjector - abstract UploadService bindUploadService(); - @ContributesAndroidInjector abstract WikiAccountAuthenticatorService bindWikiAccountAuthenticatorService(); diff --git a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java index 4c52cddfe..a470d163f 100644 --- a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java +++ b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java @@ -3,6 +3,7 @@ package fr.free.nrw.commons.repository; import fr.free.nrw.commons.category.CategoriesModel; import fr.free.nrw.commons.category.CategoryItem; import fr.free.nrw.commons.contributions.Contribution; +import fr.free.nrw.commons.contributions.ContributionDao; import fr.free.nrw.commons.filepicker.UploadableFile; import fr.free.nrw.commons.location.LatLng; import fr.free.nrw.commons.nearby.NearbyPlaces; @@ -36,18 +37,21 @@ public class UploadRepository { private final DepictModel depictModel; private static final double NEARBY_RADIUS_IN_KILO_METERS = 0.1; //100 meters + private final ContributionDao contributionDao; @Inject public UploadRepository(UploadModel uploadModel, UploadController uploadController, CategoriesModel categoriesModel, NearbyPlaces nearbyPlaces, - DepictModel depictModel) { + DepictModel depictModel, + ContributionDao contributionDao) { this.uploadModel = uploadModel; this.uploadController = uploadController; this.categoriesModel = categoriesModel; this.nearbyPlaces = nearbyPlaces; this.depictModel = depictModel; + this.contributionDao=contributionDao; } /** @@ -64,8 +68,14 @@ public class UploadRepository { * * @param contribution */ - public void startUpload(Contribution contribution) { - uploadController.startUpload(contribution); + + public void prepareMedia(Contribution contribution) { + uploadController.prepareMedia(contribution); + } + + + public void saveContribution(Contribution contribution) { + contributionDao.save(contribution).blockingAwait(); } /** @@ -77,13 +87,6 @@ public class UploadRepository { return uploadModel.getUploads(); } - /** - * asks the RemoteDataSource to prepare the Upload Service - */ - public void prepareService() { - uploadController.prepareService(); - } - /** *Prepare for a fresh upload */ diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java index 6ff297267..fc5f2307d 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java @@ -1,7 +1,6 @@ package fr.free.nrw.commons.upload; import static fr.free.nrw.commons.contributions.ContributionController.ACTION_INTERNAL_UPLOADS; -import static fr.free.nrw.commons.upload.UploadService.EXTRA_FILES; import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; import android.Manifest; @@ -24,6 +23,9 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; @@ -42,6 +44,7 @@ import fr.free.nrw.commons.upload.depicts.DepictsFragment; import fr.free.nrw.commons.upload.license.MediaLicenseFragment; import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment; import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.UploadMediaDetailFragmentCallback; +import fr.free.nrw.commons.upload.worker.UploadWorker; import fr.free.nrw.commons.utils.PermissionUtils; import fr.free.nrw.commons.utils.ViewUtil; import io.reactivex.android.schedulers.AndroidSchedulers; @@ -55,6 +58,7 @@ import javax.inject.Named; import timber.log.Timber; public class UploadActivity extends BaseActivity implements UploadContract.View, UploadBaseFragment.Callback { + @Inject ContributionController contributionController; @Inject @@ -104,6 +108,8 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, private List uploadableFiles = Collections.emptyList(); private int currentSelectedPosition = 0; + public static final String EXTRA_FILES = "commons_image_exta"; + @SuppressLint("CheckResult") @Override protected void onCreate(Bundle savedInstanceState) { @@ -279,6 +285,13 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, .getQuantityString(R.plurals.upload_count_title, uploadableFiles.size(), uploadableFiles.size())); } + @Override + public void makeUploadRequest() { + WorkManager.getInstance(getApplicationContext()).enqueueUniqueWork( + UploadWorker.class.getSimpleName(), + ExistingWorkPolicy.KEEP, OneTimeWorkRequest.from(UploadWorker.class)); + } + @Override public void askUserToLogIn() { Timber.d("current session is null, asking user to login"); diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java index 110dd89c4..2212e2b5b 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java @@ -9,12 +9,11 @@ import com.google.gson.Gson; import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.contributions.ChunkInfo; import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.upload.UploadService.NotificationUpdateProgressListener; +import fr.free.nrw.commons.upload.worker.UploadWorker.NotificationUpdateProgressListener; import io.reactivex.Observable; import io.reactivex.disposables.CompositeDisposable; import java.io.File; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.Date; import java.util.HashMap; @@ -48,8 +47,6 @@ public class UploadClient { private final FileUtilsWrapper fileUtilsWrapper; private final Gson gson; - private Map pauseUploads; - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); @Inject @@ -62,14 +59,13 @@ public class UploadClient { this.pageContentsCreator = pageContentsCreator; this.fileUtilsWrapper = fileUtilsWrapper; this.gson = gson; - this.pauseUploads = new HashMap<>(); } /** * Upload file to stash in chunks of specified size. Uploading files in chunks will make handling * of large files easier. Also, it will be useful in supporting pause/resume of uploads */ - Observable uploadFileToStash( + public Observable uploadFileToStash( final Context context, final String filename, final Contribution contribution, final NotificationUpdateProgressListener notificationUpdater) throws IOException { if (contribution.getChunkInfo() != null @@ -79,7 +75,7 @@ public class UploadClient { contribution.getChunkInfo().getUploadResult().getFilekey())); } - pauseUploads.put(contribution.getPageId(), false); + CommonsApplication.pauseUploads.put(contribution.getPageId(), false); final File file = new File(contribution.getLocalUri().getPath()); final List fileChunks = fileUtilsWrapper.getFileChunks(context, file, CHUNK_SIZE); @@ -102,7 +98,7 @@ public class UploadClient { final AtomicBoolean failures = new AtomicBoolean(); compositeDisposable.add(Observable.fromIterable(fileChunks).forEach(chunkFile -> { - if (pauseUploads.get(contribution.getPageId()) || failures.get()) { + if (CommonsApplication.pauseUploads.get(contribution.getPageId()) || failures.get()) { return; } @@ -141,7 +137,7 @@ public class UploadClient { })); })); - if (pauseUploads.get(contribution.getPageId())) { + if (CommonsApplication.pauseUploads.get(contribution.getPageId())) { Timber.d("Upload stash paused %s", contribution.getPageId()); return Observable.just(new StashUploadResult(StashUploadState.PAUSED, null)); } else if (failures.get()) { @@ -201,18 +197,6 @@ public class UploadClient { } } - /** - * Dispose the active disposable and sets the pause variable - * @param pageId - */ - public void pauseUpload(String pageId) { - pauseUploads.put(pageId, true); - if (!compositeDisposable.isDisposed()) { - compositeDisposable.dispose(); - } - compositeDisposable.clear(); - } - /** * Converts string value to request body */ @@ -222,7 +206,7 @@ public class UploadClient { } - Observable uploadFileFromStash(final Context context, + public Observable uploadFileFromStash( final Contribution contribution, final String uniqueFileName, final String fileKey) { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.java index 77e2dfa54..109b34d38 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.java @@ -29,6 +29,8 @@ public interface UploadContract { void onUploadMediaDeleted(int index); void updateTopCardTitle(); + + void makeUploadRequest(); } public interface UserActionListener extends BasePresenter { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java index 79afe90dc..569985e3a 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java @@ -13,12 +13,16 @@ import android.net.Uri; import android.os.IBinder; import android.provider.MediaStore; import android.text.TextUtils; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.contributions.Contribution; +import fr.free.nrw.commons.contributions.ContributionDao; import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.settings.Prefs; +import fr.free.nrw.commons.upload.worker.UploadWorker; import fr.free.nrw.commons.utils.ViewUtil; import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; @@ -35,62 +39,27 @@ import timber.log.Timber; @Singleton public class UploadController { - private UploadService uploadService; + private final SessionManager sessionManager; private final Context context; private final JsonKvStore store; @Inject public UploadController(final SessionManager sessionManager, - final Context context, - final JsonKvStore store) { + final Context context, + final JsonKvStore store) { this.sessionManager = sessionManager; this.context = context; this.store = store; } - private boolean isUploadServiceConnected; - public ServiceConnection uploadServiceConnection = new ServiceConnection() { - @Override - public void onServiceConnected(final ComponentName componentName, final IBinder binder) { - uploadService = ((UploadService.UploadServiceLocalBinder) binder).getService(); - isUploadServiceConnected = true; - } - - @Override - public void onServiceDisconnected(final ComponentName componentName) { - // this should never happen - isUploadServiceConnected = false; - Timber.e(new RuntimeException("UploadService died but the rest of the process did not!")); - } - }; - - /** - * Prepares the upload service. - */ - public void prepareService() { - final Intent uploadServiceIntent = new Intent(context, UploadService.class); - uploadServiceIntent.setAction(UploadService.ACTION_START_SERVICE); - context.startService(uploadServiceIntent); - context.bindService(uploadServiceIntent, uploadServiceConnection, Context.BIND_AUTO_CREATE); - } - - /** - * Disconnects the upload service. - */ - public void cleanup() { - if (isUploadServiceConnected) { - context.unbindService(uploadServiceConnection); - } - } - /** * Starts a new upload task. * * @param contribution the contribution object */ @SuppressLint("StaticFieldLeak") - public void startUpload(final Contribution contribution) { + public void prepareMedia(final Contribution contribution) { //Set creator, desc, and license // If author name is enabled and set, use it @@ -118,20 +87,7 @@ public class UploadController { final String license = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3); media.setLicense(license); - uploadTask(contribution); - } - - /** - * Initiates the upload task - * @param contribution - * @return - */ - private Disposable uploadTask(final Contribution contribution) { - return Single.just(contribution) - .map(this::buildUpload) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::upload); + buildUpload(contribution); } /** @@ -139,7 +95,7 @@ public class UploadController { * @param contribution * @return */ - private Contribution buildUpload(final Contribution contribution) { + private void buildUpload(final Contribution contribution) { final ContentResolver contentResolver = context.getContentResolver(); contribution.setDataLength(resolveDataLength(contentResolver, contribution)); @@ -153,8 +109,6 @@ public class UploadController { contribution.setDateCreated(resolveDateTakenOrNow(contentResolver, contribution)); } } - - return contribution; } private String resolveMimeType(final ContentResolver contentResolver, final Contribution contribution) { @@ -202,15 +156,6 @@ public class UploadController { new String[]{MediaStore.Images.ImageColumns.DATE_TAKEN}, null, null, null); } - /** - * When the contribution object is completely formed, the item is queued to the upload service - * @param contribution - */ - private void upload(final Contribution contribution) { - //Starts the upload. If commented out, user can proceed to next Fragment but upload doesn't happen - uploadService.queue(contribution); - } - /** * Counts the number of bytes in {@code stream}. diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java index ea3600e28..f0a631b15 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java @@ -69,7 +69,9 @@ public class UploadPresenter implements UploadContract.UserActionListener { @Override public void onNext(Contribution contribution) { - repository.startUpload(contribution); + repository.prepareMedia(contribution); + contribution.setState(Contribution.STATE_QUEUED); + repository.saveContribution(contribution); } @Override @@ -83,6 +85,7 @@ public class UploadPresenter implements UploadContract.UserActionListener { @Override public void onComplete() { + view.makeUploadRequest(); repository.cleanup(); view.finish(); compositeDisposable.clear(); @@ -119,7 +122,6 @@ public class UploadPresenter implements UploadContract.UserActionListener { @Override public void onAttachView(UploadContract.View view) { this.view = view; - repository.prepareService(); } @Override diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.kt index 0a45999d7..3ee2e0407 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.kt @@ -14,10 +14,10 @@ data class UploadResult( val filename: String ) : Parcelable { constructor(parcel: Parcel) : this( - parcel.readString(), - parcel.readString(), - parcel.readInt(), - parcel.readString() + parcel.readString()?:"", + parcel.readString()?:"", + parcel.readInt()?:0, + parcel.readString()?:"" ) { } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java index b65fd9bba..e69de29bb 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java @@ -1,488 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.annotation.SuppressLint; -import android.app.PendingIntent; -import android.content.ContentResolver; -import android.content.Intent; -import android.graphics.BitmapFactory; -import android.os.Binder; -import android.os.Bundle; -import android.os.IBinder; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.contributions.ChunkInfo; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.contributions.ContributionDao; -import fr.free.nrw.commons.contributions.MainActivity; -import fr.free.nrw.commons.di.CommonsApplicationModule; -import fr.free.nrw.commons.di.CommonsDaggerService; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.media.MediaClient; -import fr.free.nrw.commons.utils.ViewUtil; -import fr.free.nrw.commons.wikidata.WikidataEditService; -import io.reactivex.Completable; -import io.reactivex.Observable; -import io.reactivex.ObservableSource; -import io.reactivex.Scheduler; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.functions.Consumer; -import io.reactivex.functions.Function; -import io.reactivex.processors.PublishProcessor; -import io.reactivex.schedulers.Schedulers; -import java.io.IOException; -import java.util.Arrays; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.inject.Inject; -import javax.inject.Named; -import timber.log.Timber; - -public class UploadService extends CommonsDaggerService { - - private static final String EXTRA_PREFIX = "fr.free.nrw.commons.upload"; - - private static final List STASH_ERROR_CODES = Arrays - .asList("uploadstash-file-not-found", "stashfailed", "verification-error", "chunk-too-small"); - - public static final String ACTION_START_SERVICE = EXTRA_PREFIX + ".upload"; - public static final String PROCESS_PENDING_LIMITED_CONNECTION_MODE_UPLOADS = EXTRA_PREFIX + "process_limited_connection_mode_uploads"; - public static final String EXTRA_FILES = EXTRA_PREFIX + ".files"; - @Inject - WikidataEditService wikidataEditService; - @Inject - SessionManager sessionManager; - @Inject - ContributionDao contributionDao; - @Inject - UploadClient uploadClient; - @Inject - MediaClient mediaClient; - @Inject - @Named(CommonsApplicationModule.MAIN_THREAD) - Scheduler mainThreadScheduler; - @Inject - @Named(CommonsApplicationModule.IO_THREAD) - Scheduler ioThreadScheduler; - @Inject - @Named("default_preferences") - public JsonKvStore defaultKvStore; - - private NotificationManagerCompat notificationManager; - private NotificationCompat.Builder curNotification; - private int toUpload; - private CompositeDisposable compositeDisposable; - private ServiceCallback serviceCallback; - - /** - * The filePath names of unfinished uploads, used to prevent overwriting - */ - private Set unfinishedUploads = new HashSet<>(); - - // DO NOT HAVE NOTIFICATION ID OF 0 FOR ANYTHING - // See http://stackoverflow.com/questions/8725909/startforeground-does-not-show-my-notification - // Seriously, Android? - public static final int NOTIFICATION_UPLOAD_IN_PROGRESS = 1; - public static final int NOTIFICATION_UPLOAD_FAILED = 3; - public static final int NOTIFICATION_UPLOAD_PAUSED = 4; - - protected class NotificationUpdateProgressListener { - - String notificationTag; - boolean notificationTitleChanged; - Contribution contribution; - - String notificationProgressTitle; - String notificationFinishingTitle; - - NotificationUpdateProgressListener(String notificationTag, String notificationProgressTitle, - String notificationFinishingTitle, Contribution contribution) { - this.notificationTag = notificationTag; - this.notificationProgressTitle = notificationProgressTitle; - this.notificationFinishingTitle = notificationFinishingTitle; - this.contribution = contribution; - } - - public void onProgress(long transferred, long total) { - if (!notificationTitleChanged) { - curNotification.setContentTitle(notificationProgressTitle); - notificationTitleChanged = true; - contribution.setState(Contribution.STATE_IN_PROGRESS); - } - if (transferred == total) { - // Completed! - curNotification.setContentTitle(notificationFinishingTitle) - .setTicker(notificationFinishingTitle) - .setProgress(0, 100, true); - } else { - curNotification - .setProgress(100, (int) (((double) transferred / (double) total) * 100), false); - } - notificationManager - .notify(notificationTag, NOTIFICATION_UPLOAD_IN_PROGRESS, curNotification.build()); - - contribution.setTransferred(transferred); - - compositeDisposable.add(contributionDao.update(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - - public void onChunkUploaded(Contribution contribution, ChunkInfo chunkInfo) { - contribution.setChunkInfo(chunkInfo); - compositeDisposable.add(contributionDao.update(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - } - - /** - * Sets contribution state to paused and disposes the active disposable - * @param contribution - */ - public void pauseUpload(Contribution contribution) { - uploadClient.pauseUpload(contribution.getPageId()); - contribution.setState(Contribution.STATE_PAUSED); - compositeDisposable.add(contributionDao.update(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - - @Override - public void onDestroy() { - super.onDestroy(); - compositeDisposable.dispose(); - Timber.d("UploadService.onDestroy; %s are yet to be uploaded", unfinishedUploads); - } - - public class UploadServiceLocalBinder extends Binder { - - public UploadService getService() { - return UploadService.this; - } - } - - private final IBinder localBinder = new UploadServiceLocalBinder(); - - private PublishProcessor contributionsToUpload; - - @Override - public IBinder onBind(Intent intent) { - return localBinder; - } - - @Override - public void onCreate() { - super.onCreate(); - CommonsApplication.createNotificationChannel(getApplicationContext()); - compositeDisposable = new CompositeDisposable(); - notificationManager = NotificationManagerCompat.from(this); - curNotification = getNotificationBuilder(CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL); - contributionsToUpload = PublishProcessor.create(); - compositeDisposable.add(contributionsToUpload.subscribe(this::handleUpload)); - } - - public void handleUpload(Contribution contribution) { - contribution.setState(Contribution.STATE_QUEUED); - contribution.setTransferred(0); - toUpload++; - if (curNotification != null && toUpload != 1) { - curNotification.setContentText(getResources() - .getQuantityString(R.plurals.uploads_pending_notification_indicator, toUpload, toUpload)); - Timber.d("%d uploads left", toUpload); - notificationManager - .notify(contribution.getLocalUri().toString(), NOTIFICATION_UPLOAD_IN_PROGRESS, - curNotification.build()); - } - - compositeDisposable.add(contributionDao - .save(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe(() -> uploadContribution(contribution))); - } - - private boolean freshStart = true; - - public void queue(Contribution contribution) { - if (defaultKvStore - .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false)) { - contribution.setState(Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE); - contributionDao.save(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe(); - return; - } - contributionsToUpload.offer(contribution); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - showUploadNotification(); - if (ACTION_START_SERVICE.equals(intent.getAction()) && freshStart) { - compositeDisposable.add(contributionDao.updateStates(Contribution.STATE_FAILED, - new int[]{Contribution.STATE_QUEUED, Contribution.STATE_IN_PROGRESS}) - .observeOn(mainThreadScheduler) - .subscribeOn(ioThreadScheduler) - .subscribe()); - freshStart = false; - } else if (PROCESS_PENDING_LIMITED_CONNECTION_MODE_UPLOADS.equals(intent.getAction())) { - contributionDao.getContribution(Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE) - .flatMapObservable( - (Function, ObservableSource>) contributions -> Observable - .fromIterable(contributions)) - .concatMapCompletable(contribution -> Completable.fromAction(() -> queue(contribution))) - .subscribeOn(ioThreadScheduler) - .subscribe(); - } - return START_REDELIVER_INTENT; - } - - private void showUploadNotification() { - compositeDisposable.add(contributionDao - .getPendingUploads(new int[]{Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED}) - .subscribe(count -> { - if (count > 0) { - startForeground(NOTIFICATION_UPLOAD_IN_PROGRESS, - curNotification.setContentText(getText(R.string.starting_uploads)).build()); - } - })); - } - - @SuppressLint("StringFormatInvalid") - private NotificationCompat.Builder getNotificationBuilder(String channelId) { - return new NotificationCompat.Builder(this, channelId) - .setAutoCancel(true) - .setSmallIcon(R.drawable.ic_launcher) - .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher)) - .setAutoCancel(true) - .setOnlyAlertOnce(true) - .setProgress(100, 0, true) - .setOngoing(true) - .setContentIntent( - PendingIntent.getActivity(this, 0, new Intent(this, MainActivity.class), 0)); - } - - @SuppressLint("CheckResult") - private void uploadContribution(Contribution contribution) { - if (contribution.getLocalUri() == null || contribution.getLocalUri().getPath() == null) { - Timber.d("localUri/path is null"); - return; - } - String notificationTag = contribution.getLocalUri().toString(); - - Timber.d("Before execution!"); - final Media media = contribution.getMedia(); - final String displayTitle = media.getDisplayTitle(); - curNotification.setContentTitle(getString(R.string.upload_progress_notification_title_start, - displayTitle)) - .setContentText(getResources() - .getQuantityString(R.plurals.uploads_pending_notification_indicator, toUpload, - toUpload)) - .setTicker(getString(R.string.upload_progress_notification_title_in_progress, - displayTitle)) - .setOngoing(true); - notificationManager - .notify(notificationTag, NOTIFICATION_UPLOAD_IN_PROGRESS, curNotification.build()); - - String filename = media.getFilename(); - - NotificationUpdateProgressListener notificationUpdater = new NotificationUpdateProgressListener( - notificationTag, - getString(R.string.upload_progress_notification_title_in_progress, - displayTitle), - getString(R.string.upload_progress_notification_title_finishing, - displayTitle), - contribution - ); - - Observable.fromCallable(() -> "Temp_" + contribution.hashCode() + filename) - .flatMap(stashFilename -> uploadClient - .uploadFileToStash(getApplicationContext(), stashFilename, contribution, - notificationUpdater)) - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.io()) - .doFinally(() -> { - if (filename != null) { - unfinishedUploads.remove(filename); - } - toUpload--; - if (toUpload == 0) { - // Sync modifications right after all uploads are processed - ContentResolver - .requestSync(sessionManager.getCurrentAccount(), BuildConfig.MODIFICATION_AUTHORITY, - new Bundle()); - stopForeground(true); - } - }) - .flatMap(uploadStash -> { - Timber.d("Upload stash result %s", uploadStash.toString()); - notificationManager.cancel(notificationTag, NOTIFICATION_UPLOAD_IN_PROGRESS); - - if (uploadStash.getState() == StashUploadState.SUCCESS) { - Timber.d("making sure of uniqueness of name: %s", filename); - String uniqueFilename = findUniqueFilename(filename); - unfinishedUploads.add(uniqueFilename); - return uploadClient.uploadFileFromStash( - getApplicationContext(), - contribution, - uniqueFilename, - uploadStash.getFileKey()).doOnError(new Consumer() { - @Override - public void accept(Throwable throwable) throws Exception { - Timber.e(throwable, "Error occurred in uploading file from stash"); - if (STASH_ERROR_CODES.contains(throwable.getMessage())) { - clearChunks(contribution); - } - } - }); - } else if (uploadStash.getState() == StashUploadState.PAUSED) { - showPausedNotification(contribution); - return Observable.never(); - } else { - Timber.d("Contribution upload failed. Wikidata entity won't be edited"); - showFailedNotification(contribution); - return Observable.never(); - } - }) - .subscribe( - uploadResult -> onUpload(contribution, notificationTag, uploadResult), - throwable -> { - Timber.w(throwable, "Exception during upload"); - notificationManager.cancel(notificationTag, NOTIFICATION_UPLOAD_IN_PROGRESS); - showFailedNotification(contribution); - }); - } - - private void clearChunks(Contribution contribution) { - contribution.setChunkInfo(null); - compositeDisposable.add(contributionDao.update(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - - private void onUpload(Contribution contribution, String notificationTag, - UploadResult uploadResult) { - - notificationManager.cancel(notificationTag, NOTIFICATION_UPLOAD_IN_PROGRESS); - - if (uploadResult.isSuccessful()) { - onSuccessfulUpload(contribution, uploadResult); - } else { - Timber.d("Contribution upload failed. Wikidata entity won't be edited"); - showFailedNotification(contribution); - } - } - - private void onSuccessfulUpload(Contribution contribution, UploadResult uploadResult) { - compositeDisposable - .add(wikidataEditService.addDepictionsAndCaptions(uploadResult, contribution)); - WikidataPlace wikidataPlace = contribution.getWikidataPlace(); - if (wikidataPlace != null && wikidataPlace.getImageValue() == null) { - if (!contribution.hasInvalidLocation()) { - wikidataEditService.createClaim(wikidataPlace, uploadResult.getFilename(), - contribution.getMedia().getCaptions()); - } else { - ViewUtil.showShortToast(this, getString(R.string.wikidata_edit_failure)); - Timber - .d("Image location and nearby place location mismatched, so Wikidata item won't be edited"); - } - } - saveCompletedContribution(contribution, uploadResult); - if(serviceCallback!=null) { - //this function update the tatol number media Uploaded or contributions - serviceCallback.updateUploadCount(); - } - } - - private void saveCompletedContribution(Contribution contribution, UploadResult uploadResult) { - compositeDisposable.add(mediaClient.getMedia("File:" + uploadResult.getFilename()) - .map(contribution::completeWith) - .flatMapCompletable( - newContribution -> { - newContribution.setDateModified(new Date()); - return contributionDao.saveAndDelete(contribution, newContribution); - }) - .subscribe()); - } - - @SuppressLint("StringFormatInvalid") - @SuppressWarnings("deprecation") - private void showFailedNotification(final Contribution contribution) { - final String displayTitle = contribution.getMedia().getDisplayTitle(); - curNotification.setTicker(getString(R.string.upload_failed_notification_title, displayTitle)) - .setContentTitle(getString(R.string.upload_failed_notification_title, displayTitle)) - .setContentText(getString(R.string.upload_failed_notification_subtitle)) - .setProgress(0, 0, false) - .setOngoing(false); - notificationManager.notify(contribution.getLocalUri().toString(), NOTIFICATION_UPLOAD_FAILED, - curNotification.build()); - - contribution.setState(Contribution.STATE_FAILED); - contribution.setChunkInfo(null); - - compositeDisposable.add(contributionDao - .update(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - - private void showPausedNotification(final Contribution contribution) { - final String displayTitle = contribution.getMedia().getDisplayTitle(); - curNotification.setTicker(getString(R.string.upload_paused_notification_title, displayTitle)) - .setContentTitle(getString(R.string.upload_paused_notification_title, displayTitle)) - .setContentText(getString(R.string.upload_paused_notification_subtitle)) - .setProgress(0, 0, false) - .setOngoing(false); - notificationManager.notify(contribution.getLocalUri().toString(), NOTIFICATION_UPLOAD_PAUSED, - curNotification.build()); - - contribution.setState(Contribution.STATE_PAUSED); - - compositeDisposable.add(contributionDao - .update(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - - private String findUniqueFilename(String fileName) throws IOException { - String sequenceFileName; - for (int sequenceNumber = 1; true; sequenceNumber++) { - if (sequenceNumber == 1) { - sequenceFileName = fileName; - } else { - if (fileName.indexOf('.') == -1) { - // We really should have appended a filePath type suffix already. - // But... we might not. - sequenceFileName = fileName + " " + sequenceNumber; - } else { - Pattern regex = Pattern.compile("^(.*)(\\..+?)$"); - Matcher regexMatcher = regex.matcher(fileName); - sequenceFileName = regexMatcher.replaceAll("$1 " + sequenceNumber + "$2"); - } - } - if (!mediaClient.checkPageExistsUsingTitle(String.format("File:%s", sequenceFileName)) - .blockingGet() - && !unfinishedUploads.contains(sequenceFileName)) { - break; - } - } - return sequenceFileName; - } - - public interface ServiceCallback{ - void updateUploadCount() ; - } - - public void setServiceCallback(ServiceCallback serviceCallback) { - this.serviceCallback = serviceCallback; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt new file mode 100644 index 000000000..33ac4ef81 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt @@ -0,0 +1,480 @@ +package fr.free.nrw.commons.upload.worker + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.BitmapFactory +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.mapbox.mapboxsdk.plugins.localization.BuildConfig +import dagger.android.ContributesAndroidInjector +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.contributions.ChunkInfo +import fr.free.nrw.commons.contributions.Contribution +import fr.free.nrw.commons.contributions.ContributionDao +import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.media.MediaClient +import fr.free.nrw.commons.upload.StashUploadState +import fr.free.nrw.commons.upload.UploadClient +import fr.free.nrw.commons.upload.UploadResult +import fr.free.nrw.commons.wikidata.WikidataEditService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.* +import java.util.regex.Pattern +import javax.inject.Inject +import kotlin.collections.ArrayList + +class UploadWorker(var appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + + private var notificationManager: NotificationManagerCompat? = null + + @Inject + lateinit var wikidataEditService: WikidataEditService + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var contributionDao: ContributionDao + + @Inject + lateinit var uploadClient: UploadClient + + @Inject + lateinit var mediaClient: MediaClient + + private val PROCESSING_UPLOADS_NOTIFICATION_TAG = BuildConfig.APPLICATION_ID + " : upload_tag" + + private val PROCESSING_UPLOADS_NOTIFICATION_ID = 101 + + + //Attributes of the current-upload notification + private var currentNotificationID: Int = -1// lateinit is not allowed with primitives + private lateinit var currentNotificationTag: String + private var curentNotification: NotificationCompat.Builder + + private val statesToProcess= ArrayList() + + private val STASH_ERROR_CODES = Arrays + .asList( + "uploadstash-file-not-found", + "stashfailed", + "verification-error", + "chunk-too-small" + ) + + init { + ApplicationlessInjection + .getInstance(appContext) + .commonsApplicationComponent + .inject(this) + curentNotification = + getNotificationBuilder(CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)!! + + statesToProcess.add(Contribution.STATE_QUEUED) + statesToProcess.add(Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE) + } + + @dagger.Module + interface Module { + @ContributesAndroidInjector + fun worker(): UploadWorker + } + + open inner class NotificationUpdateProgressListener( + private var notificationFinishingTitle: String?, + var contribution: Contribution? + ) { + + fun onProgress(transferred: Long, total: Long) { + if (transferred == total) { + // Completed! + curentNotification.setContentTitle(notificationFinishingTitle) + .setProgress(0, 100, true) + } else { + curentNotification + .setProgress( + 100, + (transferred.toDouble() / total.toDouble() * 100).toInt(), + false + ) + } + notificationManager?.notify( + currentNotificationTag, + currentNotificationID, + curentNotification.build()!! + ) + contribution!!.transferred = transferred + contributionDao.update(contribution).blockingAwait() + } + + open fun onChunkUploaded(contribution: Contribution, chunkInfo: ChunkInfo?) { + contribution.chunkInfo = chunkInfo + contributionDao.update(contribution).blockingAwait() + } + } + + private fun getNotificationBuilder(channelId: String): NotificationCompat.Builder? { + return NotificationCompat.Builder(appContext, channelId) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_launcher) + .setLargeIcon( + BitmapFactory.decodeResource( + appContext.resources, + R.drawable.ic_launcher + ) + ) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setProgress(100, 0, true) + .setOngoing(true) + } + + override suspend fun doWork(): Result { + notificationManager = NotificationManagerCompat.from(appContext) + val processingUploads = getNotificationBuilder( + CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL + )!! + withContext(Dispatchers.IO) { + //Doing this so that retry requests do not create new work requests and while a work is + // already running, all the requests should go through this, so kind of a queue + while (contributionDao.getContribution(statesToProcess) + .blockingGet().isNotEmpty() + ) { + val queuedContributions = contributionDao.getContribution(statesToProcess) + .blockingGet() + //Showing initial notification for the number of uploads being processed + + processingUploads.setContentTitle(appContext.getString(R.string.starting_uploads)) + processingUploads.setContentText( + appContext.resources.getQuantityString( + R.plurals.starting_multiple_uploads, + queuedContributions.size, + queuedContributions.size + ) + ) + notificationManager?.notify( + PROCESSING_UPLOADS_NOTIFICATION_TAG, + PROCESSING_UPLOADS_NOTIFICATION_ID, + processingUploads.build() + ) + + queuedContributions.asFlow().map { contribution -> + /** + * If the limited connection mode is on, lets iterate through the queued + * contributions + * and set the state as STATE_QUEUED_LIMITED_CONNECTION_MODE , + * otherwise proceed with the upload + */ + if(isLimitedConnectionModeEnabled()){ + if (contribution.state == Contribution.STATE_QUEUED) { + contribution.state = Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE + contributionDao.save(contribution) + } + } else { + contribution.transferred = 0 + contribution.state = Contribution.STATE_IN_PROGRESS + contributionDao.save(contribution) + uploadContribution(contribution = contribution) + } + }.collect() + + //Dismiss the global notification + notificationManager?.cancel( + PROCESSING_UPLOADS_NOTIFICATION_TAG, + PROCESSING_UPLOADS_NOTIFICATION_ID + ) + + //No need to keep looking if the limited connection mode is on, + //If the user toggles it, the work manager will be started again + if(isLimitedConnectionModeEnabled()){ + break; + } + } + } + //TODO make this smart, think of handling retries in the future + return Result.success() + } + + /** + * Returns true is the limited connection mode is enabled + */ + private fun isLimitedConnectionModeEnabled(): Boolean { + return sessionManager.getPreference(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED) + } + + /** + * Upload the contribution + * @param contribution + */ + @SuppressLint("StringFormatInvalid") + private suspend fun uploadContribution(contribution: Contribution) { + if (contribution.localUri == null || contribution.localUri.path == null) { + Timber.e("""upload: ${contribution.media.filename} failed, file path is null""") + } + + val media = contribution.media + val displayTitle = contribution.media.displayTitle + + currentNotificationTag = contribution.localUri.toString() + currentNotificationID = + (contribution.localUri.toString() + contribution.media.filename).hashCode() + + curentNotification + getNotificationBuilder(CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)!! + curentNotification.setContentTitle( + appContext.getString( + R.string.upload_progress_notification_title_start, + displayTitle + ) + ) + + notificationManager?.notify( + currentNotificationTag, + currentNotificationID, + curentNotification.build()!! + ) + + val filename = media.filename + + val notificationProgressUpdater = NotificationUpdateProgressListener( + appContext.getString( + R.string.upload_progress_notification_title_finishing, + displayTitle + ), + contribution + ) + + try { + //Upload the file to stash + val stashUploadResult = uploadClient.uploadFileToStash( + appContext, filename, contribution, notificationProgressUpdater + ).blockingSingle() + + when (stashUploadResult.state) { + StashUploadState.SUCCESS -> { + //If the stash upload succeeds + Timber.d("Upload to stash success for fileName: $filename") + Timber.d("Ensure uniqueness of filename"); + val uniqueFileName = findUniqueFileName(filename!!) + + + try { + //Upload the file from stash + val uploadResult = uploadClient.uploadFileFromStash( + contribution, uniqueFileName, stashUploadResult.fileKey + ).blockingSingle() + + if (uploadResult.isSuccessful()) { + Timber.d( + "Stash Upload success..proceeding to make wikidata edit" + ) + + if(contribution.wikidataPlace==null){ + Timber.d( + "WikiDataEdit not required, upload success" + ) + saveCompletedContribution(contribution,uploadResult) + showSuccessNotification(contribution) + }else{ + Timber.d( + "WikiDataEdit not required, making wikidata edit" + ) + makeWikiDataEdit(uploadResult, contribution) + } + + } else { + Timber.e("Stash Upload failed") + showFailedNotification(contribution) + contribution.state = Contribution.STATE_FAILED + contribution.chunkInfo = null + contributionDao.save(contribution).blockingAwait() + + } + }catch (exception : Exception){ + Timber.e(exception) + Timber.e("Upload from stash failed for contribution : $filename") + showFailedNotification(contribution) + if (STASH_ERROR_CODES.contains(exception.message)) { + clearChunks(contribution) + } + } + } + StashUploadState.PAUSED -> { + showPausedNotification(contribution) + contribution.state = Contribution.STATE_PAUSED + contributionDao.save(contribution).blockingGet() + } + else -> { + Timber.e("""upload file to stash failed with status: ${stashUploadResult.state}""") + showFailedNotification(contribution) + contribution.state = Contribution.STATE_FAILED + contribution.chunkInfo = null + contributionDao.save(contribution).blockingAwait() + } + } + }catch (exception: Exception){ + Timber.e(exception) + Timber.e("Stash upload failed for contribution: $filename") + showFailedNotification(contribution) + } + } + + private fun clearChunks(contribution: Contribution) { + contribution.chunkInfo=null + contributionDao.save(contribution).blockingAwait() + } + + /** + * Make the WikiData Edit, if applicable + */ + private suspend fun makeWikiDataEdit(uploadResult: UploadResult, contribution: Contribution) { + wikidataEditService.addDepictionsAndCaptions(uploadResult, contribution) + val wikiDataPlace = contribution.wikidataPlace + if (wikiDataPlace != null && wikiDataPlace.imageValue == null) { + if (!contribution.hasInvalidLocation()) { + var revisionID: Long?=null + try { + revisionID = wikidataEditService.createClaim( + wikiDataPlace, uploadResult.filename, + contribution.media.captions + ) + if (null != revisionID) { + showSuccessNotification(contribution) + } + }catch (exception: Exception){ + Timber.e(exception) + } + + withContext(Dispatchers.Main) { + wikidataEditService.handleImageClaimResult( + contribution.wikidataPlace, + revisionID + ) + } + } else { + withContext(Dispatchers.Main) { + wikidataEditService.handleImageClaimResult( + contribution.wikidataPlace, null + ) + } + } + } + saveCompletedContribution(contribution, uploadResult) + } + + private fun saveCompletedContribution(contribution: Contribution, uploadResult: UploadResult) { + val contributionFromUpload = mediaClient.getMedia("File:" + uploadResult.filename) + .map { media: Media? -> contribution.completeWith(media!!) } + .blockingGet() + contributionFromUpload.dateModified=Date() + contributionDao.deleteAndSaveContribution(contribution, contributionFromUpload) + } + + private fun findUniqueFileName(fileName: String): String { + var sequenceFileName: String? + var sequenceNumber = 1 + while (true) { + sequenceFileName = if (sequenceNumber == 1) { + fileName + } else { + if (fileName.indexOf('.') == -1) { + "$fileName $sequenceNumber" + } else { + val regex = + Pattern.compile("^(.*)(\\..+?)$") + val regexMatcher = regex.matcher(fileName) + regexMatcher.replaceAll("$1 $sequenceNumber$2") + } + } + if (!mediaClient.checkPageExistsUsingTitle( + String.format( + "File:%s", + sequenceFileName + ) + ) + .blockingGet() + ) { + break + } + sequenceNumber++ + } + return sequenceFileName!! + } + + /** + * Notify that the current upload has succeeded + * @param contribution + */ + @SuppressLint("StringFormatInvalid") + private fun showSuccessNotification(contribution: Contribution) { + val displayTitle = contribution.media.displayTitle + contribution.state=Contribution.STATE_COMPLETED + curentNotification.setContentTitle( + appContext.getString( + R.string.upload_completed_notification_title, + displayTitle + ) + ) + .setContentText(appContext.getString(R.string.upload_completed_notification_text)) + .setProgress(0, 0, false) + .setOngoing(false) + notificationManager?.notify( + currentNotificationTag, currentNotificationID, + curentNotification.build() + ) + } + + /** + * Notify that the current upload has failed + * @param contribution + */ + @SuppressLint("StringFormatInvalid") + private fun showFailedNotification(contribution: Contribution) { + val displayTitle = contribution.media.displayTitle + curentNotification.setContentTitle( + appContext.getString( + R.string.upload_failed_notification_title, + displayTitle + ) + ) + .setContentText(appContext.getString(R.string.upload_failed_notification_subtitle)) + .setProgress(0, 0, false) + .setOngoing(false) + notificationManager?.notify( + currentNotificationTag, currentNotificationID, + curentNotification.build() + ) + } + + /** + * Notify that the current upload is paused + * @param contribution + */ + private fun showPausedNotification(contribution: Contribution) { + val displayTitle = contribution.media.displayTitle + curentNotification.setContentTitle( + appContext.getString( + R.string.upload_paused_notification_title, + displayTitle + ) + ) + .setContentText(appContext.getString(R.string.upload_paused_notification_subtitle)) + .setProgress(0, 0, false) + .setOngoing(false) + notificationManager!!.notify( + currentNotificationTag, currentNotificationID, + curentNotification.build() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java index 3f5a84cf3..e826de6cf 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java @@ -16,13 +16,11 @@ import fr.free.nrw.commons.upload.WikidataPlace; import fr.free.nrw.commons.utils.ConfigUtils; import fr.free.nrw.commons.utils.ViewUtil; import io.reactivex.Observable; -import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -139,17 +137,17 @@ public class WikidataEditService { } } - public void createClaim(@Nullable final WikidataPlace wikidataPlace, final String fileName, final + public Long createClaim(@Nullable final WikidataPlace wikidataPlace, final String fileName, final Map captions) { if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { Timber .d("Image location and nearby place location mismatched, so Wikidata item won't be edited"); - return; + return null; } - addImageAndMediaLegends(wikidataPlace, fileName, captions); + return addImageAndMediaLegends(wikidataPlace, fileName, captions); } - public void addImageAndMediaLegends(final WikidataItem wikidataItem, final String fileName, + public Long addImageAndMediaLegends(final WikidataItem wikidataItem, final String fileName, final Map captions) { final Snak_partial p18 = new Snak_partial("value", WikidataProperties.IMAGE.getPropertyName(), new ValueString(fileName.replace("File:", ""))); @@ -166,17 +164,10 @@ public class WikidataEditService { Collections.singletonMap(WikidataProperties.MEDIA_LEGENDS.getPropertyName(), snaks), Arrays.asList(WikidataProperties.MEDIA_LEGENDS.getPropertyName())); - wikidataClient.setClaim(claim, COMMONS_APP_TAG).subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(revisionId -> handleImageClaimResult(wikidataItem, String.valueOf(revisionId)), - throwable -> { - Timber.e(throwable, "Error occurred while making claim"); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - }); - ; + return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle(); } - private void handleImageClaimResult(final WikidataItem wikidataItem, final String revisionId) { + public void handleImageClaimResult(final WikidataItem wikidataItem, final Long revisionId) { if (revisionId != null) { if (wikidataEditListener != null) { wikidataEditListener.onSuccessfulWikidataEdit(); diff --git a/app/src/main/res/layout/layout_contribution.xml b/app/src/main/res/layout/layout_contribution.xml index a1f6cfcb9..cc501d30c 100644 --- a/app/src/main/res/layout/layout_contribution.xml +++ b/app/src/main/res/layout/layout_contribution.xml @@ -76,6 +76,8 @@ %1$d tập tin đã tải lên %1$d tập tin đã tải lên - Đang bắt đầu tải lên %1$d tập tin + + + Đang bắt đầu tải lên %1$d tập tin + Đang bắt đầu tải lên %1$d tập tin + + %1$d tập tin đã tải lên %1$d tập tin đã tải lên diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a0ec78382..7cecad345 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,12 +10,12 @@ Starting Uploads - Starting %1$d upload - Starting %1$d uploads + Processing %d upload + Processing %d uploads - %1$d upload - %1$d uploads + %d upload + %d uploads This image will be licensed under %1$s @@ -54,7 +54,7 @@ Upload queued (limited connection mode enabled) %1$s uploaded! Tap to view your upload - Starting %1$s upload + Uploading file: %s %1$s uploading Finishing uploading %1$s Uploading %1$s failed diff --git a/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPictureDaoTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPictureDaoTest.kt index 4894aebba..010a00a43 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPictureDaoTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPictureDaoTest.kt @@ -124,7 +124,7 @@ class BookmarkPictureDaoTest { whenever(client.query(any(), any(), any(), any(), anyOrNull())).thenReturn(createCursor(1)) assertFalse(testObject.updateBookmark(exampleBookmark)) - verify(client).delete(eq(exampleBookmark.contentUri), isNull(), isNull()) + verify(client).delete(eq(exampleBookmark.contentUri!!), isNull(), isNull()) } @Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsPresenterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsPresenterTest.kt index 05546504a..45c7d89d2 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsPresenterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsPresenterTest.kt @@ -2,28 +2,21 @@ package fr.free.nrw.commons.contributions import android.database.Cursor import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.loader.content.CursorLoader import androidx.loader.content.Loader -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import io.reactivex.Completable -import io.reactivex.Scheduler -import io.reactivex.Single import io.reactivex.schedulers.TestScheduler import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.ArgumentMatchers -import org.mockito.ArgumentMatchers.* import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations -import java.util.concurrent.TimeUnit /** * The unit test class for ContributionsPresenter @@ -58,7 +51,7 @@ class ContributionsPresenterTest { scheduler=TestScheduler() cursor = Mockito.mock(Cursor::class.java) contribution = Mockito.mock(Contribution::class.java) - contributionsPresenter = ContributionsPresenter(repository,scheduler,scheduler) + contributionsPresenter = ContributionsPresenter(repository, scheduler) loader = Mockito.mock(CursorLoader::class.java) contributionsPresenter.onAttachView(view) liveData=MutableLiveData() diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDaoTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDaoTest.kt index 6752ca7fe..71d464c9f 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDaoTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDaoTest.kt @@ -152,7 +152,7 @@ class RecentSearchesDaoTest { testObject.save(recentSearch) - verify(client).update(eq(recentSearch.contentUri), captor.capture(), isNull(), isNull()) + verify(client).update(eq(recentSearch.contentUri!!), captor.capture(), isNull(), isNull()) captor.firstValue.let { cv -> assertEquals(2, cv.size()) assertEquals(recentSearch.query, cv.getAsString(COLUMN_NAME)) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadControllerTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadControllerTest.kt index 1a1201899..341e210bc 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadControllerTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadControllerTest.kt @@ -1,29 +1,28 @@ package fr.free.nrw.commons.upload -import android.content.ComponentName +import android.content.ContentResolver import android.content.Context import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever import fr.free.nrw.commons.Media -import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.contributions.Contribution import fr.free.nrw.commons.kvstore.JsonKvStore import org.junit.Before import org.junit.Test import org.mockito.InjectMocks import org.mockito.Mock -import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.mockito.MockitoAnnotations class UploadControllerTest { - - @Mock - internal var sessionManager: SessionManager? = null @Mock internal var context: Context? = null + @Mock - internal var prefs: JsonKvStore? = null + internal lateinit var store: JsonKvStore + + @Mock + internal lateinit var contentResolver: ContentResolver @InjectMocks var uploadController: UploadController? = null @@ -31,20 +30,6 @@ class UploadControllerTest { @Before fun setup() { MockitoAnnotations.initMocks(this) - val uploadService = mock(UploadService::class.java) - val binder = mock(UploadService.UploadServiceLocalBinder::class.java) - `when`(binder.service).thenReturn(uploadService) - uploadController!!.uploadServiceConnection.onServiceConnected(mock(ComponentName::class.java), binder) - } - - @Test - fun prepareService() { - uploadController!!.prepareService() - } - - @Test - fun cleanup() { - uploadController!!.cleanup() } @Test @@ -53,6 +38,7 @@ class UploadControllerTest { val media = mock() whenever(contribution.media).thenReturn(media) whenever(media.author).thenReturn("Creator") - uploadController!!.startUpload(contribution) + whenever(context?.contentResolver).thenReturn(contentResolver) + uploadController?.prepareMedia(contribution) } } diff --git a/gradle.properties b/gradle.properties index d632f87a4..3a2d71a08 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,7 @@ android.enableBuildCache=true KOTLIN_VERSION=1.3.72 BUTTERKNIFE_VERSION=10.1.0 LEAK_CANARY_VERSION=1.6.2 -DAGGER_VERSION=2.21 +DAGGER_VERSION=2.23 ROOM_VERSION=2.2.3 PREFERENCE_VERSION=1.1.0 CORE_KTX_VERSION=1.2.0