Java tutorial
package org.totschnig.myexpenses.sync; /* * Copyright 2013 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import android.accounts.Account; import android.accounts.AccountManager; import android.annotation.TargetApi; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.AbstractThreadedSyncAdapter; import android.content.ContentProviderClient; import android.content.ContentProviderOperation; import android.content.ContentProviderResult; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.OperationApplicationException; import android.content.SyncResult; import android.database.Cursor; import android.database.sqlite.SQLiteException; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.RemoteException; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; import android.support.v4.util.Pair; import com.annimon.stream.Collectors; import com.annimon.stream.Exceptional; import com.annimon.stream.Stream; import org.apache.commons.collections4.ListUtils; import org.totschnig.myexpenses.BuildConfig; import org.totschnig.myexpenses.R; import org.totschnig.myexpenses.activity.ManageSyncBackends; import org.totschnig.myexpenses.export.CategoryInfo; import org.totschnig.myexpenses.model.AccountType; import org.totschnig.myexpenses.model.Payee; import org.totschnig.myexpenses.model.PaymentMethod; import org.totschnig.myexpenses.model.SplitTransaction; import org.totschnig.myexpenses.model.Transaction; import org.totschnig.myexpenses.model.Transfer; import org.totschnig.myexpenses.provider.DatabaseConstants; import org.totschnig.myexpenses.provider.TransactionProvider; import org.totschnig.myexpenses.sync.json.ChangeSet; import org.totschnig.myexpenses.sync.json.TransactionChange; import org.totschnig.myexpenses.util.AcraHelper; import org.totschnig.myexpenses.util.NotificationBuilderWrapper; import java.io.IOException; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; import timber.log.Timber; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_ACCOUNTID; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_AMOUNT; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_CATID; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_COMMENT; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_CR_STATUS; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_DATE; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_METHODID; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_PAYEEID; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_PICTURE_URI; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_REFERENCE_NUMBER; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_ROWID; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_SYNC_ACCOUNT_NAME; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_SYNC_SEQUENCE_LOCAL; import static org.totschnig.myexpenses.provider.DatabaseConstants.KEY_UUID; public class SyncAdapter extends AbstractThreadedSyncAdapter { public static final int BATCH_SIZE = 100; public static String KEY_LAST_SYNCED_REMOTE(long accountId) { return "last_synced_remote_" + accountId; } public static final String KEY_LAST_SYNCED_LOCAL(long accountId) { return "last_synced_local_" + accountId; } public static final String KEY_RESET_REMOTE_ACCOUNT = "reset_remote_account"; public static final String KEY_UPLOAD_AUTO_BACKUP = "upload_auto_backup"; private Map<String, Long> categoryToId; private Map<String, Long> payeeToId; private Map<String, Long> methodToId; private Map<String, Long> accountUuidToId; private static final ThreadLocal<org.totschnig.myexpenses.model.Account> dbAccount = new ThreadLocal<>(); public SyncAdapter(Context context, boolean autoInitialize) { super(context, autoInitialize); } @TargetApi(Build.VERSION_CODES.HONEYCOMB) public SyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) { super(context, autoInitialize, allowParallelSyncs); } private String getUserDataWithDefault(AccountManager accountManager, Account account, String key, String defaultValue) { String value = accountManager.getUserData(account, key); return value == null ? defaultValue : value; } @Override public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { categoryToId = new HashMap<>(); payeeToId = new HashMap<>(); methodToId = new HashMap<>(); accountUuidToId = new HashMap<>(); String uuidFromExtras = extras.getString(KEY_UUID); Timber.i("onPerformSync " + extras.toString()); AccountManager accountManager = AccountManager.get(getContext()); Exceptional<SyncBackendProvider> backendProviderExceptional = SyncBackendProviderFactory.get(getContext(), account); SyncBackendProvider backend; try { backend = backendProviderExceptional.getOrThrow(); } catch (Throwable throwable) { syncResult.databaseError = true; AcraHelper.report(throwable instanceof Exception ? ((Exception) throwable) : new Exception(throwable)); GenericAccountService.deactivateSync(account); accountManager.setUserData(account, GenericAccountService.KEY_BROKEN, "1"); String content = String.format(Locale.ROOT, "The backend could not be instantiated.Reason: %s. Please try to delete and recreate it.", throwable.getMessage()); Intent manageIntent = new Intent(getContext(), ManageSyncBackends.class); NotificationBuilderWrapper builder = NotificationBuilderWrapper .defaultBigTextStyleBuilder(getContext(), "Synchronization backend deactivated", content) .setContentIntent(PendingIntent.getActivity(getContext(), 0, manageIntent, PendingIntent.FLAG_CANCEL_CURRENT)); Notification notification = builder.build(); notification.flags = Notification.FLAG_AUTO_CANCEL; ((NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE)).notify(0, notification); return; } if (!backend.setUp()) { syncResult.stats.numIoExceptions++; syncResult.delayUntil = 300; return; } String autoBackupFileUri = extras.getString(KEY_UPLOAD_AUTO_BACKUP); if (autoBackupFileUri != null) { try { backend.storeBackup(Uri.parse(autoBackupFileUri)); } catch (IOException e) { String content = getContext().getString(R.string.auto_backup_cloud_failure, autoBackupFileUri, account.name) + " " + e.getMessage(); Notification notification = NotificationBuilderWrapper.defaultBigTextStyleBuilder(getContext(), getContext().getString(R.string.pref_auto_backup_title), content).build(); notification.flags = Notification.FLAG_AUTO_CANCEL; ((NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE)).notify(0, notification); } return; } Cursor c; String[] selectionArgs; String selection = KEY_SYNC_ACCOUNT_NAME + " = ?"; if (uuidFromExtras != null) { selection += " AND " + KEY_UUID + " = ?"; selectionArgs = new String[] { account.name, uuidFromExtras }; } else { selectionArgs = new String[] { account.name }; } String[] projection = { KEY_ROWID }; try { c = provider.query(TransactionProvider.ACCOUNTS_URI, projection, selection + " AND " + KEY_SYNC_SEQUENCE_LOCAL + " = 0", selectionArgs, null); } catch (RemoteException e) { syncResult.databaseError = true; AcraHelper.report(e); return; } if (c == null) { syncResult.databaseError = true; AcraHelper.report("Cursor is null"); return; } if (c.moveToFirst()) { do { long accountId = c.getLong(0); try { provider.update(buildInitializationUri(accountId), new ContentValues(0), null, null); } catch (RemoteException e) { syncResult.databaseError = true; AcraHelper.report(e); return; } } while (c.moveToNext()); } try { c = provider.query(TransactionProvider.ACCOUNTS_URI, projection, selection, selectionArgs, null); } catch (RemoteException e) { syncResult.databaseError = true; AcraHelper.report(e); return; } if (c != null) { if (c.moveToFirst()) { do { long accountId = c.getLong(0); String lastLocalSyncKey = KEY_LAST_SYNCED_LOCAL(accountId); String lastRemoteSyncKey = KEY_LAST_SYNCED_REMOTE(accountId); long lastSyncedLocal = Long .parseLong(getUserDataWithDefault(accountManager, account, lastLocalSyncKey, "0")); long lastSyncedRemote = Long .parseLong(getUserDataWithDefault(accountManager, account, lastRemoteSyncKey, "0")); dbAccount.set(org.totschnig.myexpenses.model.Account.getInstanceFromDb(accountId)); Timber.i("now syncing " + dbAccount.get().label); if (uuidFromExtras != null && extras.getBoolean(KEY_RESET_REMOTE_ACCOUNT)) { if (!backend.resetAccountData(uuidFromExtras)) { syncResult.stats.numIoExceptions++; Timber.e("error resetting account data"); } continue; } if (!backend.withAccount(dbAccount.get())) { syncResult.stats.numIoExceptions++; Timber.e("error withAccount"); continue; } if (backend.lock()) { try { ChangeSet changeSetSince = backend.getChangeSetSince(lastSyncedRemote, getContext()); if (changeSetSince.isFailed()) { syncResult.stats.numIoExceptions++; Timber.e("error getting changeset"); continue; } List<TransactionChange> remoteChanges; lastSyncedRemote = changeSetSince.sequenceNumber; remoteChanges = changeSetSince.changes; List<TransactionChange> localChanges = new ArrayList<>(); long sequenceToTest = lastSyncedLocal + 1; while (true) { List<TransactionChange> nextChanges = getLocalChanges(provider, accountId, sequenceToTest); if (nextChanges.size() > 0) { localChanges.addAll( Stream.of(nextChanges).filter(change -> !change.isEmpty()).toList()); lastSyncedLocal = sequenceToTest; sequenceToTest++; } else { break; } } if (localChanges.size() == 0 && remoteChanges.size() == 0) { continue; } if (localChanges.size() > 0) { localChanges = collectSplits(localChanges); } Pair<List<TransactionChange>, List<TransactionChange>> mergeResult = mergeChangeSets( localChanges, remoteChanges); localChanges = mergeResult.first; remoteChanges = mergeResult.second; if (remoteChanges.size() > 0) { writeRemoteChangesToDb(provider, Stream.of(remoteChanges) .filter(change -> !(change.isCreate() && uuidExists(change.uuid()))) .toList(), accountId); accountManager.setUserData(account, lastRemoteSyncKey, String.valueOf(lastSyncedRemote)); } if (localChanges.size() > 0) { lastSyncedRemote = backend.writeChangeSet(localChanges, getContext()); if (lastSyncedRemote != ChangeSet.FAILED) { if (!BuildConfig.DEBUG) { // on debug build for auditing purposes, we keep changes in the table provider.delete(TransactionProvider.CHANGES_URI, KEY_ACCOUNTID + " = ? AND " + KEY_SYNC_SEQUENCE_LOCAL + " <= ?", new String[] { String.valueOf(accountId), String.valueOf(lastSyncedLocal) }); } accountManager.setUserData(account, lastLocalSyncKey, String.valueOf(lastSyncedLocal)); accountManager.setUserData(account, lastRemoteSyncKey, String.valueOf(lastSyncedRemote)); } } } catch (IOException e) { Timber.e(e, "Error while syncing "); syncResult.stats.numIoExceptions++; } catch (RemoteException | OperationApplicationException | SQLiteException e) { Timber.e(e, "Error while syncing "); syncResult.databaseError = true; AcraHelper.report(e); } finally { if (!backend.unlock()) { Timber.e("Unlocking backend failed"); syncResult.stats.numIoExceptions++; } } } else { //TODO syncResult.delayUntil = ??? syncResult.stats.numIoExceptions++; } } while (c.moveToNext()); } c.close(); } backend.tearDown(); } private List<TransactionChange> getLocalChanges(ContentProviderClient provider, long accountId, long sequenceNumber) throws RemoteException { List<TransactionChange> result = new ArrayList<>(); Uri changesUri = buildChangesUri(sequenceNumber, accountId); boolean hasLocalChanges = hasLocalChanges(provider, changesUri); if (hasLocalChanges) { ContentValues currentSyncIncrease = new ContentValues(1); long nextSequence = sequenceNumber + 1; currentSyncIncrease.put(KEY_SYNC_SEQUENCE_LOCAL, nextSequence); //in case of failed syncs due to non-available backends, sequence number might already be higher than nextSequence //we must take care to not decrease it here provider.update(TransactionProvider.ACCOUNTS_URI, currentSyncIncrease, KEY_ROWID + " = ? AND " + KEY_SYNC_SEQUENCE_LOCAL + " < ?", new String[] { String.valueOf(accountId), String.valueOf(nextSequence) }); } if (hasLocalChanges) { Cursor c = provider.query(changesUri, null, null, null, null); if (c != null) { if (c.moveToFirst()) { do { TransactionChange transactionChange = TransactionChange.create(c); result.add(transactionChange); } while (c.moveToNext()); } c.close(); } } return result; } /** * @param changeList * @return the same list with split parts moved as parts to their parents. If there are multiple parents * for the same uuid, the splits will appear under each of them */ private List<TransactionChange> collectSplits(List<TransactionChange> changeList) { HashMap<String, List<TransactionChange>> splitsPerUuid = new HashMap<>(); for (Iterator<TransactionChange> i = changeList.iterator(); i.hasNext();) { TransactionChange change = i.next(); if ((change.parentUuid() != null)) { ensureList(splitsPerUuid, change.parentUuid()).add(change); i.remove(); } } //When a split transaction is changed, we do not necessarily have an entry for the parent, so we //create one here Stream.of(splitsPerUuid.keySet()).forEach(uuid -> { if (!Stream.of(changeList).anyMatch(change -> change.uuid().equals(uuid))) { changeList.add(TransactionChange.builder().setType(TransactionChange.Type.updated) .setTimeStamp(splitsPerUuid.get(uuid).get(0).timeStamp()).setUuid(uuid).build()); splitsPerUuid.put(uuid, filterDeleted(splitsPerUuid.get(uuid), findDeletedUuids(Stream.of(splitsPerUuid.get(uuid))))); } }); return Stream.of(changeList) .map(change -> splitsPerUuid.containsKey(change.uuid()) ? change.toBuilder().setSplitPartsAndValidate(splitsPerUuid.get(change.uuid())).build() : change) .collect(Collectors.toList()); } private void writeRemoteChangesToDb(ContentProviderClient provider, List<TransactionChange> remoteChanges, long accountId) throws RemoteException, OperationApplicationException { if (remoteChanges.size() == 0) { return; } if (remoteChanges.size() > BATCH_SIZE) { for (List<TransactionChange> part : ListUtils.partition(remoteChanges, BATCH_SIZE)) { writeRemoteChangesToDbPart(provider, part, accountId); } } else { writeRemoteChangesToDbPart(provider, remoteChanges, accountId); } } private void writeRemoteChangesToDbPart(ContentProviderClient provider, List<TransactionChange> remoteChanges, long accountId) throws RemoteException, OperationApplicationException { ArrayList<ContentProviderOperation> ops = new ArrayList<>(); ops.add(ContentProviderOperation .newInsert(TransactionProvider.DUAL_URI.buildUpon() .appendQueryParameter(TransactionProvider.QUERY_PARAMETER_SYNC_BEGIN, "1").build()) .build()); Stream.of(remoteChanges).filter(change -> !(change.isCreate() && uuidExists(change.uuid()))) .forEach(change -> collectOperations(change, accountId, ops, -1)); ops.add(ContentProviderOperation.newDelete(TransactionProvider.DUAL_URI.buildUpon() .appendQueryParameter(TransactionProvider.QUERY_PARAMETER_SYNC_END, "1").build()).build()); ContentProviderResult[] contentProviderResults = provider.applyBatch(ops); int opsSize = ops.size(); int resultsSize = contentProviderResults.length; if (opsSize != resultsSize) { AcraHelper.report( String.format(Locale.ROOT, "applied %d operations, received %d results", opsSize, resultsSize)); } } private boolean uuidExists(String uuid) { return Transaction.countPerUuid(uuid) > 0; } @VisibleForTesting public void collectOperations(@NonNull TransactionChange change, long accountId, ArrayList<ContentProviderOperation> ops, int parentOffset) { Uri uri = Transaction.CALLER_IS_SYNC_ADAPTER_URI; switch (change.type()) { case created: ops.addAll(getContentProviderOperationsForCreate(change, ops.size(), parentOffset)); break; case updated: ContentValues values = toContentValues(change); if (values.size() > 0) { ops.add(ContentProviderOperation.newUpdate(uri) .withSelection(KEY_UUID + " = ? AND " + KEY_ACCOUNTID + " = ?", new String[] { change.uuid(), String.valueOf(accountId) }) .withValues(values).build()); } break; case deleted: ops.add(ContentProviderOperation.newDelete(uri) .withSelection(KEY_UUID + " = ?", new String[] { change.uuid() }).build()); break; } if (change.splitParts() != null) { final int newParentOffset = ops.size() - 1; List<TransactionChange> splitPartsFiltered = filterDeleted(change.splitParts(), findDeletedUuids(Stream.of(change.splitParts()))); Stream.of(splitPartsFiltered).forEach(splitChange -> collectOperations(splitChange, accountId, ops, change.isCreate() ? newParentOffset : -1)); //back reference is only used when we insert a new split, for updating an existing split we search for its _id via its uuid } } private ArrayList<ContentProviderOperation> getContentProviderOperationsForCreate(TransactionChange change, int offset, int parentOffset) { if (!change.isCreate()) throw new AssertionError(); Long amount; if (change.amount() != null) { amount = change.amount(); } else { amount = 0L; } Transaction t; long transferAccount; if (change.splitParts() != null) { t = new SplitTransaction(getAccount().getId(), amount); } else if (change.transferAccount() != null && (transferAccount = extractTransferAccount(change.transferAccount(), change.label())) != -1) { t = new Transfer(getAccount().getId(), amount); t.transfer_account = transferAccount; } else { t = new Transaction(getAccount().getId(), amount); if (change.label() != null) { long catId = extractCatId(change.label()); if (catId != -1) { t.setCatId(catId); } } } t.uuid = change.uuid(); if (change.comment() != null) { t.comment = change.comment(); } if (change.date() != null) { Long date = change.date(); assert date != null; t.setDate(new Date(date * 1000)); } if (change.payeeName() != null) { long id = Payee.extractPayeeId(change.payeeName(), payeeToId); if (id != -1) { t.payeeId = id; } } if (change.methodLabel() != null) { long id = extractMethodId(change.methodLabel()); if (id != -1) { t.methodId = id; } } if (change.crStatus() != null) { t.crStatus = Transaction.CrStatus.valueOf(change.crStatus()); } t.referenceNumber = change.referenceNumber(); if (parentOffset == -1 && change.parentUuid() != null) { long parentId = Transaction.findByUuid(change.parentUuid()); if (parentId == -1) { return new ArrayList<>(); //if we fail to link a split part to a parent, we need to ignore it } t.parentId = parentId; } if (change.pictureUri() != null) { t.setPictureUri(Uri.parse(change.pictureUri())); } return t.buildSaveOperations(offset, parentOffset, true); } private ContentValues toContentValues(TransactionChange change) { if (!change.isUpdate()) throw new AssertionError(); ContentValues values = new ContentValues(); //values.put("parent_uuid", parentUuid()); if (change.comment() != null) { values.put(KEY_COMMENT, change.comment()); } if (change.date() != null) { values.put(KEY_DATE, change.date()); } if (change.amount() != null) { values.put(KEY_AMOUNT, change.amount()); } if (change.label() != null) { long catId = extractCatId(change.label()); if (catId != -1) { values.put(KEY_CATID, catId); } } if (change.payeeName() != null) { long id = Payee.extractPayeeId(change.payeeName(), payeeToId); if (id != -1) { values.put(KEY_PAYEEID, id); } } if (change.methodLabel() != null) { long id = extractMethodId(change.methodLabel()); if (id != -1) { values.put(KEY_METHODID, id); } } if (change.crStatus() != null) { values.put(KEY_CR_STATUS, change.crStatus()); } if (change.referenceNumber() != null) { values.put(KEY_REFERENCE_NUMBER, change.referenceNumber()); } if (change.pictureUri() != null) { values.put(KEY_PICTURE_URI, change.pictureUri()); } return values; } private long extractTransferAccount(String uuid, String label) { Long id = accountUuidToId.get(uuid); if (id == null) { id = org.totschnig.myexpenses.model.Account.findByUuid(uuid); if (id == -1 && label != null) { org.totschnig.myexpenses.model.Account transferAccount = new org.totschnig.myexpenses.model.Account( label, getAccount().currency, 0L, "", AccountType.CASH, org.totschnig.myexpenses.model.Account.DEFAULT_COLOR); transferAccount.uuid = uuid; transferAccount.save(); id = transferAccount.getId(); } if (id != -1) { //should always be the case accountUuidToId.put(uuid, id); } } return id; } private long extractCatId(String label) { new CategoryInfo(label).insert(categoryToId, false); return categoryToId.get(label) != null ? categoryToId.get(label) : -1; } private long extractMethodId(String methodLabel) { Long id = methodToId.get(methodLabel); if (id == null) { id = PaymentMethod.find(methodLabel); if (id == -1) { id = PaymentMethod.maybeWrite(methodLabel, getAccount().type); } if (id != -1) { //should always be the case methodToId.put(methodLabel, id); } } return id; } @VisibleForTesting Pair<List<TransactionChange>, List<TransactionChange>> mergeChangeSets(List<TransactionChange> first, List<TransactionChange> second) { //filter out changes made obsolete by later delete List<String> deletedUuids = findDeletedUuids(Stream.concat(Stream.of(first), Stream.of(second))); List<TransactionChange> firstResult = filterDeleted(first, deletedUuids); List<TransactionChange> secondResult = filterDeleted(second, deletedUuids); //merge update changes HashMap<String, List<TransactionChange>> updatesPerUuid = new HashMap<>(); HashMap<String, TransactionChange> mergesPerUuid = new HashMap<>(); Stream.concat(Stream.of(firstResult), Stream.of(secondResult)).filter(TransactionChange::isCreateOrUpdate) .forEach(change -> ensureList(updatesPerUuid, change.uuid()).add(change)); List<String> uuidsRequiringMerge = Stream.of(updatesPerUuid.keySet()) .filter(uuid -> updatesPerUuid.get(uuid).size() > 1).collect(Collectors.toList()); Stream.of(uuidsRequiringMerge) .forEach(uuid -> mergesPerUuid.put(uuid, mergeUpdates(updatesPerUuid.get(uuid)))); firstResult = replaceByMerged(firstResult, mergesPerUuid); secondResult = replaceByMerged(secondResult, mergesPerUuid); return Pair.create(firstResult, secondResult); } private List<String> findDeletedUuids(Stream<TransactionChange> stream) { return stream.filter(TransactionChange::isDelete).map(TransactionChange::uuid).collect(Collectors.toList()); } private List<TransactionChange> filterDeleted(List<TransactionChange> input, List<String> deletedUuids) { return Stream.of(input).filter(change -> change.isDelete() || !deletedUuids.contains(change.uuid())) .collect(Collectors.toList()); } private List<TransactionChange> replaceByMerged(List<TransactionChange> input, HashMap<String, TransactionChange> mergedMap) { return Stream.of(input) .map(change -> change.isCreateOrUpdate() && mergedMap.containsKey(change.uuid()) ? mergedMap.get(change.uuid()) : change) .distinct().collect(Collectors.toList()); } @VisibleForTesting public TransactionChange mergeUpdates(List<TransactionChange> changeList) { if (changeList.size() < 2) { throw new IllegalStateException("nothing to merge"); } return Stream.of(changeList).sortBy(TransactionChange::timeStamp).reduce(this::mergeUpdate).get(); } private TransactionChange mergeUpdate(TransactionChange initial, TransactionChange change) { if (!(change.isCreateOrUpdate() && initial.isCreateOrUpdate())) { throw new IllegalStateException("Can only merge creates and updates"); } if (!initial.uuid().equals(change.uuid())) { throw new IllegalStateException("Can only merge changes with same uuid"); } TransactionChange.Builder builder = initial.toBuilder(); if (change.parentUuid() != null) { builder.setParentUuid(change.parentUuid()); } if (change.comment() != null) { builder.setComment(change.comment()); } if (change.date() != null) { builder.setDate(change.date()); } if (change.amount() != null) { builder.setAmount(change.amount()); } if (change.label() != null) { builder.setLabel(change.label()); } if (change.payeeName() != null) { builder.setPayeeName(change.payeeName()); } if (change.transferAccount() != null) { builder.setTransferAccount(change.transferAccount()); } if (change.methodLabel() != null) { builder.setMethodLabel(change.methodLabel()); } if (change.crStatus() != null) { builder.setCrStatus(change.crStatus()); } if (change.referenceNumber() != null) { builder.setReferenceNumber(change.referenceNumber()); } if (change.pictureUri() != null) { builder.setPictureUri(change.pictureUri()); } if (change.splitParts() != null) { builder.setSplitParts(change.splitParts()); } return builder.setCurrentTimeStamp().build(); } private Uri buildChangesUri(long current_sync, long accountId) { return TransactionProvider.CHANGES_URI.buildUpon() .appendQueryParameter(DatabaseConstants.KEY_ACCOUNTID, String.valueOf(accountId)) .appendQueryParameter(KEY_SYNC_SEQUENCE_LOCAL, String.valueOf(current_sync)).build(); } private Uri buildInitializationUri(long accountId) { return TransactionProvider.CHANGES_URI.buildUpon() .appendQueryParameter(DatabaseConstants.KEY_ACCOUNTID, String.valueOf(accountId)) .appendQueryParameter(TransactionProvider.QUERY_PARAMETER_INIT, "1").build(); } private List<TransactionChange> ensureList(HashMap<String, List<TransactionChange>> map, String uuid) { List<TransactionChange> changesForUuid = map.get(uuid); if (changesForUuid == null) { changesForUuid = new ArrayList<>(); map.put(uuid, changesForUuid); } return changesForUuid; } private boolean hasLocalChanges(ContentProviderClient provider, Uri changesUri) throws RemoteException { boolean result = false; Cursor c = provider.query(changesUri, new String[] { "count(*)" }, null, null, null); if (c != null) { if (c.moveToFirst()) { result = c.getLong(0) > 0; } c.close(); } return result; } @VisibleForTesting public org.totschnig.myexpenses.model.Account getAccount() { return dbAccount.get(); } }