|
1 |
| -package fr.free.nrw.commons.upload; |
2 |
| - |
3 |
| -import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF; |
4 |
| - |
5 |
| -import android.content.Context; |
6 |
| -import android.net.Uri; |
7 |
| -import androidx.annotation.Nullable; |
8 |
| -import com.google.gson.Gson; |
9 |
| -import fr.free.nrw.commons.CommonsApplication; |
10 |
| -import fr.free.nrw.commons.contributions.ChunkInfo; |
11 |
| -import fr.free.nrw.commons.contributions.Contribution; |
12 |
| -import fr.free.nrw.commons.upload.worker.UploadWorker.NotificationUpdateProgressListener; |
13 |
| -import io.reactivex.Observable; |
14 |
| -import io.reactivex.disposables.CompositeDisposable; |
15 |
| -import java.io.File; |
16 |
| -import java.io.IOException; |
17 |
| -import java.net.URLEncoder; |
18 |
| -import java.util.Date; |
19 |
| -import java.util.List; |
20 |
| -import java.util.concurrent.atomic.AtomicBoolean; |
21 |
| -import java.util.concurrent.atomic.AtomicInteger; |
22 |
| -import java.util.concurrent.atomic.AtomicReference; |
23 |
| -import javax.inject.Inject; |
24 |
| -import javax.inject.Named; |
25 |
| -import javax.inject.Singleton; |
26 |
| -import okhttp3.MediaType; |
27 |
| -import okhttp3.MultipartBody; |
28 |
| -import okhttp3.RequestBody; |
29 |
| -import fr.free.nrw.commons.auth.csrf.CsrfTokenClient; |
30 |
| -import fr.free.nrw.commons.wikidata.mwapi.MwException; |
31 |
| -import timber.log.Timber; |
32 |
| - |
33 |
| -@Singleton |
34 |
| -public class UploadClient { |
35 |
| - |
36 |
| - private final int CHUNK_SIZE = 512 * 1024; // 512 KB |
37 |
| - |
38 |
| - //This is maximum duration for which a stash is persisted on MediaWiki |
39 |
| - // https://www.mediawiki.org/wiki/Manual:$wgUploadStashMaxAge |
40 |
| - private final int MAX_CHUNK_AGE = 6 * 3600 * 1000; // 6 hours |
41 |
| - |
42 |
| - private final UploadInterface uploadInterface; |
43 |
| - private final CsrfTokenClient csrfTokenClient; |
44 |
| - private final PageContentsCreator pageContentsCreator; |
45 |
| - private final FileUtilsWrapper fileUtilsWrapper; |
46 |
| - private final Gson gson; |
47 |
| - |
48 |
| - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); |
49 |
| - |
50 |
| - @Inject |
51 |
| - public UploadClient(final UploadInterface uploadInterface, |
52 |
| - @Named(NAMED_COMMONS_CSRF) final CsrfTokenClient csrfTokenClient, |
53 |
| - final PageContentsCreator pageContentsCreator, |
54 |
| - final FileUtilsWrapper fileUtilsWrapper, final Gson gson) { |
55 |
| - this.uploadInterface = uploadInterface; |
56 |
| - this.csrfTokenClient = csrfTokenClient; |
57 |
| - this.pageContentsCreator = pageContentsCreator; |
58 |
| - this.fileUtilsWrapper = fileUtilsWrapper; |
59 |
| - this.gson = gson; |
60 |
| - } |
61 |
| - |
62 |
| - /** |
63 |
| - * Upload file to stash in chunks of specified size. Uploading files in chunks will make |
64 |
| - * handling of large files easier. Also, it will be useful in supporting pause/resume of |
65 |
| - * uploads |
66 |
| - */ |
67 |
| - public Observable<StashUploadResult> uploadFileToStash( |
68 |
| - final Context context, final String filename, final Contribution contribution, |
69 |
| - final NotificationUpdateProgressListener notificationUpdater) throws IOException { |
70 |
| - if (contribution.getChunkInfo() != null |
71 |
| - && contribution.getChunkInfo().getTotalChunks() == contribution.getChunkInfo() |
72 |
| - .getIndexOfNextChunkToUpload()) { |
73 |
| - return Observable.just(new StashUploadResult(StashUploadState.SUCCESS, |
74 |
| - contribution.getChunkInfo().getUploadResult().getFilekey())); |
75 |
| - } |
76 |
| - |
77 |
| - CommonsApplication.pauseUploads.put(contribution.getPageId(), false); |
78 |
| - |
79 |
| - final File file = new File(contribution.getLocalUri().getPath()); |
80 |
| - final List<File> fileChunks = fileUtilsWrapper.getFileChunks(context, file, CHUNK_SIZE); |
81 |
| - |
82 |
| - final int totalChunks = fileChunks.size(); |
83 |
| - |
84 |
| - final MediaType mediaType = MediaType |
85 |
| - .parse(FileUtils.getMimeType(context, Uri.parse(file.getPath()))); |
86 |
| - |
87 |
| - final AtomicReference<ChunkInfo> chunkInfo = new AtomicReference<>(); |
88 |
| - if (isStashValid(contribution)) { |
89 |
| - chunkInfo.set(contribution.getChunkInfo()); |
90 |
| - |
91 |
| - Timber.d("Chunk: Next Chunk: %s, Total Chunks: %s", |
92 |
| - contribution.getChunkInfo().getIndexOfNextChunkToUpload(), |
93 |
| - contribution.getChunkInfo().getTotalChunks()); |
94 |
| - } |
95 |
| - |
96 |
| - final AtomicInteger index = new AtomicInteger(); |
97 |
| - final AtomicBoolean failures = new AtomicBoolean(); |
98 |
| - |
99 |
| - compositeDisposable.add(Observable.fromIterable(fileChunks).forEach(chunkFile -> { |
100 |
| - if (CommonsApplication.pauseUploads.get(contribution.getPageId()) || failures.get()) { |
101 |
| - return; |
102 |
| - } |
103 |
| - |
104 |
| - if (chunkInfo.get() != null && index.get() < chunkInfo.get() |
105 |
| - .getIndexOfNextChunkToUpload()) { |
106 |
| - index.incrementAndGet(); |
107 |
| - Timber.d("Chunk: Increment and return: %s", index.get()); |
108 |
| - return; |
109 |
| - } |
110 |
| - index.getAndIncrement(); |
111 |
| - final int offset = |
112 |
| - chunkInfo.get() != null ? chunkInfo.get().getUploadResult().getOffset() : 0; |
113 |
| - |
114 |
| - Timber.d("Chunk: Sending Chunk number: %s, offset: %s", index.get(), offset); |
115 |
| - final String filekey = |
116 |
| - chunkInfo.get() != null ? chunkInfo.get().getUploadResult().getFilekey() : null; |
117 |
| - |
118 |
| - final RequestBody requestBody = RequestBody |
119 |
| - .create(mediaType, chunkFile); |
120 |
| - final CountingRequestBody countingRequestBody = new CountingRequestBody(requestBody, |
121 |
| - notificationUpdater::onProgress, offset, |
122 |
| - file.length()); |
123 |
| - |
124 |
| - compositeDisposable.add(uploadChunkToStash(filename, |
125 |
| - file.length(), |
126 |
| - offset, |
127 |
| - filekey, |
128 |
| - countingRequestBody).subscribe(uploadResult -> { |
129 |
| - Timber.d("Chunk: Received Chunk number: %s, offset: %s", index.get(), |
130 |
| - uploadResult.getOffset()); |
131 |
| - chunkInfo.set( |
132 |
| - new ChunkInfo(uploadResult, index.get(), totalChunks)); |
133 |
| - notificationUpdater.onChunkUploaded(contribution, chunkInfo.get()); |
134 |
| - }, throwable -> { |
135 |
| - Timber.e(throwable, "Received error in chunk upload"); |
136 |
| - failures.set(true); |
137 |
| - })); |
138 |
| - })); |
139 |
| - |
140 |
| - if (CommonsApplication.pauseUploads.get(contribution.getPageId())) { |
141 |
| - Timber.d("Upload stash paused %s", contribution.getPageId()); |
142 |
| - return Observable.just(new StashUploadResult(StashUploadState.PAUSED, null)); |
143 |
| - } else if (failures.get()) { |
144 |
| - Timber.d("Upload stash contains failures %s", contribution.getPageId()); |
145 |
| - return Observable.just(new StashUploadResult(StashUploadState.FAILED, null)); |
146 |
| - } else if (chunkInfo.get() != null) { |
147 |
| - Timber.d("Upload stash success %s", contribution.getPageId()); |
148 |
| - return Observable.just(new StashUploadResult(StashUploadState.SUCCESS, |
149 |
| - chunkInfo.get().getUploadResult().getFilekey())); |
150 |
| - } else { |
151 |
| - Timber.d("Upload stash failed %s", contribution.getPageId()); |
152 |
| - return Observable.just(new StashUploadResult(StashUploadState.FAILED, null)); |
153 |
| - } |
154 |
| - } |
155 |
| - |
156 |
| - /** |
157 |
| - * Stash is valid for 6 hours. This function checks the validity of stash |
158 |
| - * |
159 |
| - * @param contribution |
160 |
| - * @return |
161 |
| - */ |
162 |
| - private boolean isStashValid(Contribution contribution) { |
163 |
| - return contribution.getChunkInfo() != null && |
164 |
| - contribution.getDateModified() |
165 |
| - .after(new Date(System.currentTimeMillis() - MAX_CHUNK_AGE)); |
166 |
| - } |
167 |
| - |
168 |
| - /** |
169 |
| - * Uploads a file chunk to stash |
170 |
| - * |
171 |
| - * @param filename The name of the file being uploaded |
172 |
| - * @param fileSize The total size of the file |
173 |
| - * @param offset The offset returned by the previous chunk upload |
174 |
| - * @param fileKey The filekey returned by the previous chunk upload |
175 |
| - * @param countingRequestBody Request body with chunk file |
176 |
| - * @return |
177 |
| - */ |
178 |
| - Observable<UploadResult> uploadChunkToStash(final String filename, |
179 |
| - final long fileSize, |
180 |
| - final long offset, |
181 |
| - final String fileKey, |
182 |
| - final CountingRequestBody countingRequestBody) { |
183 |
| - final MultipartBody.Part filePart; |
184 |
| - try { |
185 |
| - filePart = MultipartBody.Part |
186 |
| - .createFormData("chunk", URLEncoder.encode(filename, "utf-8"), countingRequestBody); |
187 |
| - |
188 |
| - return uploadInterface.uploadFileToStash(toRequestBody(filename), |
189 |
| - toRequestBody(String.valueOf(fileSize)), |
190 |
| - toRequestBody(String.valueOf(offset)), |
191 |
| - toRequestBody(fileKey), |
192 |
| - toRequestBody(csrfTokenClient.getTokenBlocking()), |
193 |
| - filePart) |
194 |
| - .map(UploadResponse::getUpload); |
195 |
| - } catch (final Throwable throwable) { |
196 |
| - Timber.e(throwable, "Failed to upload chunk to stash"); |
197 |
| - return Observable.error(throwable); |
198 |
| - } |
199 |
| - } |
200 |
| - |
201 |
| - /** |
202 |
| - * Converts string value to request body |
203 |
| - */ |
204 |
| - @Nullable |
205 |
| - private RequestBody toRequestBody(@Nullable final String value) { |
206 |
| - return value == null ? null : RequestBody.create(okhttp3.MultipartBody.FORM, value); |
207 |
| - } |
208 |
| - |
209 |
| - |
210 |
| - public Observable<UploadResult> uploadFileFromStash( |
211 |
| - final Contribution contribution, |
212 |
| - final String uniqueFileName, |
213 |
| - final String fileKey) { |
214 |
| - try { |
215 |
| - return uploadInterface |
216 |
| - .uploadFileFromStash(csrfTokenClient.getTokenBlocking(), |
217 |
| - pageContentsCreator.createFrom(contribution), |
218 |
| - CommonsApplication.DEFAULT_EDIT_SUMMARY, |
219 |
| - uniqueFileName, |
220 |
| - fileKey).map(uploadResponse -> { |
221 |
| - final UploadResponse uploadResult = gson |
222 |
| - .fromJson(uploadResponse, UploadResponse.class); |
223 |
| - if (uploadResult.getUpload() == null) { |
224 |
| - final MwException exception = gson |
225 |
| - .fromJson(uploadResponse, MwException.class); |
226 |
| - Timber.e(exception, "Error in uploading file from stash"); |
227 |
| - throw new Exception(exception.getErrorCode()); |
228 |
| - } |
229 |
| - return uploadResult.getUpload(); |
230 |
| - }); |
231 |
| - } catch (final Throwable throwable) { |
232 |
| - Timber.e(throwable, "Exception occurred in uploading file from stash"); |
233 |
| - return Observable.error(throwable); |
234 |
| - } |
235 |
| - } |
236 |
| -} |
0 commit comments