package co.smartreceipts.android.sync.drive.rx; import android.content.Context; import android.net.Uri; import android.support.annotation.NonNull; import android.text.TextUtils; import com.google.android.gms.common.api.GoogleApiClient; import com.google.android.gms.common.api.ResultCallbacks; import com.google.android.gms.common.api.Status; import com.google.android.gms.drive.Drive; import com.google.android.gms.drive.DriveApi; import com.google.android.gms.drive.DriveContents; import com.google.android.gms.drive.DriveFile; import com.google.android.gms.drive.DriveFolder; import com.google.android.gms.drive.DriveId; import com.google.android.gms.drive.DriveResource; import com.google.android.gms.drive.ExecutionOptions; import com.google.android.gms.drive.Metadata; import com.google.android.gms.drive.MetadataChangeSet; import com.google.android.gms.drive.metadata.CustomPropertyKey; import com.google.android.gms.drive.query.Filters; import com.google.android.gms.drive.query.Query; import com.google.android.gms.drive.query.SearchableField; import com.google.common.base.Preconditions; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; import co.smartreceipts.android.persistence.DatabaseHelper; import co.smartreceipts.android.sync.drive.device.DeviceMetadata; import co.smartreceipts.android.sync.drive.device.GoogleDriveSyncMetadata; import co.smartreceipts.android.sync.drive.services.DriveIdUploadCompleteCallback; import co.smartreceipts.android.sync.drive.services.DriveIdUploadMetadata; import co.smartreceipts.android.sync.drive.services.DriveUploadCompleteManager; import co.smartreceipts.android.sync.model.RemoteBackupMetadata; import co.smartreceipts.android.sync.model.impl.DefaultRemoteBackupMetadata; import co.smartreceipts.android.sync.model.impl.Identifier; import co.smartreceipts.android.utils.UriUtils; import co.smartreceipts.android.utils.log.Logger; import io.reactivex.Observable; import io.reactivex.Single; import io.reactivex.subjects.ReplaySubject; import wb.android.storage.StorageManager; class DriveDataStreams { private static final String SMART_RECEIPTS_FOLDER = "Smart Receipts"; private static final CustomPropertyKey SMART_RECEIPTS_FOLDER_KEY = new CustomPropertyKey("smart_receipts_id", CustomPropertyKey.PUBLIC); private final GoogleApiClient mGoogleApiClient; private final GoogleDriveSyncMetadata mGoogleDriveSyncMetadata; private final Context mContext; private final DeviceMetadata mDeviceMetadata; private final DriveUploadCompleteManager mDriveUploadCompleteManager; private final Executor mExecutor; private ReplaySubject<DriveFolder> mSmartReceiptsFolderSubject; public DriveDataStreams(@NonNull Context context, @NonNull GoogleApiClient googleApiClient, @NonNull GoogleDriveSyncMetadata googleDriveSyncMetadata, @NonNull DriveUploadCompleteManager driveUploadCompleteManager) { this(googleApiClient, context, googleDriveSyncMetadata, new DeviceMetadata(context), driveUploadCompleteManager, Executors.newCachedThreadPool()); } public DriveDataStreams(@NonNull GoogleApiClient googleApiClient, @NonNull Context context, @NonNull GoogleDriveSyncMetadata googleDriveSyncMetadata, @NonNull DeviceMetadata deviceMetadata, @NonNull DriveUploadCompleteManager driveUploadCompleteManager, @NonNull Executor executor) { mGoogleApiClient = Preconditions.checkNotNull(googleApiClient); mContext = Preconditions.checkNotNull(context.getApplicationContext()); mGoogleDriveSyncMetadata = Preconditions.checkNotNull(googleDriveSyncMetadata); mDeviceMetadata = Preconditions.checkNotNull(deviceMetadata); mDriveUploadCompleteManager = Preconditions.checkNotNull(driveUploadCompleteManager); mExecutor = Preconditions.checkNotNull(executor); } public synchronized Single<List<RemoteBackupMetadata>> getSmartReceiptsFolders() { return Single.create(emitter -> { final Query folderQuery = new Query.Builder().addFilter(Filters.eq(SearchableField.TITLE, SMART_RECEIPTS_FOLDER)).build(); Drive.DriveApi.query(mGoogleApiClient, folderQuery).setResultCallback(new ResultCallbacks<DriveApi.MetadataBufferResult>() { @Override public void onSuccess(@NonNull DriveApi.MetadataBufferResult metadataBufferResult) { try { final List<Metadata> folderMetadataList = new ArrayList<>(); for (final Metadata metadata : metadataBufferResult.getMetadataBuffer()) { if (isValidSmartReceiptsFolder(metadata)) { folderMetadataList.add(metadata); } } final AtomicInteger resultsCount = new AtomicInteger(folderMetadataList.size()); final List<RemoteBackupMetadata> resultsList = new ArrayList<>(); if (resultsCount.get() == 0) { emitter.onSuccess(resultsList); } else { final Query databaseQuery = new Query.Builder().addFilter(Filters.eq(SearchableField.TITLE, DatabaseHelper.DATABASE_NAME)).build(); for (final Metadata metadata : folderMetadataList) { final Identifier driveFolderId = new Identifier(metadata.getDriveId().getResourceId()); final Map<CustomPropertyKey, String> customPropertyMap = metadata.getCustomProperties(); if (customPropertyMap != null && customPropertyMap.containsKey(SMART_RECEIPTS_FOLDER_KEY)) { final Identifier syncDeviceIdentifier = new Identifier(customPropertyMap.get(SMART_RECEIPTS_FOLDER_KEY)); final String deviceName = metadata.getDescription() != null ? metadata.getDescription() : ""; final Date parentFolderLastModifiedDate = metadata.getModifiedDate(); metadata.getDriveId().asDriveFolder().queryChildren(mGoogleApiClient, databaseQuery).setResultCallback(new ResultCallbacks<DriveApi.MetadataBufferResult>() { @Override public void onSuccess(@NonNull DriveApi.MetadataBufferResult metadataBufferResult) { try { Date lastModifiedDate = parentFolderLastModifiedDate; for (final Metadata databaseMetadata : metadataBufferResult.getMetadataBuffer()) { if (databaseMetadata.getModifiedDate().getTime() > lastModifiedDate.getTime()) { lastModifiedDate = databaseMetadata.getModifiedDate(); } } resultsList.add(new DefaultRemoteBackupMetadata(driveFolderId, syncDeviceIdentifier, deviceName, lastModifiedDate)); } finally { metadataBufferResult.getMetadataBuffer().release(); if (resultsCount.decrementAndGet() == 0) { emitter.onSuccess(resultsList); } } } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to query a database within the parent folder: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } }); } else { Logger.error(DriveDataStreams.this, "Found an invalid Smart Receipts folder. Skipping"); } } } } finally { metadataBufferResult.getMetadataBuffer().release(); } } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to query a Smart Receipts folder with status: " + status); emitter.onError(new IOException(status.getStatusMessage())); } }); }); } public synchronized Observable<DriveFolder> getSmartReceiptsFolder() { if (mSmartReceiptsFolderSubject == null) { Logger.info(this, "Creating new replay subject for the Smart Receipts folder"); mSmartReceiptsFolderSubject = ReplaySubject.create(); Single.<DriveFolder>create(emitter -> { final Query folderQuery = new Query.Builder().addFilter(Filters.eq(SMART_RECEIPTS_FOLDER_KEY, mGoogleDriveSyncMetadata.getDeviceIdentifier().getId())).build(); Drive.DriveApi.query(mGoogleApiClient, folderQuery).setResultCallback(new ResultCallbacks<DriveApi.MetadataBufferResult>() { @Override public void onSuccess(@NonNull DriveApi.MetadataBufferResult metadataBufferResult) { try { DriveId folderId = null; for (final Metadata metadata : metadataBufferResult.getMetadataBuffer()) { if (isValidSmartReceiptsFolder(metadata)) { folderId = metadata.getDriveId(); break; } } if (folderId != null) { Logger.info(DriveDataStreams.this, "Found an existing Google Drive folder for Smart Receipts"); emitter.onSuccess(folderId.asDriveFolder()); } else { Logger.info(DriveDataStreams.this, "Failed to find an existing Smart Receipts folder for this device. Creating a new one..."); final MetadataChangeSet changeSet = new MetadataChangeSet.Builder().setTitle(SMART_RECEIPTS_FOLDER).setDescription(mDeviceMetadata.getDeviceName()).setCustomProperty(SMART_RECEIPTS_FOLDER_KEY, mGoogleDriveSyncMetadata.getDeviceIdentifier().getId()).build(); Drive.DriveApi.getAppFolder(mGoogleApiClient).createFolder(mGoogleApiClient, changeSet).setResultCallback(new ResultCallbacks<DriveFolder.DriveFolderResult>() { @Override public void onSuccess(@NonNull DriveFolder.DriveFolderResult driveFolderResult) { emitter.onSuccess(driveFolderResult.getDriveFolder()); } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to create a home folder with status: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } }); } } finally { metadataBufferResult.getMetadataBuffer().release(); } } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to query a Smart Receipts folder with status: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } }); }) .toObservable() .subscribe(mSmartReceiptsFolderSubject); } return mSmartReceiptsFolderSubject; } @NonNull public synchronized Single<DriveId> getDriveId(@NonNull final Identifier identifier) { Preconditions.checkNotNull(identifier); return Single.create(emitter -> Drive.DriveApi.fetchDriveId(mGoogleApiClient, identifier.getId()).setResultCallback(new ResultCallbacks<DriveApi.DriveIdResult>() { @Override public void onSuccess(@NonNull DriveApi.DriveIdResult driveIdResult) { final DriveId driveId = driveIdResult.getDriveId(); Logger.debug(DriveDataStreams.this, "Successfully fetch file with id: {}", driveId); emitter.onSuccess(driveId); } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to fetch file with status: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } })); } @NonNull public synchronized Observable<DriveId> getFilesInFolder(@NonNull final DriveFolder driveFolder) { Preconditions.checkNotNull(driveFolder); return Observable.create(emitter -> { final Query folderQuery = new Query.Builder().build(); driveFolder.queryChildren(mGoogleApiClient, folderQuery).setResultCallback(new ResultCallbacks<DriveApi.MetadataBufferResult>() { @Override public void onSuccess(@NonNull DriveApi.MetadataBufferResult metadataBufferResult) { try { for (final Metadata metadata : metadataBufferResult.getMetadataBuffer()) { if (!metadata.isTrashed()) { emitter.onNext(metadata.getDriveId()); } } emitter.onComplete(); } finally { metadataBufferResult.getMetadataBuffer().release(); } } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to query files in folder with status: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } }); }); } @NonNull public synchronized Observable<DriveId> getFilesInFolder(@NonNull final DriveFolder driveFolder, @NonNull final String fileName) { Preconditions.checkNotNull(driveFolder); Preconditions.checkNotNull(fileName); return Observable.create(emitter -> { final Query folderQuery = new Query.Builder().addFilter(Filters.eq(SearchableField.TITLE, fileName)).build(); driveFolder.queryChildren(mGoogleApiClient, folderQuery).setResultCallback(new ResultCallbacks<DriveApi.MetadataBufferResult>() { @Override public void onSuccess(@NonNull DriveApi.MetadataBufferResult metadataBufferResult) { try { for (final Metadata metadata : metadataBufferResult.getMetadataBuffer()) { if (!metadata.isTrashed()) { emitter.onNext(metadata.getDriveId()); } } emitter.onComplete(); } finally { metadataBufferResult.getMetadataBuffer().release(); } } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to query files in folder with status: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } }); }); } @NonNull public synchronized Single<Metadata> getMetadata(@NonNull final DriveFile driveFile) { Preconditions.checkNotNull(driveFile); return Single.create(emitter -> { driveFile.getMetadata(mGoogleApiClient).setResultCallback(new ResultCallbacks<DriveResource.MetadataResult>() { @Override public void onSuccess(@NonNull DriveResource.MetadataResult metadataResult) { emitter.onSuccess(metadataResult.getMetadata()); } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to query files in folder with status: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } }); }); } public synchronized Single<DriveFile> createFileInFolder(@NonNull final DriveFolder folder, @NonNull final File file) { Preconditions.checkNotNull(folder); Preconditions.checkNotNull(file); return Single.create(emitter -> { Drive.DriveApi.newDriveContents(mGoogleApiClient).setResultCallback(new ResultCallbacks<DriveApi.DriveContentsResult>() { @Override public void onSuccess(@NonNull final DriveApi.DriveContentsResult driveContentsResult) { mExecutor.execute(() -> { final DriveContents driveContents = driveContentsResult.getDriveContents(); OutputStream outputStream = null; FileInputStream fileInputStream = null; try { outputStream = driveContents.getOutputStream(); fileInputStream = new FileInputStream(file); byte[] buffer = new byte[8192]; int read; while ((read = fileInputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, read); } final Uri uri = Uri.fromFile(file); final String mimeType = UriUtils.getMimeType(uri, mContext.getContentResolver()); final MetadataChangeSet.Builder builder = new MetadataChangeSet.Builder(); builder.setTitle(file.getName()); if (!TextUtils.isEmpty(mimeType)) { builder.setMimeType(mimeType); } final MetadataChangeSet changeSet = builder.build(); final String trackingTag = UUID.randomUUID().toString(); folder.createFile(mGoogleApiClient, changeSet, driveContents, new ExecutionOptions.Builder().setNotifyOnCompletion(true).setTrackingTag(trackingTag).build()).setResultCallback(new ResultCallbacks<DriveFolder.DriveFileResult>() { @Override public void onSuccess(@NonNull DriveFolder.DriveFileResult driveFileResult) { final DriveFile driveFile = driveFileResult.getDriveFile(); final DriveId driveFileId = driveFile.getDriveId(); if (driveFileId.getResourceId() == null) { final DriveIdUploadMetadata uploadMetadata = new DriveIdUploadMetadata(driveFileId, trackingTag); mDriveUploadCompleteManager.registerCallback(uploadMetadata, new DriveIdUploadCompleteCallback() { @Override public void onSuccess(@NonNull DriveId fetchedDriveId) { emitter.onSuccess(fetchedDriveId.asDriveFile()); } @Override public void onFailure(@NonNull DriveId driveId) { emitter.onError(new IOException("Failed to receive a Drive Id")); } }); } } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to create file with status: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } }); } catch (IOException e) { Logger.error(DriveDataStreams.this, "Failed write file with exception: ", e); driveContents.discard(mGoogleApiClient); emitter.onError(e); } finally { StorageManager.closeQuietly(fileInputStream); StorageManager.closeQuietly(outputStream); } }); } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to create file with status: " + status); emitter.onError(new IOException(status.getStatusMessage())); } }); }); } public synchronized Single<DriveFile> updateFile(@NonNull final Identifier driveIdentifier, @NonNull final File file) { Preconditions.checkNotNull(driveIdentifier); Preconditions.checkNotNull(file); return Single.create(emitter -> { Drive.DriveApi.fetchDriveId(mGoogleApiClient, driveIdentifier.getId()).setResultCallback(new ResultCallbacks<DriveApi.DriveIdResult>() { @Override public void onSuccess(@NonNull DriveApi.DriveIdResult driveIdResult) { final DriveId driveId = driveIdResult.getDriveId(); final DriveFile driveFile = driveId.asDriveFile(); driveFile.open(mGoogleApiClient, DriveFile.MODE_WRITE_ONLY, null).setResultCallback(new ResultCallbacks<DriveApi.DriveContentsResult>() { @Override public void onSuccess(@NonNull final DriveApi.DriveContentsResult driveContentsResult) { mExecutor.execute(new Runnable() { @Override public void run() { final DriveContents driveContents = driveContentsResult.getDriveContents(); OutputStream outputStream = null; FileInputStream fileInputStream = null; try { outputStream = driveContents.getOutputStream(); fileInputStream = new FileInputStream(file); byte[] buffer = new byte[8192]; int read; while ((read = fileInputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, read); } driveContents.commit(mGoogleApiClient, null).setResultCallback(new ResultCallbacks<Status>() { @Override public void onSuccess(@NonNull Status status) { emitter.onSuccess(driveFile); } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to updateDriveFile file with status: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } }); } catch (IOException e) { Logger.error(DriveDataStreams.this, "Failed write file with exception: ", e); driveContents.discard(mGoogleApiClient); emitter.onError(e); } finally { StorageManager.closeQuietly(fileInputStream); StorageManager.closeQuietly(outputStream); } } }); } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to updateDriveFile file with status: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } }); } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to fetch drive id {} to updateDriveFile with status: {}", driveIdentifier, status); emitter.onError(new IOException(status.getStatusMessage())); } }); }); } public synchronized Single<Boolean> delete(@NonNull final Identifier driveIdentifier) { Preconditions.checkNotNull(driveIdentifier); final Identifier smartReceiptsFolderId; if (mSmartReceiptsFolderSubject != null && mSmartReceiptsFolderSubject.getValue() != null && mSmartReceiptsFolderSubject.getValue().getDriveId().getResourceId() != null) { smartReceiptsFolderId = new Identifier(mSmartReceiptsFolderSubject.getValue().getDriveId().getResourceId()); } else { smartReceiptsFolderId = null; } if (driveIdentifier.equals(smartReceiptsFolderId)) { Logger.info(DriveDataStreams.this, "Attemping to delete our Smart Receipts folder. Clearing our cached replay result..."); mSmartReceiptsFolderSubject = null; } // Note: (https://developers.google.com/drive/android/trash) If the target of the trash/untrash operation is a folder, all descendants of that folder are similarly trashed or untrashed return Single.create(emitter -> Drive.DriveApi.fetchDriveId(mGoogleApiClient, driveIdentifier.getId()).setResultCallback(new ResultCallbacks<DriveApi.DriveIdResult>() { @Override public void onSuccess(@NonNull DriveApi.DriveIdResult driveIdResult) { final DriveId driveId = driveIdResult.getDriveId(); final DriveResource driveResource = driveId.asDriveResource(); driveResource.delete(mGoogleApiClient).setResultCallback(new ResultCallbacks<Status>() { @Override public void onSuccess(@NonNull Status status) { Logger.info(DriveDataStreams.this, "Successfully deleted resource with status: {}", status); emitter.onSuccess(true); } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to delete resource with status: {}", status); emitter.onSuccess(false); } }); } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to fetch drive id " + driveIdentifier + " to deleteFolder with status: {}", status); emitter.onError(new IOException(status.getStatusMessage())); } })); } public synchronized void clear() { Logger.info(DriveDataStreams.this, "Clearing our cached replay result..."); mSmartReceiptsFolderSubject = null; } public synchronized Single<File> download(@NonNull final DriveFile driveFile, @NonNull final File downloadLocationFile) { Preconditions.checkNotNull(driveFile); Preconditions.checkNotNull(downloadLocationFile); return Single.create(emitter -> driveFile.open(mGoogleApiClient, DriveFile.MODE_READ_ONLY, null).setResultCallback(new ResultCallbacks<DriveApi.DriveContentsResult>() { @Override public void onSuccess(@NonNull final DriveApi.DriveContentsResult driveContentsResult) { mExecutor.execute(() -> { Logger.info(DriveDataStreams.this, "Successfully connected to the drive download stream"); final DriveContents driveContents = driveContentsResult.getDriveContents(); InputStream inputStream = null; FileOutputStream fileOutputStream = null; try { inputStream = driveContents.getInputStream(); fileOutputStream = new FileOutputStream(downloadLocationFile); byte[] buffer = new byte[8192]; int read; while ((read = inputStream.read(buffer)) != -1) { fileOutputStream.write(buffer, 0, read); } driveContents.discard(mGoogleApiClient); emitter.onSuccess(downloadLocationFile); } catch (IOException e) { Logger.error(DriveDataStreams.this, "Failed write file with exception: ", e); driveContents.discard(mGoogleApiClient); emitter.onError(e); } finally { StorageManager.closeQuietly(inputStream); StorageManager.closeQuietly(fileOutputStream); } }); } @Override public void onFailure(@NonNull Status status) { Logger.error(DriveDataStreams.this, "Failed to downloaded the drive resource with status: {}", status); } })); } private boolean isValidSmartReceiptsFolder(@NonNull Metadata metadata) { return metadata.isInAppFolder() && metadata.isFolder() && !metadata.isTrashed(); } }