Two way synchronization for files
[pub/Android/ownCloud.git] / src / com / owncloud / android / files / services / FileUploader.java
1 package com.owncloud.android.files.services;
2
3 import java.io.File;
4 import java.util.AbstractList;
5 import java.util.Iterator;
6 import java.util.Vector;
7 import java.util.concurrent.ConcurrentHashMap;
8 import java.util.concurrent.ConcurrentMap;
9
10 import com.owncloud.android.authenticator.AccountAuthenticator;
11 import com.owncloud.android.datamodel.FileDataStorageManager;
12 import com.owncloud.android.datamodel.OCFile;
13 import com.owncloud.android.files.InstantUploadBroadcastReceiver;
14 import com.owncloud.android.operations.ChunkedUploadFileOperation;
15 import com.owncloud.android.operations.RemoteOperationResult;
16 import com.owncloud.android.operations.UploadFileOperation;
17 import com.owncloud.android.ui.activity.FileDetailActivity;
18 import com.owncloud.android.ui.fragment.FileDetailFragment;
19 import com.owncloud.android.utils.OwnCloudVersion;
20
21 import eu.alefzero.webdav.OnDatatransferProgressListener;
22
23 import com.owncloud.android.network.OwnCloudClientUtils;
24
25 import android.accounts.Account;
26 import android.accounts.AccountManager;
27 import android.app.Notification;
28 import android.app.NotificationManager;
29 import android.app.PendingIntent;
30 import android.app.Service;
31 import android.content.Intent;
32 import android.os.Binder;
33 import android.os.Handler;
34 import android.os.HandlerThread;
35 import android.os.IBinder;
36 import android.os.Looper;
37 import android.os.Message;
38 import android.os.Process;
39 import android.util.Log;
40 import android.webkit.MimeTypeMap;
41 import android.widget.RemoteViews;
42
43 import com.owncloud.android.R;
44 import eu.alefzero.webdav.WebdavClient;
45
46 public class FileUploader extends Service implements OnDatatransferProgressListener {
47
48 public static final String UPLOAD_FINISH_MESSAGE = "UPLOAD_FINISH";
49 public static final String EXTRA_PARENT_DIR_ID = "PARENT_DIR_ID";
50 public static final String EXTRA_UPLOAD_RESULT = "RESULT";
51 public static final String EXTRA_REMOTE_PATH = "REMOTE_PATH";
52 public static final String EXTRA_FILE_PATH = "FILE_PATH";
53
54 public static final String KEY_LOCAL_FILE = "LOCAL_FILE";
55 public static final String KEY_REMOTE_FILE = "REMOTE_FILE";
56 public static final String KEY_ACCOUNT = "ACCOUNT";
57 public static final String KEY_UPLOAD_TYPE = "UPLOAD_TYPE";
58 public static final String KEY_FORCE_OVERWRITE = "KEY_FORCE_OVERWRITE";
59 public static final String ACCOUNT_NAME = "ACCOUNT_NAME";
60 public static final String KEY_MIME_TYPE = "MIME_TYPE";
61 public static final String KEY_INSTANT_UPLOAD = "INSTANT_UPLOAD";
62
63 public static final int UPLOAD_SINGLE_FILE = 0;
64 public static final int UPLOAD_MULTIPLE_FILES = 1;
65
66 private static final String TAG = FileUploader.class.getSimpleName();
67
68 private Looper mServiceLooper;
69 private ServiceHandler mServiceHandler;
70 private IBinder mBinder;
71 private WebdavClient mUploadClient = null;
72 private Account mLastAccount = null;
73 private FileDataStorageManager mStorageManager;
74
75 private ConcurrentMap<String, UploadFileOperation> mPendingUploads = new ConcurrentHashMap<String, UploadFileOperation>();
76 private UploadFileOperation mCurrentUpload = null;
77
78 private NotificationManager mNotificationManager;
79 private Notification mNotification;
80 private int mLastPercent;
81 private RemoteViews mDefaultNotificationContentView;
82
83
84 /**
85 * Builds a key for mPendingUploads from the account and file to upload
86 *
87 * @param account Account where the file to download is stored
88 * @param file File to download
89 */
90 private String buildRemoteName(Account account, OCFile file) {
91 return account.name + file.getRemotePath();
92 }
93
94 private String buildRemoteName(Account account, String remotePath) {
95 return account.name + remotePath;
96 }
97
98
99 /**
100 * Checks if an ownCloud server version should support chunked uploads.
101 *
102 * @param version OwnCloud version instance corresponding to an ownCloud server.
103 * @return 'True' if the ownCloud server with version supports chunked uploads.
104 */
105 private static boolean chunkedUploadIsSupported(OwnCloudVersion version) {
106 return (version != null && version.compareTo(OwnCloudVersion.owncloud_v4_5) >= 0);
107 }
108
109
110
111 /**
112 * Service initialization
113 */
114 @Override
115 public void onCreate() {
116 super.onCreate();
117 mNotificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
118 HandlerThread thread = new HandlerThread("FileUploaderThread",
119 Process.THREAD_PRIORITY_BACKGROUND);
120 thread.start();
121 mServiceLooper = thread.getLooper();
122 mServiceHandler = new ServiceHandler(mServiceLooper, this);
123 mBinder = new FileUploaderBinder();
124 }
125
126
127 /**
128 * Entry point to add one or several files to the queue of uploads.
129 *
130 * New uploads are added calling to startService(), resulting in a call to this method. This ensures the service will keep on working
131 * although the caller activity goes away.
132 */
133 @Override
134 public int onStartCommand(Intent intent, int flags, int startId) {
135 if (!intent.hasExtra(KEY_ACCOUNT) || !intent.hasExtra(KEY_UPLOAD_TYPE)) {
136 Log.e(TAG, "Not enough information provided in intent");
137 return Service.START_NOT_STICKY;
138 }
139 int uploadType = intent.getIntExtra(KEY_UPLOAD_TYPE, -1);
140 if (uploadType == -1) {
141 Log.e(TAG, "Incorrect upload type provided");
142 return Service.START_NOT_STICKY;
143 }
144 Account account = intent.getParcelableExtra(KEY_ACCOUNT);
145
146 String[] localPaths, remotePaths, mimeTypes;
147 if (uploadType == UPLOAD_SINGLE_FILE) {
148 localPaths = new String[] { intent.getStringExtra(KEY_LOCAL_FILE) };
149 remotePaths = new String[] { intent
150 .getStringExtra(KEY_REMOTE_FILE) };
151 mimeTypes = new String[] { intent.getStringExtra(KEY_MIME_TYPE) };
152
153 } else { // mUploadType == UPLOAD_MULTIPLE_FILES
154 localPaths = intent.getStringArrayExtra(KEY_LOCAL_FILE);
155 remotePaths = intent.getStringArrayExtra(KEY_REMOTE_FILE);
156 mimeTypes = intent.getStringArrayExtra(KEY_MIME_TYPE);
157 }
158
159 if (localPaths == null) {
160 Log.e(TAG, "Incorrect array for local paths provided in upload intent");
161 return Service.START_NOT_STICKY;
162 }
163 if (remotePaths == null) {
164 Log.e(TAG, "Incorrect array for remote paths provided in upload intent");
165 return Service.START_NOT_STICKY;
166 }
167
168 if (localPaths.length != remotePaths.length) {
169 Log.e(TAG, "Different number of remote paths and local paths!");
170 return Service.START_NOT_STICKY;
171 }
172
173 boolean isInstant = intent.getBooleanExtra(KEY_INSTANT_UPLOAD, false);
174 boolean forceOverwrite = intent.getBooleanExtra(KEY_FORCE_OVERWRITE, false);
175
176 OwnCloudVersion ocv = new OwnCloudVersion(AccountManager.get(this).getUserData(account, AccountAuthenticator.KEY_OC_VERSION));
177 boolean chunked = FileUploader.chunkedUploadIsSupported(ocv);
178 AbstractList<String> requestedUploads = new Vector<String>();
179 String uploadKey = null;
180 UploadFileOperation newUpload = null;
181 OCFile file = null;
182 FileDataStorageManager storageManager = new FileDataStorageManager(account, getContentResolver());
183 boolean fixed = false;
184 if (isInstant) {
185 fixed = checkAndFixInstantUploadDirectory(storageManager);
186 }
187 try {
188 for (int i=0; i < localPaths.length; i++) {
189 uploadKey = buildRemoteName(account, remotePaths[i]);
190 file = obtainNewOCFileToUpload(remotePaths[i], localPaths[i], ((mimeTypes!=null)?mimeTypes[i]:(String)null), isInstant, forceOverwrite, storageManager);
191 if (chunked) {
192 newUpload = new ChunkedUploadFileOperation(account, file, isInstant, forceOverwrite);
193 } else {
194 newUpload = new UploadFileOperation(account, file, isInstant, forceOverwrite);
195 }
196 if (fixed && i==0) {
197 newUpload.setRemoteFolderToBeCreated();
198 }
199 mPendingUploads.putIfAbsent(uploadKey, newUpload);
200 newUpload.addDatatransferProgressListener(this);
201 requestedUploads.add(uploadKey);
202 }
203
204 } catch (IllegalArgumentException e) {
205 Log.e(TAG, "Not enough information provided in intent: " + e.getMessage());
206 return START_NOT_STICKY;
207
208 } catch (IllegalStateException e) {
209 Log.e(TAG, "Bad information provided in intent: " + e.getMessage());
210 return START_NOT_STICKY;
211
212 } catch (Exception e) {
213 Log.e(TAG, "Unexpected exception while processing upload intent", e);
214 return START_NOT_STICKY;
215
216 }
217
218 if (requestedUploads.size() > 0) {
219 Message msg = mServiceHandler.obtainMessage();
220 msg.arg1 = startId;
221 msg.obj = requestedUploads;
222 mServiceHandler.sendMessage(msg);
223 }
224
225 return Service.START_NOT_STICKY;
226 }
227
228
229 /**
230 * Provides a binder object that clients can use to perform operations on the queue of uploads, excepting the addition of new files.
231 *
232 * Implemented to perform cancellation, pause and resume of existing uploads.
233 */
234 @Override
235 public IBinder onBind(Intent arg0) {
236 return mBinder;
237 }
238
239 /**
240 * Binder to let client components to perform operations on the queue of uploads.
241 *
242 * It provides by itself the available operations.
243 */
244 public class FileUploaderBinder extends Binder {
245
246 /**
247 * Cancels a pending or current upload of a remote file.
248 *
249 * @param account Owncloud account where the remote file will be stored.
250 * @param file A file in the queue of pending uploads
251 */
252 public void cancel(Account account, OCFile file) {
253 UploadFileOperation upload = null;
254 synchronized (mPendingUploads) {
255 upload = mPendingUploads.remove(buildRemoteName(account, file));
256 }
257 if (upload != null) {
258 upload.cancel();
259 }
260 }
261
262
263 /**
264 * Returns True when the file described by 'file' is being uploaded to the ownCloud account 'account' or waiting for it
265 *
266 * @param account Owncloud account where the remote file will be stored.
267 * @param file A file that could be in the queue of pending uploads
268 */
269 public boolean isUploading(Account account, OCFile file) {
270 synchronized (mPendingUploads) {
271 return (mPendingUploads.containsKey(buildRemoteName(account, file)));
272 }
273 }
274 }
275
276
277
278
279 /**
280 * Upload worker. Performs the pending uploads in the order they were requested.
281 *
282 * Created with the Looper of a new thread, started in {@link FileUploader#onCreate()}.
283 */
284 private static class ServiceHandler extends Handler {
285 // don't make it a final class, and don't remove the static ; lint will warn about a possible memory leak
286 FileUploader mService;
287 public ServiceHandler(Looper looper, FileUploader service) {
288 super(looper);
289 if (service == null)
290 throw new IllegalArgumentException("Received invalid NULL in parameter 'service'");
291 mService = service;
292 }
293
294 @Override
295 public void handleMessage(Message msg) {
296 @SuppressWarnings("unchecked")
297 AbstractList<String> requestedUploads = (AbstractList<String>) msg.obj;
298 if (msg.obj != null) {
299 Iterator<String> it = requestedUploads.iterator();
300 while (it.hasNext()) {
301 mService.uploadFile(it.next());
302 }
303 }
304 mService.stopSelf(msg.arg1);
305 }
306 }
307
308
309
310
311 /**
312 * Core upload method: sends the file(s) to upload
313 *
314 * @param uploadKey Key to access the upload to perform, contained in mPendingUploads
315 */
316 public void uploadFile(String uploadKey) {
317
318 synchronized(mPendingUploads) {
319 mCurrentUpload = mPendingUploads.get(uploadKey);
320 }
321
322 if (mCurrentUpload != null) {
323
324 notifyUploadStart(mCurrentUpload);
325
326
327 /// prepare client object to send requests to the ownCloud server
328 if (mUploadClient == null || !mLastAccount.equals(mCurrentUpload.getAccount())) {
329 mLastAccount = mCurrentUpload.getAccount();
330 mStorageManager = new FileDataStorageManager(mLastAccount, getContentResolver());
331 mUploadClient = OwnCloudClientUtils.createOwnCloudClient(mLastAccount, getApplicationContext());
332 }
333
334 /// create remote folder for instant uploads
335 if (mCurrentUpload.isRemoteFolderToBeCreated()) {
336 mUploadClient.createDirectory(InstantUploadBroadcastReceiver.INSTANT_UPLOAD_DIR); // ignoring result; fail could just mean that it already exists, but local database is not synchronized; the upload will be tried anyway
337 }
338
339
340 /// perform the upload
341 RemoteOperationResult uploadResult = null;
342 try {
343 uploadResult = mCurrentUpload.execute(mUploadClient);
344 if (uploadResult.isSuccess()) {
345 saveUploadedFile(mCurrentUpload.getFile(), mStorageManager);
346 }
347
348 } finally {
349 synchronized(mPendingUploads) {
350 mPendingUploads.remove(uploadKey);
351 }
352 }
353
354 /// notify result
355 notifyUploadResult(uploadResult, mCurrentUpload);
356
357 sendFinalBroadcast(mCurrentUpload, uploadResult);
358
359 }
360
361 }
362
363 /**
364 * Saves a new OC File after a successful upload.
365 *
366 * @param file OCFile describing the uploaded file
367 * @param storageManager Interface to the database where the new OCFile has to be stored.
368 * @param parentDirId Id of the parent OCFile.
369 */
370 private void saveUploadedFile(OCFile file, FileDataStorageManager storageManager) {
371 file.setModificationTimestamp(System.currentTimeMillis());
372 storageManager.saveFile(file);
373 }
374
375
376 private boolean checkAndFixInstantUploadDirectory(FileDataStorageManager storageManager) {
377 OCFile instantUploadDir = storageManager.getFileByPath(InstantUploadBroadcastReceiver.INSTANT_UPLOAD_DIR);
378 if (instantUploadDir == null) {
379 // first instant upload in the account, or never account not synchronized after the remote InstantUpload folder was created
380 OCFile newDir = new OCFile(InstantUploadBroadcastReceiver.INSTANT_UPLOAD_DIR);
381 newDir.setMimetype("DIR");
382 newDir.setParentId(storageManager.getFileByPath(OCFile.PATH_SEPARATOR).getFileId());
383 storageManager.saveFile(newDir);
384 return true;
385 }
386 return false;
387 }
388
389
390 private OCFile obtainNewOCFileToUpload(String remotePath, String localPath, String mimeType, boolean isInstant, boolean forceOverwrite, FileDataStorageManager storageManager) {
391 OCFile newFile = new OCFile(remotePath);
392 newFile.setStoragePath(localPath);
393 newFile.setLastSyncDate(0);
394 newFile.setKeepInSync(forceOverwrite);
395
396 // size
397 if (localPath != null && localPath.length() > 0) {
398 File localFile = new File(localPath);
399 newFile.setFileLength(localFile.length());
400 } // don't worry about not assigning size, the problems with localPath are checked when the UploadFileOperation instance is created
401
402 // MIME type
403 if (mimeType == null || mimeType.length() <= 0) {
404 try {
405 mimeType = MimeTypeMap.getSingleton()
406 .getMimeTypeFromExtension(
407 remotePath.substring(remotePath.lastIndexOf('.') + 1));
408 } catch (IndexOutOfBoundsException e) {
409 Log.e(TAG, "Trying to find out MIME type of a file without extension: " + remotePath);
410 }
411 }
412 if (mimeType == null) {
413 mimeType = "application/octet-stream";
414 }
415 newFile.setMimetype(mimeType);
416
417 // parent dir
418 String parentPath = new File(remotePath).getParent();
419 parentPath = parentPath.endsWith("/")?parentPath:parentPath+"/" ;
420 OCFile parentDir = storageManager.getFileByPath(parentPath);
421 if (parentDir == null) {
422 throw new IllegalStateException("Can not upload a file to a non existing remote location: " + parentPath);
423 }
424 long parentDirId = parentDir.getFileId();
425 newFile.setParentId(parentDirId);
426 return newFile;
427 }
428
429
430 /**
431 * Creates a status notification to show the upload progress
432 *
433 * @param upload Upload operation starting.
434 */
435 private void notifyUploadStart(UploadFileOperation upload) {
436 /// create status notification with a progress bar
437 mLastPercent = 0;
438 mNotification = new Notification(R.drawable.icon, getString(R.string.uploader_upload_in_progress_ticker), System.currentTimeMillis());
439 mNotification.flags |= Notification.FLAG_ONGOING_EVENT;
440 mDefaultNotificationContentView = mNotification.contentView;
441 mNotification.contentView = new RemoteViews(getApplicationContext().getPackageName(), R.layout.progressbar_layout);
442 mNotification.contentView.setProgressBar(R.id.status_progress, 100, 0, false);
443 mNotification.contentView.setTextViewText(R.id.status_text, String.format(getString(R.string.uploader_upload_in_progress_content), 0, new File(upload.getStoragePath()).getName()));
444 mNotification.contentView.setImageViewResource(R.id.status_icon, R.drawable.icon);
445
446 /// includes a pending intent in the notification showing the details view of the file
447 Intent showDetailsIntent = new Intent(this, FileDetailActivity.class);
448 showDetailsIntent.putExtra(FileDetailFragment.EXTRA_FILE, upload.getFile());
449 showDetailsIntent.putExtra(FileDetailFragment.EXTRA_ACCOUNT, upload.getAccount());
450 showDetailsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
451 mNotification.contentIntent = PendingIntent.getActivity(getApplicationContext(), (int)System.currentTimeMillis(), showDetailsIntent, 0);
452
453 mNotificationManager.notify(R.string.uploader_upload_in_progress_ticker, mNotification);
454 }
455
456
457 /**
458 * Callback method to update the progress bar in the status notification
459 */
460 @Override
461 public void onTransferProgress(long progressRate, long totalTransferredSoFar, long totalToTransfer, String fileName) {
462 int percent = (int)(100.0*((double)totalTransferredSoFar)/((double)totalToTransfer));
463 if (percent != mLastPercent) {
464 mNotification.contentView.setProgressBar(R.id.status_progress, 100, percent, false);
465 String text = String.format(getString(R.string.uploader_upload_in_progress_content), percent, fileName);
466 mNotification.contentView.setTextViewText(R.id.status_text, text);
467 mNotificationManager.notify(R.string.uploader_upload_in_progress_ticker, mNotification);
468 }
469 mLastPercent = percent;
470 }
471
472
473 /**
474 * Callback method to update the progress bar in the status notification (old version)
475 */
476 @Override
477 public void onTransferProgress(long progressRate) {
478 // NOTHING TO DO HERE ANYMORE
479 }
480
481
482 /**
483 * Updates the status notification with the result of an upload operation.
484 *
485 * @param uploadResult Result of the upload operation.
486 * @param upload Finished upload operation
487 */
488 private void notifyUploadResult(RemoteOperationResult uploadResult, UploadFileOperation upload) {
489 if (uploadResult.isCancelled()) {
490 /// cancelled operation -> silent removal of progress notification
491 mNotificationManager.cancel(R.string.uploader_upload_in_progress_ticker);
492
493 } else if (uploadResult.isSuccess()) {
494 /// success -> silent update of progress notification to success message
495 mNotification.flags ^= Notification.FLAG_ONGOING_EVENT; // remove the ongoing flag
496 mNotification.flags |= Notification.FLAG_AUTO_CANCEL;
497 mNotification.contentView = mDefaultNotificationContentView;
498
499 /// includes a pending intent in the notification showing the details view of the file
500 Intent showDetailsIntent = new Intent(this, FileDetailActivity.class);
501 showDetailsIntent.putExtra(FileDetailFragment.EXTRA_FILE, upload.getFile());
502 showDetailsIntent.putExtra(FileDetailFragment.EXTRA_ACCOUNT, upload.getAccount());
503 showDetailsIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
504 mNotification.contentIntent = PendingIntent.getActivity(getApplicationContext(), (int)System.currentTimeMillis(), showDetailsIntent, 0);
505
506 mNotification.setLatestEventInfo( getApplicationContext(),
507 getString(R.string.uploader_upload_succeeded_ticker),
508 String.format(getString(R.string.uploader_upload_succeeded_content_single), (new File(upload.getStoragePath())).getName()),
509 mNotification.contentIntent);
510
511 mNotificationManager.notify(R.string.uploader_upload_in_progress_ticker, mNotification); // NOT AN ERROR; uploader_upload_in_progress_ticker is the target, not a new notification
512
513 /* Notification about multiple uploads: pending of update
514 mNotification.setLatestEventInfo( getApplicationContext(),
515 getString(R.string.uploader_upload_succeeded_ticker),
516 String.format(getString(R.string.uploader_upload_succeeded_content_multiple), mSuccessCounter),
517 mNotification.contentIntent);
518 */
519
520 } else {
521 /// fail -> explicit failure notification
522 mNotificationManager.cancel(R.string.uploader_upload_in_progress_ticker);
523 Notification finalNotification = new Notification(R.drawable.icon, getString(R.string.uploader_upload_failed_ticker), System.currentTimeMillis());
524 finalNotification.flags |= Notification.FLAG_AUTO_CANCEL;
525 // TODO put something smart in the contentIntent below
526 finalNotification.contentIntent = PendingIntent.getActivity(getApplicationContext(), (int)System.currentTimeMillis(), new Intent(), 0);
527 finalNotification.setLatestEventInfo( getApplicationContext(),
528 getString(R.string.uploader_upload_failed_ticker),
529 String.format(getString(R.string.uploader_upload_failed_content_single), (new File(upload.getStoragePath())).getName()),
530 finalNotification.contentIntent);
531
532 mNotificationManager.notify(R.string.uploader_upload_failed_ticker, finalNotification);
533
534 /* Notification about multiple uploads failure: pending of update
535 finalNotification.setLatestEventInfo( getApplicationContext(),
536 getString(R.string.uploader_upload_failed_ticker),
537 String.format(getString(R.string.uploader_upload_failed_content_multiple), mSuccessCounter, mTotalFilesToSend),
538 finalNotification.contentIntent);
539 } */
540 }
541
542 }
543
544
545 /**
546 * Sends a broadcast in order to the interested activities can update their view
547 *
548 * @param upload Finished upload operation
549 * @param uploadResult Result of the upload operation
550 */
551 private void sendFinalBroadcast(UploadFileOperation upload, RemoteOperationResult uploadResult) {
552 Intent end = new Intent(UPLOAD_FINISH_MESSAGE);
553 end.putExtra(EXTRA_REMOTE_PATH, upload.getRemotePath()); // real remote path, after possible automatic renaming
554 end.putExtra(EXTRA_FILE_PATH, upload.getStoragePath());
555 end.putExtra(ACCOUNT_NAME, upload.getAccount().name);
556 end.putExtra(EXTRA_UPLOAD_RESULT, uploadResult.isSuccess());
557 end.putExtra(EXTRA_PARENT_DIR_ID, upload.getFile().getParentId());
558 sendBroadcast(end);
559 }
560
561
562 }