/* * This source is part of the * _____ ___ ____ * __ / / _ \/ _ | / __/___ _______ _ * / // / , _/ __ |/ _/_/ _ \/ __/ _ `/ * \___/_/|_/_/ |_/_/ (_)___/_/ \_, / * /___/ * repository. * * Copyright (C) 2015 Benoit 'BoD' Lubek (BoD@JRAF.org) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.jraf.android.bikey.backend.googledrive; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.concurrent.TimeUnit; import android.content.ContentUris; import android.content.Context; import android.net.Uri; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; import org.jraf.android.bikey.backend.dbimport.BikeyRideImporter; import org.jraf.android.bikey.backend.dbimport.RideImporterProgressListener; import org.jraf.android.bikey.backend.export.bikey.BikeyExporter; import org.jraf.android.bikey.backend.provider.ride.RideColumns; import org.jraf.android.bikey.backend.provider.ride.RideCursor; import org.jraf.android.bikey.backend.provider.ride.RideSelection; import org.jraf.android.bikey.backend.provider.ride.RideState; import org.jraf.android.util.io.IoUtil; import org.jraf.android.util.log.Log; import com.google.android.gms.common.api.GoogleApiClient; 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.Metadata; import com.google.android.gms.drive.MetadataBuffer; import com.google.android.gms.drive.MetadataChangeSet; import com.google.android.gms.drive.metadata.CustomPropertyKey; import com.google.android.gms.drive.query.Query; public class GoogleDriveSyncManager { private static final GoogleDriveSyncManager INSTANCE = new GoogleDriveSyncManager(); private static final long AWAIT_DELAY_SHORT = 10; private static final TimeUnit AWAIT_UNIT_SHORT = TimeUnit.SECONDS; private static final long AWAIT_DELAY_LONG = 4; private static final TimeUnit AWAIT_UNIT_LONG = TimeUnit.MINUTES; private static final String EXTENSION = ".ride"; private static final String MIME_TYPE = "application/vnd.jraf.bikey.ride"; static final String PROPERTY_TRASHED = "trashed"; static final String PROPERTY_TRASHED_TRUE = "true"; private Context mContext; private @Nullable GoogleDriveSyncListener mListener; private volatile boolean mAbortRequested; public static GoogleDriveSyncManager get(Context context) { INSTANCE.mContext = context.getApplicationContext(); return INSTANCE; } private GoogleDriveSyncManager() {} @WorkerThread public boolean sync(GoogleApiClient googleApiClient, @Nullable GoogleDriveSyncListener googleDriveSyncListener) { mAbortRequested = false; mListener = googleDriveSyncListener; if (mListener != null) mListener.onSyncStart(); if (mListener != null) mListener.onDeleteRemoteItemsStart(); Log.d("Get server list"); ArrayList<ServerItem> serverItems = getServerItems(googleApiClient); Log.d("serverItems=" + serverItems); if (serverItems == null) { Log.d("Got null serverItems: abort"); if (mListener != null) mListener.onSyncFinish(false); return false; } // serverDeleteAllItems(googleApiClient, serverItems); // return true; if (mAbortRequested) { if (mListener != null) mListener.onSyncFinish(false); return false; } Log.d("Get locally deleted items"); ArrayList<String> locallyDeletedItems = getLocallyDeletedItems(); Log.d("locallyDeletedItems=" + locallyDeletedItems); if (!locallyDeletedItems.isEmpty()) { Log.d("Delete locally deleted items from the server"); boolean ok = serverTrashItems(googleApiClient, locallyDeletedItems, serverItems); Log.d("ok=" + ok); if (ok) { Log.d("Purge locally deleted items"); purgeLocallyDeletedItems(); } } if (mListener != null) mListener.onDeleteRemoteItemsFinish(); if (mAbortRequested) { if (mListener != null) mListener.onSyncFinish(false); return false; } if (mListener != null) mListener.onDeleteLocalItemsStart(); Log.d("Delete local items that are marked as deleted on the server"); locallyDeleteRemotelyDeletedItems(serverItems); if (mListener != null) mListener.onDeleteLocalItemsFinish(); if (mAbortRequested) { if (mListener != null) mListener.onSyncFinish(false); return false; } if (mListener != null) mListener.onUploadNewLocalItemsStart(); Log.d("Get new local items"); ArrayList<String> newLocalItems = getNewLocalItems(serverItems); Log.d("newLocalItems=" + newLocalItems); Log.d("Upload new local items to the server"); boolean ok = uploadNewLocalItems(googleApiClient, newLocalItems); Log.d("ok=" + ok); if (mListener != null) mListener.onUploadNewLocalItemsFinish(); if (mAbortRequested) { if (mListener != null) mListener.onSyncFinish(false); return false; } if (mListener != null) mListener.onDownloadNewRemoteItemsStart(); Log.d("Get new server items"); ArrayList<String> allLocalItems = getAllLocalItems(); // ArrayList<String> allLocalItems = newLocalItems; // Uncomment to test ArrayList<ServerItem> newServerItems = getNewServerItems(serverItems, allLocalItems); Log.d("newServerItems=" + newServerItems); if (!newServerItems.isEmpty()) { Log.d("Download new server items"); ok = ok && downloadNewServerItems(googleApiClient, newServerItems); Log.d("ok=" + ok); } if (mListener != null) mListener.onDownloadNewRemoteItemsFinish(); if (mListener != null) mListener.onSyncFinish(ok); Log.d("Sync finished"); return ok; } @WorkerThread @Nullable public ArrayList<ServerItem> getServerItems(GoogleApiClient googleApiClient) { Log.d(); Query query = new Query.Builder().build(); DriveApi.MetadataBufferResult metadataBufferResult = Drive.DriveApi.getAppFolder(googleApiClient).queryChildren(googleApiClient, query).await(AWAIT_DELAY_SHORT, AWAIT_UNIT_SHORT); Status status = metadataBufferResult.getStatus(); Log.d("status=" + status); if (!status.isSuccess()) { Log.w("Could not query app folder"); metadataBufferResult.release(); return null; } MetadataBuffer metadataBuffer = metadataBufferResult.getMetadataBuffer(); int count = metadataBuffer.getCount(); Log.d("count=" + count); ArrayList<ServerItem> res = new ArrayList<>(count); for (Metadata metadata : metadataBuffer) { res.add(new ServerItem(metadata)); } metadataBufferResult.release(); Log.d(res.toString()); return res; } @WorkerThread private ArrayList<String> getLocallyDeletedItems() { Log.d(); RideSelection rideSelection = new RideSelection(); rideSelection.state(RideState.DELETED); RideCursor c = rideSelection.query(mContext); ArrayList<String> res = new ArrayList<>(); while (c.moveToNext()) { res.add(c.getUuid()); } c.close(); return res; } @WorkerThread private boolean serverTrashItems(GoogleApiClient googleApiClient, ArrayList<String> locallyDeletedItems, ArrayList<ServerItem> serverItems) { // Find the server items to trash ArrayList<ServerItem> serverItemsToTrash = new ArrayList<>(); // FIXME: Double iteration! Bad perf! for (String locallyDeletedItem : locallyDeletedItems) { for (ServerItem serverItem : serverItems) { if (serverItem.uuid.equals(locallyDeletedItem)) { serverItemsToTrash.add(serverItem); break; } } } Log.d("Server items to trash: " + serverItemsToTrash); for (ServerItem serverItem : serverItemsToTrash) { Log.d("Trash " + serverItem); DriveFile driveFile = Drive.DriveApi.getFile(googleApiClient, serverItem.driveId); // Mark the file as trashed (by setting a property) CustomPropertyKey key = new CustomPropertyKey(PROPERTY_TRASHED, CustomPropertyKey.PRIVATE); MetadataChangeSet changeSet = new MetadataChangeSet.Builder() .setCustomProperty(key, PROPERTY_TRASHED_TRUE).build(); Status status = driveFile.updateMetadata(googleApiClient, changeSet).await(AWAIT_DELAY_SHORT, AWAIT_UNIT_SHORT).getStatus(); Log.d("status=" + status); if (!status.isSuccess()) { Log.w("Could not mark as trashed " + serverItem); return false; } serverItem.deleted = true; } return true; } @WorkerThread private boolean serverDeleteAllItems(GoogleApiClient googleApiClient, ArrayList<ServerItem> serverItemsToDelete) { Log.d("Server items to delete: " + serverItemsToDelete); for (ServerItem serverItem : serverItemsToDelete) { Log.d("Delete " + serverItem); DriveFile driveFile = Drive.DriveApi.getFile(googleApiClient, serverItem.driveId); // Mark the file as trashed Status status = driveFile.delete(googleApiClient).await(AWAIT_DELAY_SHORT, AWAIT_UNIT_SHORT); Log.d("status=" + status); if (!status.isSuccess()) { Log.w("Could not delete " + serverItem); return false; } } return true; } @WorkerThread private void purgeLocallyDeletedItems() { Log.d(); RideSelection rideSelection = new RideSelection(); rideSelection.state(RideState.DELETED); rideSelection.delete(mContext); } @WorkerThread private void locallyDeleteRemotelyDeletedItems(ArrayList<ServerItem> serverItems) { Log.d(); ArrayList<String> uuidsToDelete = new ArrayList<>(serverItems.size()); for (ServerItem serverItem : serverItems) { if (serverItem.deleted) uuidsToDelete.add(serverItem.uuid); } Log.d("uuidsToDelete=" + uuidsToDelete); if (uuidsToDelete.size() > 0) { RideSelection rideSelection = new RideSelection(); rideSelection.uuid(uuidsToDelete.toArray(new String[uuidsToDelete.size()])); rideSelection.delete(mContext); } } @WorkerThread private ArrayList<String> getNewLocalItems(ArrayList<ServerItem> serverItems) { Log.d(); RideSelection rideSelection = new RideSelection(); rideSelection.stateNot(RideState.DELETED); RideCursor c = rideSelection.query(mContext); ArrayList<String> res = new ArrayList<>(); // FIXME: Double iteration! Bad perf! while (c.moveToNext()) { String uuid = c.getUuid(); boolean existsOnServer = false; for (ServerItem serverItem : serverItems) { if (serverItem.uuid.equals(uuid)) { // Already exists on the server! Skip it existsOnServer = true; break; } } if (!existsOnServer) res.add(uuid); } c.close(); return res; } @WorkerThread private boolean uploadNewLocalItems(GoogleApiClient googleApiClient, ArrayList<String> newLocalItems) { Log.d(); int itemsCount = newLocalItems.size(); int itemIndex = 0; for (String uuid : newLocalItems) { if (mListener != null) mListener.onUploadNewLocalItemsProgress(itemIndex, itemsCount); Log.d("Creating " + uuid); DriveApi.DriveContentsResult driveContentsResult = Drive.DriveApi.newDriveContents(googleApiClient).await(AWAIT_DELAY_LONG, AWAIT_UNIT_LONG); Status status = driveContentsResult.getStatus(); Log.d("driveContentsResult.status=" + status); if (!status.isSuccess()) { Log.w("Could not create new Drive contents"); return false; } RideSelection rideSelection = new RideSelection(); rideSelection.uuid(uuid); RideCursor rideCursor = rideSelection.query(mContext); rideCursor.moveToFirst(); Uri rideUri = ContentUris.withAppendedId(RideColumns.CONTENT_URI, rideCursor.getId()); rideCursor.close(); Log.d("rideUri=" + rideUri); DriveContents driveContents = driveContentsResult.getDriveContents(); OutputStream outputStream = driveContents.getOutputStream(); BikeyExporter exporter = new BikeyExporter(rideUri); exporter.setOutputStream(outputStream); try { exporter.export(); outputStream.flush(); IoUtil.closeSilently(outputStream); } catch (IOException e) { Log.w("Could not export to Drive contents", e); return false; } MetadataChangeSet changeSet = new MetadataChangeSet.Builder() .setTitle(uuid + EXTENSION) .setMimeType(MIME_TYPE).build(); DriveFolder.DriveFileResult driveFileResult = Drive.DriveApi.getAppFolder(googleApiClient).createFile(googleApiClient, changeSet, driveContents).await(AWAIT_DELAY_LONG, AWAIT_UNIT_LONG); status = driveFileResult.getStatus(); Log.d("driveFileResult.status=" + status); if (!status.isSuccess()) { Log.w("Could not create new Drive file"); return false; } itemIndex++; if (mListener != null) mListener.onUploadNewLocalItemsProgress(itemIndex, itemsCount); if (mAbortRequested) { if (mListener != null) mListener.onSyncFinish(false); return false; } } return true; } @WorkerThread private ArrayList<String> getAllLocalItems() { Log.d(); RideSelection rideSelection = new RideSelection(); rideSelection.stateNot(RideState.DELETED); RideCursor c = rideSelection.query(mContext); ArrayList<String> res = new ArrayList<>(); while (c.moveToNext()) { res.add(c.getUuid()); } c.close(); return res; } private ArrayList<ServerItem> getNewServerItems(ArrayList<ServerItem> serverItems, ArrayList<String> allLocalItems) { ArrayList<ServerItem> res = new ArrayList<>(); // FIXME: Double iteration! Bad perf! for (ServerItem serverItem : serverItems) { if (serverItem.deleted) continue; boolean existsLocally = false; for (String localItem : allLocalItems) { if (serverItem.uuid.equals(localItem)) { // Already exists locally! Skip it existsLocally = true; break; } } if (!existsLocally) res.add(serverItem); } return res; } @WorkerThread private boolean downloadNewServerItems(GoogleApiClient googleApiClient, ArrayList<ServerItem> newServerItems) { Log.d(); int itemsCount = newServerItems.size(); int itemIndex = 0; for (ServerItem serverItem : newServerItems) { if (mListener != null) mListener.onDownloadNewRemoteItemsOverallProgress(itemIndex, itemsCount); Log.d("Download " + serverItem); DriveFile driveFile = Drive.DriveApi.getFile(googleApiClient, serverItem.driveId); DriveApi.DriveContentsResult driveContentsResult = driveFile.open(googleApiClient, DriveFile.MODE_READ_ONLY, (bytesDownloaded, bytesExpected) -> Log.d(bytesDownloaded + "/" + bytesExpected)).await(AWAIT_DELAY_LONG, AWAIT_UNIT_LONG); Status status = driveContentsResult.getStatus(); Log.d("driveContentsResult.status=" + status); if (!status.isSuccess()) { Log.w("Could not open Drive contents"); return false; } DriveContents contents = driveContentsResult.getDriveContents(); InputStream inputStream = contents.getInputStream(); RideImporterProgressListener rideImporterProgressListener = new RideImporterProgressListener() { @Override public void onImportStarted() { Log.d(); } @Override public void onLogImported(long logIndex, long total) { Log.d(logIndex + "/" + total); if (mListener != null) mListener.onDownloadNewRemoteItemsDownloadProgress(logIndex, total); } @Override public void onImportFinished(LogImportStatus status) { Log.d("status=" + status); } }; if (mAbortRequested) { if (mListener != null) mListener.onSyncFinish(false); return false; } try { new BikeyRideImporter(mContext.getContentResolver(), inputStream, rideImporterProgressListener).doImport(); } catch (Exception e) { Log.w("Could not parse or read Drive contents", e); contents.discard(googleApiClient); return false; } contents.discard(googleApiClient); itemIndex++; if (mListener != null) mListener.onDownloadNewRemoteItemsOverallProgress(itemIndex, itemsCount); if (mAbortRequested) { if (mListener != null) mListener.onSyncFinish(false); return false; } } return true; } public void abort() { mAbortRequested = true; } }