]> git.ktnx.net Git - mobile-ledger.git/commitdiff
rework account list management to be fully asynchronous
authorDamyan Ivanov <dam+mobileledger@ktnx.net>
Sat, 1 Aug 2020 13:27:03 +0000 (16:27 +0300)
committerDamyan Ivanov <dam+mobileledger@ktnx.net>
Sat, 1 Aug 2020 13:27:03 +0000 (16:27 +0300)
displayed list is mostly static, updates are made to new lists, which are diff-updated (in a thread)

the tricky part is handling updates from the web (in a thread) and from the UI (expansion/collapsing of sub-trees)

13 files changed:
app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java
app/src/main/java/net/ktnx/mobileledger/model/LedgerAccount.java
app/src/main/java/net/ktnx/mobileledger/model/LedgerAmount.java
app/src/main/java/net/ktnx/mobileledger/model/LedgerTransaction.java
app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java
app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryAdapter.java
app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryFragment.java
app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java
app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java
app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListAdapter.java
app/src/main/java/net/ktnx/mobileledger/utils/ObservableValue.java
app/src/test/java/net/ktnx/mobileledger/model/LedgerAccountTest.java [new file with mode: 0644]
app/src/test/java/net/ktnx/mobileledger/model/MobileLedgerProfileTest.java [new file with mode: 0644]

index ef6eb8172c0598f3088af2c5481be4073042862f..5360cd006e694acef276612afed88724e56d14c6 100644 (file)
@@ -52,12 +52,10 @@ import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Locale;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Locale;
-import java.util.Stack;
+import java.util.Objects;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-import static net.ktnx.mobileledger.utils.Logger.debug;
-
 
 public class RetrieveTransactionsTask
         extends AsyncTask<Void, RetrieveTransactionsTask.Progress, String> {
 
 public class RetrieveTransactionsTask
         extends AsyncTask<Void, RetrieveTransactionsTask.Progress, String> {
@@ -94,7 +92,7 @@ public class RetrieveTransactionsTask
             String postingStatus = m.group(1);
             String acc_name = m.group(2);
             String currencyPre = m.group(3);
             String postingStatus = m.group(1);
             String acc_name = m.group(2);
             String currencyPre = m.group(3);
-            String amount = m.group(4);
+            String amount = Objects.requireNonNull(m.group(4));
             String currencyPost = m.group(5);
 
             String currency = null;
             String currencyPost = m.group(5);
 
             String currency = null;
@@ -109,7 +107,7 @@ public class RetrieveTransactionsTask
 
             amount = amount.replace(',', '.');
 
 
             amount = amount.replace(',', '.');
 
-            return new LedgerTransactionAccount(acc_name, Float.valueOf(amount), currency, null);
+            return new LedgerTransactionAccount(acc_name, Float.parseFloat(amount), currency, null);
         }
         else {
             return null;
         }
         else {
             return null;
@@ -147,14 +145,15 @@ public class RetrieveTransactionsTask
             return;
         context.onRetrieveDone(null);
     }
             return;
         context.onRetrieveDone(null);
     }
-    private String retrieveTransactionListLegacy()
-            throws IOException, ParseException, HTTPException {
+    private String retrieveTransactionListLegacy() throws IOException, HTTPException {
         Progress progress = new Progress();
         int maxTransactionId = Progress.INDETERMINATE;
         Progress progress = new Progress();
         int maxTransactionId = Progress.INDETERMINATE;
-        ArrayList<LedgerAccount> accountList = new ArrayList<>();
-        HashMap<String, Void> accountNames = new HashMap<>();
-        HashMap<String, LedgerAccount> syntheticAccounts = new HashMap<>();
-        LedgerAccount lastAccount = null, prevAccount = null;
+        ArrayList<LedgerAccount> list = new ArrayList<>();
+        HashMap<String, LedgerAccount> map = new HashMap<>();
+        ArrayList<LedgerAccount> displayed = new ArrayList<>();
+        ArrayList<LedgerTransaction> transactions = new ArrayList<>();
+        LedgerAccount lastAccount = null;
+        ArrayList<LedgerAccount> syntheticAccounts = new ArrayList<>();
 
         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "journal");
         http.setAllowUserInteraction(false);
 
         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "journal");
         http.setAllowUserInteraction(false);
@@ -162,235 +161,174 @@ public class RetrieveTransactionsTask
         if (http.getResponseCode() != 200)
             throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
 
         if (http.getResponseCode() != 200)
             throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
 
-        SQLiteDatabase db = App.getDatabase();
         try (InputStream resp = http.getInputStream()) {
             if (http.getResponseCode() != 200)
                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
         try (InputStream resp = http.getInputStream()) {
             if (http.getResponseCode() != 200)
                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
-            db.beginTransaction();
-            try {
-                prepareDbForRetrieval(db, profile);
 
 
-                int matchedTransactionsCount = 0;
+            int matchedTransactionsCount = 0;
 
 
+            ParserState state = ParserState.EXPECTING_ACCOUNT;
+            String line;
+            BufferedReader buf =
+                    new BufferedReader(new InputStreamReader(resp, StandardCharsets.UTF_8));
 
 
-                ParserState state = ParserState.EXPECTING_ACCOUNT;
-                String line;
-                BufferedReader buf =
-                        new BufferedReader(new InputStreamReader(resp, StandardCharsets.UTF_8));
-
-                int processedTransactionCount = 0;
-                int transactionId = 0;
-                LedgerTransaction transaction = null;
-                LINES:
-                while ((line = buf.readLine()) != null) {
-                    throwIfCancelled();
-                    Matcher m;
-                    m = reComment.matcher(line);
-                    if (m.find()) {
-                        // TODO: comments are ignored for now
+            int processedTransactionCount = 0;
+            int transactionId = 0;
+            LedgerTransaction transaction = null;
+            LINES:
+            while ((line = buf.readLine()) != null) {
+                throwIfCancelled();
+                Matcher m;
+                m = reComment.matcher(line);
+                if (m.find()) {
+                    // TODO: comments are ignored for now
 //                            Log.v("transaction-parser", "Ignoring comment");
 //                            Log.v("transaction-parser", "Ignoring comment");
-                        continue;
-                    }
-                    //L(String.format("State is %d", updating));
-                    switch (state) {
-                        case EXPECTING_ACCOUNT:
-                            if (line.equals("<h2>General Journal</h2>")) {
-                                state = ParserState.EXPECTING_TRANSACTION;
-                                L("→ expecting transaction");
-                                // commit the current transaction and start a new one
-                                // the account list in the UI should reflect the (committed)
-                                // state of the database
-                                db.setTransactionSuccessful();
-                                db.endTransaction();
-                                profile.setAccounts(accountList);
-                                db.beginTransaction();
+                    continue;
+                }
+                //L(String.format("State is %d", updating));
+                switch (state) {
+                    case EXPECTING_ACCOUNT:
+                        if (line.equals("<h2>General Journal</h2>")) {
+                            state = ParserState.EXPECTING_TRANSACTION;
+                            L("→ expecting transaction");
+                            continue;
+                        }
+                        m = reAccountName.matcher(line);
+                        if (m.find()) {
+                            String acct_encoded = m.group(1);
+                            String accName = URLDecoder.decode(acct_encoded, "UTF-8");
+                            accName = accName.replace("\"", "");
+                            L(String.format("found account: %s", accName));
+
+                            lastAccount = map.get(accName);
+                            if (lastAccount != null) {
+                                L(String.format("ignoring duplicate account '%s'", accName));
                                 continue;
                             }
                                 continue;
                             }
-                            m = reAccountName.matcher(line);
-                            if (m.find()) {
-                                String acct_encoded = m.group(1);
-                                String acct_name = URLDecoder.decode(acct_encoded, "UTF-8");
-                                acct_name = acct_name.replace("\"", "");
-                                L(String.format("found account: %s", acct_name));
-
-                                prevAccount = lastAccount;
-                                lastAccount = profile.tryLoadAccount(db, acct_name);
-                                if (lastAccount == null)
-                                    lastAccount = new LedgerAccount(profile, acct_name);
-                                else
-                                    lastAccount.removeAmounts();
-                                profile.storeAccount(db, lastAccount);
-
-                                if (prevAccount != null)
-                                    prevAccount.setHasSubAccounts(
-                                            prevAccount.isParentOf(lastAccount));
-                                // make sure the parent account(s) are present,
-                                // synthesising them if necessary
-                                // this happens when the (missing-in-HTML) parent account has
-                                // only one child so we create a synthetic parent account record,
-                                // copying the amounts when child's amounts are parsed
-                                String parentName = lastAccount.getParentName();
-                                if (parentName != null) {
-                                    Stack<String> toAppend = new Stack<>();
-                                    while (parentName != null) {
-                                        if (accountNames.containsKey(parentName))
-                                            break;
-                                        toAppend.push(parentName);
-                                        parentName = new LedgerAccount(profile, parentName).getParentName();
-                                    }
-                                    syntheticAccounts.clear();
-                                    while (!toAppend.isEmpty()) {
-                                        String aName = toAppend.pop();
-                                        LedgerAccount acc = profile.tryLoadAccount(db, aName);
-                                        if (acc == null) {
-                                            acc = new LedgerAccount(profile, aName);
-                                            acc.setExpanded(!lastAccount.hasSubAccounts() ||
-                                                            lastAccount.isExpanded());
-                                        }
-                                        acc.setHasSubAccounts(true);
-                                        acc.removeAmounts();    // filled below when amounts are
-                                        // parsed
-                                        if (acc.isVisible(accountList))
-                                            accountList.add(acc);
-                                        L(String.format("gap-filling with %s", aName));
-                                        accountNames.put(aName, null);
-                                        profile.storeAccount(db, acc);
-                                        syntheticAccounts.put(aName, acc);
-                                    }
-                                }
+                            String parentAccountName = LedgerAccount.extractParentName(accName);
+                            LedgerAccount parentAccount;
+                            if (parentAccountName != null) {
+                                parentAccount = ensureAccountExists(parentAccountName, map,
+                                        syntheticAccounts);
+                            }
+                            else {
+                                parentAccount = null;
+                            }
+                            lastAccount = new LedgerAccount(profile, accName, parentAccount);
 
 
-                                if (lastAccount.isVisible(accountList))
-                                    accountList.add(lastAccount);
-                                accountNames.put(acct_name, null);
+                            list.add(lastAccount);
+                            map.put(accName, lastAccount);
 
 
-                                state = ParserState.EXPECTING_ACCOUNT_AMOUNT;
-                                L("→ expecting account amount");
-                            }
-                            break;
-
-                        case EXPECTING_ACCOUNT_AMOUNT:
-                            m = reAccountValue.matcher(line);
-                            boolean match_found = false;
-                            while (m.find()) {
-                                throwIfCancelled();
-
-                                match_found = true;
-                                String value = m.group(1);
-                                String currency = m.group(2);
-                                if (currency == null)
-                                    currency = "";
-
-                                {
-                                    Matcher tmpM = reDecimalComma.matcher(value);
-                                    if (tmpM.find()) {
-                                        value = value.replace(".", "");
-                                        value = value.replace(',', '.');
-                                    }
-
-                                    tmpM = reDecimalPoint.matcher(value);
-                                    if (tmpM.find()) {
-                                        value = value.replace(",", "");
-                                        value = value.replace(" ", "");
-                                    }
+                            state = ParserState.EXPECTING_ACCOUNT_AMOUNT;
+                            L("→ expecting account amount");
+                        }
+                        break;
+
+                    case EXPECTING_ACCOUNT_AMOUNT:
+                        m = reAccountValue.matcher(line);
+                        boolean match_found = false;
+                        while (m.find()) {
+                            throwIfCancelled();
+
+                            match_found = true;
+                            String value = Objects.requireNonNull(m.group(1));
+                            String currency = m.group(2);
+                            if (currency == null)
+                                currency = "";
+
+                            {
+                                Matcher tmpM = reDecimalComma.matcher(value);
+                                if (tmpM.find()) {
+                                    value = value.replace(".", "");
+                                    value = value.replace(',', '.');
                                 }
                                 }
-                                L("curr=" + currency + ", value=" + value);
-                                final float val = Float.parseFloat(value);
-                                profile.storeAccountValue(db, lastAccount.getName(), currency, val);
-                                lastAccount.addAmount(val, currency);
-                                for (LedgerAccount syn : syntheticAccounts.values()) {
-                                    L(String.format(Locale.ENGLISH, "propagating %s %1.2f to %s",
-                                            currency, val, syn.getName()));
-                                    syn.addAmount(val, currency);
-                                    profile.storeAccountValue(db, syn.getName(), currency, val);
+
+                                tmpM = reDecimalPoint.matcher(value);
+                                if (tmpM.find()) {
+                                    value = value.replace(",", "");
+                                    value = value.replace(" ", "");
                                 }
                             }
                                 }
                             }
-
-                            if (match_found) {
-                                syntheticAccounts.clear();
-                                state = ParserState.EXPECTING_ACCOUNT;
-                                L("→ expecting account");
+                            L("curr=" + currency + ", value=" + value);
+                            final float val = Float.parseFloat(value);
+                            lastAccount.addAmount(val, currency);
+                            for (LedgerAccount syn : syntheticAccounts) {
+                                L(String.format(Locale.ENGLISH, "propagating %s %1.2f to %s",
+                                        currency, val, syn.getName()));
+                                syn.addAmount(val, currency);
                             }
                             }
+                        }
 
 
-                            break;
+                        if (match_found) {
+                            syntheticAccounts.clear();
+                            state = ParserState.EXPECTING_ACCOUNT;
+                            L("→ expecting account");
+                        }
 
 
-                        case EXPECTING_TRANSACTION:
-                            if (!line.isEmpty() && (line.charAt(0) == ' '))
-                                continue;
-                            m = reTransactionStart.matcher(line);
-                            if (m.find()) {
-                                transactionId = Integer.valueOf(m.group(1));
-                                state = ParserState.EXPECTING_TRANSACTION_DESCRIPTION;
-                                L(String.format(Locale.ENGLISH,
-                                        "found transaction %d → expecting description",
-                                        transactionId));
-                                progress.setProgress(++processedTransactionCount);
-                                if (maxTransactionId < transactionId)
-                                    maxTransactionId = transactionId;
-                                if ((progress.getTotal() == Progress.INDETERMINATE) ||
-                                    (progress.getTotal() < transactionId))
-                                    progress.setTotal(transactionId);
-                                publishProgress(progress);
+                        break;
+
+                    case EXPECTING_TRANSACTION:
+                        if (!line.isEmpty() && (line.charAt(0) == ' '))
+                            continue;
+                        m = reTransactionStart.matcher(line);
+                        if (m.find()) {
+                            transactionId = Integer.parseInt(Objects.requireNonNull(m.group(1)));
+                            state = ParserState.EXPECTING_TRANSACTION_DESCRIPTION;
+                            L(String.format(Locale.ENGLISH,
+                                    "found transaction %d → expecting description", transactionId));
+                            progress.setProgress(++processedTransactionCount);
+                            if (maxTransactionId < transactionId)
+                                maxTransactionId = transactionId;
+                            if ((progress.getTotal() == Progress.INDETERMINATE) ||
+                                (progress.getTotal() < transactionId))
+                                progress.setTotal(transactionId);
+                            publishProgress(progress);
+                        }
+                        m = reEnd.matcher(line);
+                        if (m.find()) {
+                            L("--- transaction value complete ---");
+                            break LINES;
+                        }
+                        break;
+
+                    case EXPECTING_TRANSACTION_DESCRIPTION:
+                        if (!line.isEmpty() && (line.charAt(0) == ' '))
+                            continue;
+                        m = reTransactionDescription.matcher(line);
+                        if (m.find()) {
+                            if (transactionId == 0)
+                                throw new TransactionParserException(
+                                        "Transaction Id is 0 while expecting " + "description");
+
+                            String date = Objects.requireNonNull(m.group(1));
+                            try {
+                                int equalsIndex = date.indexOf('=');
+                                if (equalsIndex >= 0)
+                                    date = date.substring(equalsIndex + 1);
+                                transaction =
+                                        new LedgerTransaction(transactionId, date, m.group(2));
                             }
                             }
-                            m = reEnd.matcher(line);
-                            if (m.find()) {
-                                L("--- transaction value complete ---");
-                                break LINES;
+                            catch (ParseException e) {
+                                e.printStackTrace();
+                                return String.format("Error parsing date '%s'", date);
                             }
                             }
-                            break;
+                            state = ParserState.EXPECTING_TRANSACTION_DETAILS;
+                            L(String.format(Locale.ENGLISH,
+                                    "transaction %d created for %s (%s) →" + " expecting details",
+                                    transactionId, date, m.group(2)));
+                        }
+                        break;
 
 
-                        case EXPECTING_TRANSACTION_DESCRIPTION:
-                            if (!line.isEmpty() && (line.charAt(0) == ' '))
-                                continue;
-                            m = reTransactionDescription.matcher(line);
-                            if (m.find()) {
-                                if (transactionId == 0)
-                                    throw new TransactionParserException(
-                                            "Transaction Id is 0 while expecting " + "description");
-
-                                String date = m.group(1);
-                                try {
-                                    int equalsIndex = date.indexOf('=');
-                                    if (equalsIndex >= 0)
-                                        date = date.substring(equalsIndex + 1);
-                                    transaction =
-                                            new LedgerTransaction(transactionId, date, m.group(2));
-                                }
-                                catch (ParseException e) {
-                                    e.printStackTrace();
-                                    return String.format("Error parsing date '%s'", date);
-                                }
-                                state = ParserState.EXPECTING_TRANSACTION_DETAILS;
-                                L(String.format(Locale.ENGLISH,
-                                        "transaction %d created for %s (%s) →" +
-                                        " expecting details", transactionId, date, m.group(2)));
-                            }
-                            break;
-
-                        case EXPECTING_TRANSACTION_DETAILS:
-                            if (line.isEmpty()) {
-                                // transaction data collected
-                                if (transaction.existsInDb(db)) {
-                                    profile.markTransactionAsPresent(db, transaction);
-                                    matchedTransactionsCount++;
-
-                                    if (matchedTransactionsCount == MATCHING_TRANSACTIONS_LIMIT) {
-                                        profile.markTransactionsBeforeTransactionAsPresent(db,
-                                                transaction);
-                                        progress.setTotal(progress.getProgress());
-                                        publishProgress(progress);
-                                        break LINES;
-                                    }
-                                }
-                                else {
-                                    profile.storeTransaction(db, transaction);
-                                    matchedTransactionsCount = 0;
-                                    progress.setTotal(maxTransactionId);
-                                }
+                    case EXPECTING_TRANSACTION_DETAILS:
+                        if (line.isEmpty()) {
+                            // transaction data collected
+
+                            transaction.finishLoading();
+                            transactions.add(transaction);
 
 
-                                state = ParserState.EXPECTING_TRANSACTION;
-                                L(String.format("transaction %s saved → expecting transaction",
-                                        transaction.getId()));
-                                transaction.finishLoading();
+                            state = ParserState.EXPECTING_TRANSACTION;
+                            L(String.format("transaction %s parsed → expecting transaction",
+                                    transaction.getId()));
 
 // sounds like a good idea, but transaction-1 may not be the first one chronologically
 // for example, when you add the initial seeding transaction after entering some others
 
 // sounds like a good idea, but transaction-1 may not be the first one chronologically
 // for example, when you add the initial seeding transaction after entering some others
@@ -400,47 +338,53 @@ public class RetrieveTransactionsTask
 //                                                  "parser");
 //                                                break LINES;
 //                                            }
 //                                                  "parser");
 //                                                break LINES;
 //                                            }
+                        }
+                        else {
+                            LedgerTransactionAccount lta = parseTransactionAccountLine(line);
+                            if (lta != null) {
+                                transaction.addAccount(lta);
+                                L(String.format(Locale.ENGLISH, "%d: %s = %s", transaction.getId(),
+                                        lta.getAccountName(), lta.getAmount()));
                             }
                             }
-                            else {
-                                LedgerTransactionAccount lta = parseTransactionAccountLine(line);
-                                if (lta != null) {
-                                    transaction.addAccount(lta);
-                                    L(String.format(Locale.ENGLISH, "%d: %s = %s",
-                                            transaction.getId(), lta.getAccountName(),
-                                            lta.getAmount()));
-                                }
-                                else
-                                    throw new IllegalStateException(
-                                            String.format("Can't parse transaction %d details: %s",
-                                                    transactionId, line));
-                            }
-                            break;
-                        default:
-                            throw new RuntimeException(
-                                    String.format("Unknown parser updating %s", state.name()));
-                    }
+                            else
+                                throw new IllegalStateException(
+                                        String.format("Can't parse transaction %d details: %s",
+                                                transactionId, line));
+                        }
+                        break;
+                    default:
+                        throw new RuntimeException(
+                                String.format("Unknown parser updating %s", state.name()));
                 }
                 }
+            }
 
 
-                throwIfCancelled();
-
-                profile.deleteNotPresentTransactions(db);
-                db.setTransactionSuccessful();
+            throwIfCancelled();
 
 
-                profile.setLastUpdateStamp();
+            profile.setAndStoreAccountAndTransactionListFromWeb(list, transactions);
 
 
-                return null;
-            }
-            finally {
-                db.endTransaction();
-            }
+            return null;
         }
     }
         }
     }
-    private void prepareDbForRetrieval(SQLiteDatabase db, MobileLedgerProfile profile) {
-        db.execSQL("UPDATE transactions set keep=0 where profile=?",
-                new String[]{profile.getUuid()});
-        db.execSQL("update account_values set keep=0 where profile=?;",
-                new String[]{profile.getUuid()});
-        db.execSQL("update accounts set keep=0 where profile=?;", new String[]{profile.getUuid()});
+    private @NonNull
+    LedgerAccount ensureAccountExists(String accountName, HashMap<String, LedgerAccount> map,
+                                      ArrayList<LedgerAccount> createdAccounts) {
+        LedgerAccount acc = map.get(accountName);
+
+        if (acc != null)
+            return acc;
+
+        String parentName = LedgerAccount.extractParentName(accountName);
+        LedgerAccount parentAccount;
+        if (parentName != null) {
+            parentAccount = ensureAccountExists(parentName, map, createdAccounts);
+        }
+        else {
+            parentAccount = null;
+        }
+
+        acc = new LedgerAccount(profile, accountName, parentAccount);
+        createdAccounts.add(acc);
+        return acc;
     }
     private boolean retrieveAccountList() throws IOException, HTTPException {
         Progress progress = new Progress();
     }
     private boolean retrieveAccountList() throws IOException, HTTPException {
         Progress progress = new Progress();
@@ -457,85 +401,85 @@ public class RetrieveTransactionsTask
         }
         publishProgress(progress);
         SQLiteDatabase db = App.getDatabase();
         }
         publishProgress(progress);
         SQLiteDatabase db = App.getDatabase();
-        ArrayList<LedgerAccount> accountList = new ArrayList<>();
-        boolean listFilledOK = false;
+        ArrayList<LedgerAccount> list = new ArrayList<>();
+        HashMap<String, LedgerAccount> map = new HashMap<>();
+        HashMap<String, LedgerAccount> currentMap = new HashMap<>();
+        for (LedgerAccount acc : Objects.requireNonNull(profile.getAllAccounts()))
+            currentMap.put(acc.getName(), acc);
         try (InputStream resp = http.getInputStream()) {
             if (http.getResponseCode() != 200)
                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
 
         try (InputStream resp = http.getInputStream()) {
             if (http.getResponseCode() != 200)
                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
 
-            db.beginTransaction();
-            try {
-                profile.markAccountsAsNotPresent(db);
+            AccountListParser parser = new AccountListParser(resp);
 
 
-                AccountListParser parser = new AccountListParser(resp);
+            while (true) {
+                throwIfCancelled();
+                ParsedLedgerAccount parsedAccount = parser.nextAccount();
+                if (parsedAccount == null) {
+                    break;
+                }
 
 
-                LedgerAccount prevAccount = null;
+                final String accName = parsedAccount.getAname();
+                LedgerAccount acc = map.get(accName);
+                if (acc != null)
+                    throw new RuntimeException(
+                            String.format("Account '%s' already present", acc.getName()));
+                String parentName = LedgerAccount.extractParentName(accName);
+                ArrayList<LedgerAccount> createdParents = new ArrayList<>();
+                LedgerAccount parent;
+                if (parentName == null) {
+                    parent = null;
+                }
+                else {
+                    parent = ensureAccountExists(parentName, map, createdParents);
+                    parent.setHasSubAccounts(true);
+                }
+                acc = new LedgerAccount(profile, accName, parent);
+                list.add(acc);
+                map.put(accName, acc);
 
 
-                while (true) {
+                String lastCurrency = null;
+                float lastCurrencyAmount = 0;
+                for (ParsedBalance b : parsedAccount.getAibalance()) {
                     throwIfCancelled();
                     throwIfCancelled();
-                    ParsedLedgerAccount parsedAccount = parser.nextAccount();
-                    if (parsedAccount == null)
-                        break;
-
-                    LedgerAccount acc = profile.tryLoadAccount(db, parsedAccount.getAname());
-                    if (acc == null)
-                        acc = new LedgerAccount(profile, parsedAccount.getAname());
-                    else
-                        acc.removeAmounts();
-
-                    profile.storeAccount(db, acc);
-                    String lastCurrency = null;
-                    float lastCurrencyAmount = 0;
-                    for (ParsedBalance b : parsedAccount.getAibalance()) {
-                        final String currency = b.getAcommodity();
-                        final float amount = b.getAquantity()
-                                              .asFloat();
-                        if (currency.equals(lastCurrency))
-                            lastCurrencyAmount += amount;
-                        else {
-                            if (lastCurrency != null) {
-                                profile.storeAccountValue(db, acc.getName(), lastCurrency,
-                                        lastCurrencyAmount);
-                                acc.addAmount(lastCurrencyAmount, lastCurrency);
-                            }
-                            lastCurrency = currency;
-                            lastCurrencyAmount = amount;
-                        }
-                    }
-                    if (lastCurrency != null) {
-                        profile.storeAccountValue(db, acc.getName(), lastCurrency,
-                                lastCurrencyAmount);
-                        acc.addAmount(lastCurrencyAmount, lastCurrency);
+                    final String currency = b.getAcommodity();
+                    final float amount = b.getAquantity()
+                                          .asFloat();
+                    if (currency.equals(lastCurrency)) {
+                        lastCurrencyAmount += amount;
                     }
                     }
-
-                    if (acc.isVisible(accountList))
-                        accountList.add(acc);
-
-                    if (prevAccount != null) {
-                        prevAccount.setHasSubAccounts(acc.getName()
-                                                         .startsWith(prevAccount.getName() + ":"));
+                    else {
+                        if (lastCurrency != null) {
+                            acc.addAmount(lastCurrencyAmount, lastCurrency);
+                        }
+                        lastCurrency = currency;
+                        lastCurrencyAmount = amount;
                     }
                     }
-
-                    prevAccount = acc;
                 }
                 }
-                throwIfCancelled();
-
-                profile.deleteNotPresentAccounts(db);
-                throwIfCancelled();
-                db.setTransactionSuccessful();
-            }
-            finally {
-                db.endTransaction();
+                if (lastCurrency != null) {
+                    acc.addAmount(lastCurrencyAmount, lastCurrency);
+                }
+                for (LedgerAccount p : createdParents)
+                    acc.propagateAmountsTo(p);
             }
             }
+            throwIfCancelled();
         }
 
         }
 
-        profile.setAccounts(accountList);
+        // the current account tree may have changed, update the new-to be tree to match
+        for (LedgerAccount acc : list) {
+            LedgerAccount prevData = currentMap.get(acc.getName());
+            if (prevData != null) {
+                acc.setExpanded(prevData.isExpanded());
+                acc.setAmountsExpanded(prevData.amountsExpanded());
+            }
+        }
 
 
+        profile.setAndStoreAccountListFromWeb(list);
         return true;
     }
     private boolean retrieveTransactionList() throws IOException, ParseException, HTTPException {
         Progress progress = new Progress();
         return true;
     }
     private boolean retrieveTransactionList() throws IOException, ParseException, HTTPException {
         Progress progress = new Progress();
-        int maxTransactionId = Progress.INDETERMINATE;
+        int maxTransactionId = Data.transactions.size();
 
         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "transactions");
         http.setAllowUserInteraction(false);
 
         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "transactions");
         http.setAllowUserInteraction(false);
@@ -548,93 +492,30 @@ public class RetrieveTransactionsTask
             default:
                 throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
         }
             default:
                 throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
         }
-        SQLiteDatabase db = App.getDatabase();
         try (InputStream resp = http.getInputStream()) {
         try (InputStream resp = http.getInputStream()) {
-            if (http.getResponseCode() != 200)
-                throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
             throwIfCancelled();
             throwIfCancelled();
-            db.beginTransaction();
-            try {
-                profile.markTransactionsAsNotPresent(db);
-
-                int matchedTransactionsCount = 0;
-                TransactionListParser parser = new TransactionListParser(resp);
-
-                int processedTransactionCount = 0;
-
-                DetectedTransactionOrder transactionOrder = DetectedTransactionOrder.UNKNOWN;
-                int orderAccumulator = 0;
-                int lastTransactionId = 0;
-
-                while (true) {
-                    throwIfCancelled();
-                    ParsedLedgerTransaction parsedTransaction = parser.nextTransaction();
-                    throwIfCancelled();
-                    if (parsedTransaction == null)
-                        break;
+            ArrayList<LedgerTransaction> trList = new ArrayList<>();
 
 
-                    LedgerTransaction transaction = parsedTransaction.asLedgerTransaction();
-                    if (transaction.getId() > lastTransactionId)
-                        orderAccumulator++;
-                    else
-                        orderAccumulator--;
-                    lastTransactionId = transaction.getId();
-                    if (transactionOrder == DetectedTransactionOrder.UNKNOWN) {
-                        if (orderAccumulator > 30) {
-                            transactionOrder = DetectedTransactionOrder.FILE;
-                            debug("rtt", String.format(Locale.ENGLISH,
-                                    "Detected native file order after %d transactions (factor %d)",
-                                    processedTransactionCount, orderAccumulator));
-                            progress.setTotal(Data.transactions.size());
-                        }
-                        else if (orderAccumulator < -30) {
-                            transactionOrder = DetectedTransactionOrder.REVERSE_CHRONOLOGICAL;
-                            debug("rtt", String.format(Locale.ENGLISH,
-                                    "Detected reverse chronological order after %d transactions " +
-                                    "(factor %d)", processedTransactionCount, orderAccumulator));
-                        }
-                    }
+            TransactionListParser parser = new TransactionListParser(resp);
 
 
-                    if (transaction.existsInDb(db)) {
-                        profile.markTransactionAsPresent(db, transaction);
-                        matchedTransactionsCount++;
-
-                        if ((transactionOrder == DetectedTransactionOrder.REVERSE_CHRONOLOGICAL) &&
-                            (matchedTransactionsCount == MATCHING_TRANSACTIONS_LIMIT))
-                        {
-                            profile.markTransactionsBeforeTransactionAsPresent(db, transaction);
-                            progress.setTotal(progress.getProgress());
-                            publishProgress(progress);
-                            db.setTransactionSuccessful();
-                            profile.setLastUpdateStamp();
-                            return true;
-                        }
-                    }
-                    else {
-                        profile.storeTransaction(db, transaction);
-                        matchedTransactionsCount = 0;
-                        progress.setTotal(maxTransactionId);
-                    }
-
-
-                    if ((transactionOrder != DetectedTransactionOrder.UNKNOWN) &&
-                        ((progress.getTotal() == Progress.INDETERMINATE) ||
-                         (progress.getTotal() < transaction.getId())))
-                        progress.setTotal(transaction.getId());
-
-                    progress.setProgress(++processedTransactionCount);
-                    publishProgress(progress);
-                }
+            int processedTransactionCount = 0;
 
 
+            while (true) {
                 throwIfCancelled();
                 throwIfCancelled();
-                profile.deleteNotPresentTransactions(db);
+                ParsedLedgerTransaction parsedTransaction = parser.nextTransaction();
                 throwIfCancelled();
                 throwIfCancelled();
-                db.setTransactionSuccessful();
-                profile.setLastUpdateStamp();
-            }
-            finally {
-                db.endTransaction();
+                if (parsedTransaction == null)
+                    break;
+
+                LedgerTransaction transaction = parsedTransaction.asLedgerTransaction();
+                trList.add(transaction);
+
+                progress.setProgress(++processedTransactionCount);
+                publishProgress(progress);
             }
             }
+
+            throwIfCancelled();
+            profile.setAndStoreTransactionList(trList);
         }
 
         return true;
         }
 
         return true;
@@ -680,14 +561,12 @@ public class RetrieveTransactionsTask
         if (isCancelled())
             throw new OperationCanceledException(null);
     }
         if (isCancelled())
             throw new OperationCanceledException(null);
     }
-    enum DetectedTransactionOrder {UNKNOWN, REVERSE_CHRONOLOGICAL, FILE}
-
     private enum ParserState {
     private enum ParserState {
-        EXPECTING_ACCOUNT, EXPECTING_ACCOUNT_AMOUNT, EXPECTING_JOURNAL, EXPECTING_TRANSACTION,
+        EXPECTING_ACCOUNT, EXPECTING_ACCOUNT_AMOUNT, EXPECTING_TRANSACTION,
         EXPECTING_TRANSACTION_DESCRIPTION, EXPECTING_TRANSACTION_DETAILS
     }
 
         EXPECTING_TRANSACTION_DESCRIPTION, EXPECTING_TRANSACTION_DETAILS
     }
 
-    public class Progress {
+    public static class Progress {
         public static final int INDETERMINATE = -1;
         private int progress;
         private int total;
         public static final int INDETERMINATE = -1;
         private int progress;
         private int total;
@@ -712,7 +591,7 @@ public class RetrieveTransactionsTask
         }
     }
 
         }
     }
 
-    private class TransactionParserException extends IllegalStateException {
+    private static class TransactionParserException extends IllegalStateException {
         TransactionParserException(String message) {
             super(message);
         }
         TransactionParserException(String message) {
             super(message);
         }
index 19a33ad6166f2f6f2231d619d9b02ddd28550c36..92ce6c80c8c1a7352b8d340ff4dc505da23635f3 100644 (file)
@@ -23,7 +23,6 @@ import androidx.annotation.Nullable;
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.List;
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 public class LedgerAccount {
 import java.util.regex.Pattern;
 
 public class LedgerAccount {
@@ -31,26 +30,32 @@ public class LedgerAccount {
     private String name;
     private String shortName;
     private int level;
     private String name;
     private String shortName;
     private int level;
-    private String parentName;
+    private LedgerAccount parent;
     private boolean expanded;
     private List<LedgerAmount> amounts;
     private boolean hasSubAccounts;
     private boolean amountsExpanded;
     private WeakReference<MobileLedgerProfile> profileWeakReference;
 
     private boolean expanded;
     private List<LedgerAmount> amounts;
     private boolean hasSubAccounts;
     private boolean amountsExpanded;
     private WeakReference<MobileLedgerProfile> profileWeakReference;
 
-    public LedgerAccount(MobileLedgerProfile profile, String name) {
+    public LedgerAccount(MobileLedgerProfile profile, String name, @Nullable LedgerAccount parent) {
         this.profileWeakReference = new WeakReference<>(profile);
         this.profileWeakReference = new WeakReference<>(profile);
+        this.parent = parent;
+        if (parent != null && !name.startsWith(parent.getName() + ":"))
+            throw new IllegalStateException(
+                    String.format("Account name '%s' doesn't match parent account '%s'", name,
+                            parent.getName()));
         this.setName(name);
     }
         this.setName(name);
     }
-
-    public LedgerAccount(MobileLedgerProfile profile, String name, float amount) {
-        this.profileWeakReference = new WeakReference<>(profile);
-        this.setName(name);
-        this.expanded = true;
-        this.amounts = new ArrayList<>();
-        this.addAmount(amount);
+    @Nullable
+    public static String extractParentName(@NonNull String accName) {
+        int colonPos = accName.lastIndexOf(':');
+        if (colonPos < 0)
+            return null;    // no parent account -- this is a top-level account
+        else
+            return accName.substring(0, colonPos);
     }
     }
-    public @Nullable MobileLedgerProfile getProfile() {
+    public @Nullable
+    MobileLedgerProfile getProfile() {
         return profileWeakReference.get();
     }
     @Override
         return profileWeakReference.get();
     }
     @Override
@@ -62,42 +67,40 @@ public class LedgerAccount {
         if (obj == null)
             return false;
 
         if (obj == null)
             return false;
 
-        return obj.getClass()
-                  .equals(this.getClass()) && name.equals(((LedgerAccount) obj).getName());
+        if (!(obj instanceof LedgerAccount))
+            return false;
+
+        LedgerAccount acc = (LedgerAccount) obj;
+        if (!name.equals(acc.name))
+            return false;
+
+        if (!getAmountsString().equals(acc.getAmountsString()))
+            return false;
+
+        return expanded == acc.expanded && amountsExpanded == acc.amountsExpanded;
     }
     // an account is visible if:
     }
     // an account is visible if:
-    //  - it has an expanded parent or is a top account
-    public boolean isVisible(List<LedgerAccount> list) {
-        for (LedgerAccount acc : list) {
-            if (acc.isParentOf(this)) {
-                if (!acc.isExpanded())
-                    return false;
-            }
-        }
-        return true;
+    //  - it has an expanded visible parent or is a top account
+    public boolean isVisible() {
+        if (parent == null)
+            return true;
+
+        return (parent.isExpanded() && parent.isVisible());
     }
     public boolean isParentOf(LedgerAccount potentialChild) {
         return potentialChild.getName()
                              .startsWith(name + ":");
     }
     private void stripName() {
     }
     public boolean isParentOf(LedgerAccount potentialChild) {
         return potentialChild.getName()
                              .startsWith(name + ":");
     }
     private void stripName() {
-        level = 0;
-        shortName = name;
-        StringBuilder parentBuilder = new StringBuilder();
-        while (true) {
-            Matcher m = reHigherAccount.matcher(shortName);
-            if (m.find()) {
-                level++;
-                parentBuilder.append(m.group(0));
-                shortName = m.replaceFirst("");
-            }
-            else
-                break;
+        if (parent == null) {
+            level = 0;
+            shortName = name;
+        }
+        else {
+            level = parent.level + 1;
+            shortName = name.substring(parent.getName()
+                                             .length() + 1);
         }
         }
-        if (parentBuilder.length() > 0)
-            parentName = parentBuilder.substring(0, parentBuilder.length() - 1);
-        else
-            parentName = null;
     }
     public String getName() {
         return name;
     }
     public String getName() {
         return name;
@@ -157,7 +160,7 @@ public class LedgerAccount {
     }
 
     public String getParentName() {
     }
 
     public String getParentName() {
-        return parentName;
+        return (parent == null) ? null : parent.getName();
     }
     public boolean hasSubAccounts() {
         return hasSubAccounts;
     }
     public boolean hasSubAccounts() {
         return hasSubAccounts;
@@ -182,4 +185,11 @@ public class LedgerAccount {
     public void setAmountsExpanded(boolean flag) { amountsExpanded = flag; }
     public void toggleAmountsExpanded() { amountsExpanded = !amountsExpanded; }
 
     public void setAmountsExpanded(boolean flag) { amountsExpanded = flag; }
     public void toggleAmountsExpanded() { amountsExpanded = !amountsExpanded; }
 
+    public void propagateAmountsTo(LedgerAccount acc) {
+        for (LedgerAmount a : amounts)
+            a.propagateToAccount(acc);
+    }
+    public List<LedgerAmount> getAmounts() {
+        return amounts;
+    }
 }
 }
index 6aebdb50f7fb70dbf99cb8a5c43cf7b99cf21443..3ecbc0726369077eb5b8ae1221457c0315d0dadc 100644 (file)
@@ -1,5 +1,5 @@
 /*
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2020 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -18,6 +18,7 @@
 package net.ktnx.mobileledger.model;
 
 import android.annotation.SuppressLint;
 package net.ktnx.mobileledger.model;
 
 import android.annotation.SuppressLint;
+
 import androidx.annotation.NonNull;
 
 public class LedgerAmount {
 import androidx.annotation.NonNull;
 
 public class LedgerAmount {
@@ -30,8 +31,7 @@ public class LedgerAmount {
         this.amount = amount;
     }
 
         this.amount = amount;
     }
 
-    public
-    LedgerAmount(float amount) {
+    public LedgerAmount(float amount) {
         this.amount = amount;
         this.currency = null;
     }
         this.amount = amount;
         this.currency = null;
     }
@@ -39,7 +39,21 @@ public class LedgerAmount {
     @SuppressLint("DefaultLocale")
     @NonNull
     public String toString() {
     @SuppressLint("DefaultLocale")
     @NonNull
     public String toString() {
-        if (currency == null) return String.format("%,1.2f", amount);
-        else return String.format("%s %,1.2f", currency, amount);
+        if (currency == null)
+            return String.format("%,1.2f", amount);
+        else
+            return String.format("%s %,1.2f", currency, amount);
+    }
+    public void propagateToAccount(@NonNull LedgerAccount acc) {
+        if (currency != null)
+            acc.addAmount(amount, currency);
+        else
+            acc.addAmount(amount);
+    }
+    public String getCurrency() {
+        return currency;
+    }
+    public float getAmount() {
+        return amount;
     }
 }
     }
 }
index 04b63b64169f03b11ac0ab7e747abfab51a17431..638a692cff56d21417e15786183c2bb0a14109a2 100644 (file)
@@ -219,4 +219,27 @@ public class LedgerTransaction {
     public void finishLoading() {
         dataLoaded = true;
     }
     public void finishLoading() {
         dataLoaded = true;
     }
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj == null)
+            return false;
+        if (!obj.getClass()
+                .equals(this.getClass()))
+            return false;
+
+        return ((LedgerTransaction) obj).getDataHash()
+                                        .equals(getDataHash());
+    }
+
+    public boolean hasAccountNamedLike(String name) {
+        name = name.toUpperCase();
+        for (LedgerTransactionAccount acc : accounts) {
+            if (acc.getAccountName()
+                   .toUpperCase()
+                   .contains(name))
+                return true;
+        }
+
+        return false;
+    }
 }
 }
index fc940e9ad6f8850b33e4ee9dd118dafc746e1ac3..6b847ec103c1cb91aa5a2ea34e0a155632dae751 100644 (file)
@@ -20,9 +20,10 @@ package net.ktnx.mobileledger.model;
 import android.content.res.Resources;
 import android.database.Cursor;
 import android.database.sqlite.SQLiteDatabase;
 import android.content.res.Resources;
 import android.database.Cursor;
 import android.database.sqlite.SQLiteDatabase;
+import android.os.Build;
+import android.text.TextUtils;
 import android.util.SparseArray;
 
 import android.util.SparseArray;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 import androidx.annotation.Nullable;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
@@ -31,22 +32,34 @@ import net.ktnx.mobileledger.App;
 import net.ktnx.mobileledger.R;
 import net.ktnx.mobileledger.async.DbOpQueue;
 import net.ktnx.mobileledger.async.SendTransactionTask;
 import net.ktnx.mobileledger.R;
 import net.ktnx.mobileledger.async.DbOpQueue;
 import net.ktnx.mobileledger.async.SendTransactionTask;
+import net.ktnx.mobileledger.utils.LockHolder;
+import net.ktnx.mobileledger.utils.Locker;
 import net.ktnx.mobileledger.utils.Logger;
 import net.ktnx.mobileledger.utils.MLDB;
 import net.ktnx.mobileledger.utils.Misc;
 
 import net.ktnx.mobileledger.utils.Logger;
 import net.ktnx.mobileledger.utils.MLDB;
 import net.ktnx.mobileledger.utils.Misc;
 
+import org.jetbrains.annotations.Contract;
+
 import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 import java.util.List;
 import java.util.Locale;
-import java.util.UUID;
+import java.util.Map;
+import java.util.Objects;
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
 
 public final class MobileLedgerProfile {
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
 
 public final class MobileLedgerProfile {
+    private final MutableLiveData<List<LedgerAccount>> displayedAccounts;
+    private final MutableLiveData<List<LedgerTransaction>> allTransactions;
+    private final MutableLiveData<List<LedgerTransaction>> displayedTransactions;
     // N.B. when adding new fields, update the copy-constructor below
     // N.B. when adding new fields, update the copy-constructor below
-    private String uuid;
+    private final String uuid;
+    private final Locker accountsLocker = new Locker();
+    private List<LedgerAccount> allAccounts;
     private String name;
     private boolean permitPosting;
     private boolean showCommentsByDefault;
     private String name;
     private boolean permitPosting;
     private boolean showCommentsByDefault;
@@ -59,19 +72,25 @@ public final class MobileLedgerProfile {
     private String authPassword;
     private int themeHue;
     private int orderNo = -1;
     private String authPassword;
     private int themeHue;
     private int orderNo = -1;
-    // N.B. when adding new fields, update the copy-constructor below
-    private FutureDates futureDates = FutureDates.None;
     private SendTransactionTask.API apiVersion = SendTransactionTask.API.auto;
     private Calendar firstTransactionDate;
     private Calendar lastTransactionDate;
     private SendTransactionTask.API apiVersion = SendTransactionTask.API.auto;
     private Calendar firstTransactionDate;
     private Calendar lastTransactionDate;
-    private MutableLiveData<ArrayList<LedgerAccount>> accounts =
-            new MutableLiveData<>(new ArrayList<>());
-    private AccountListLoader loader = null;
-    public MobileLedgerProfile() {
-        this.uuid = String.valueOf(UUID.randomUUID());
-    }
+    private FutureDates futureDates = FutureDates.None;
+    private boolean accountsLoaded;
+    private boolean transactionsLoaded;
+    // N.B. when adding new fields, update the copy-constructor below
+    transient private AccountListLoader loader = null;
+    transient private Thread displayedAccountsUpdater;
+    transient private AccountListSaver accountListSaver;
+    transient private TransactionListSaver transactionListSaver;
+    transient private AccountAndTransactionListSaver accountAndTransactionListSaver;
+    private Map<String, LedgerAccount> accountMap = new HashMap<>();
     public MobileLedgerProfile(String uuid) {
         this.uuid = uuid;
     public MobileLedgerProfile(String uuid) {
         this.uuid = uuid;
+        allAccounts = new ArrayList<>();
+        displayedAccounts = new MutableLiveData<>();
+        allTransactions = new MutableLiveData<>(new ArrayList<>());
+        displayedTransactions = new MutableLiveData<>(new ArrayList<>());
     }
     public MobileLedgerProfile(MobileLedgerProfile origin) {
         uuid = origin.uuid;
     }
     public MobileLedgerProfile(MobileLedgerProfile origin) {
         uuid = origin.uuid;
@@ -91,6 +110,13 @@ public final class MobileLedgerProfile {
         defaultCommodity = origin.defaultCommodity;
         firstTransactionDate = origin.firstTransactionDate;
         lastTransactionDate = origin.lastTransactionDate;
         defaultCommodity = origin.defaultCommodity;
         firstTransactionDate = origin.firstTransactionDate;
         lastTransactionDate = origin.lastTransactionDate;
+        displayedAccounts = origin.displayedAccounts;
+        allAccounts = origin.allAccounts;
+        accountMap = origin.accountMap;
+        displayedTransactions = origin.displayedTransactions;
+        allTransactions = origin.allTransactions;
+        accountsLoaded = origin.accountsLoaded;
+        transactionsLoaded = origin.transactionsLoaded;
     }
     // loads all profiles into Data.profiles
     // returns the profile with the given UUID
     }
     // loads all profiles into Data.profiles
     // returns the profile with the given UUID
@@ -147,8 +173,107 @@ public final class MobileLedgerProfile {
             db.endTransaction();
         }
     }
             db.endTransaction();
         }
     }
-    public LiveData<ArrayList<LedgerAccount>> getAccounts() {
-        return accounts;
+    public static ArrayList<LedgerAccount> mergeAccountLists(List<LedgerAccount> oldList,
+                                                             List<LedgerAccount> newList) {
+        LedgerAccount oldAcc, newAcc;
+        ArrayList<LedgerAccount> merged = new ArrayList<>();
+
+        Iterator<LedgerAccount> oldIterator = oldList.iterator();
+        Iterator<LedgerAccount> newIterator = newList.iterator();
+
+        while (true) {
+            if (!oldIterator.hasNext()) {
+                // the rest of the incoming are new
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+                    newIterator.forEachRemaining(merged::add);
+                }
+                else {
+                    while (newIterator.hasNext())
+                        merged.add(newIterator.next());
+                }
+                break;
+            }
+            oldAcc = oldIterator.next();
+
+            if (!newIterator.hasNext()) {
+                // no more incoming accounts. ignore the rest of the old
+                break;
+            }
+            newAcc = newIterator.next();
+
+            // ignore now missing old items
+            if (oldAcc.getName()
+                      .compareTo(newAcc.getName()) < 0)
+                continue;
+
+            // add newly found items
+            if (oldAcc.getName()
+                      .compareTo(newAcc.getName()) > 0)
+            {
+                merged.add(newAcc);
+                continue;
+            }
+
+            // two items with same account names; merge UI-controlled fields
+            oldAcc.setExpanded(newAcc.isExpanded());
+            oldAcc.setAmountsExpanded(newAcc.amountsExpanded());
+            merged.add(oldAcc);
+        }
+
+        return merged;
+    }
+    public void mergeAccountList(List<LedgerAccount> newList) {
+
+        try (LockHolder l = accountsLocker.lockForWriting()) {
+            allAccounts = mergeAccountLists(allAccounts, newList);
+            updateAccountsMap(allAccounts);
+        }
+    }
+    public LiveData<List<LedgerAccount>> getDisplayedAccounts() {
+        return displayedAccounts;
+    }
+    @Contract(value = "null -> false", pure = true)
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj == null)
+            return false;
+        if (obj == this)
+            return true;
+        if (obj.getClass() != this.getClass())
+            return false;
+
+        MobileLedgerProfile p = (MobileLedgerProfile) obj;
+        if (!uuid.equals(p.uuid))
+            return false;
+        if (!name.equals(p.name))
+            return false;
+        if (permitPosting != p.permitPosting)
+            return false;
+        if (showCommentsByDefault != p.showCommentsByDefault)
+            return false;
+        if (showCommodityByDefault != p.showCommodityByDefault)
+            return false;
+        if (!Objects.equals(defaultCommodity, p.defaultCommodity))
+            return false;
+        if (!Objects.equals(preferredAccountsFilter, p.preferredAccountsFilter))
+            return false;
+        if (!Objects.equals(url, p.url))
+            return false;
+        if (authEnabled != p.authEnabled)
+            return false;
+        if (!Objects.equals(authUserName, p.authUserName))
+            return false;
+        if (!Objects.equals(authPassword, p.authPassword))
+            return false;
+        if (themeHue != p.themeHue)
+            return false;
+        if (apiVersion != p.apiVersion)
+            return false;
+        if (!Objects.equals(firstTransactionDate, p.firstTransactionDate))
+            return false;
+        if (!Objects.equals(lastTransactionDate, p.lastTransactionDate))
+            return false;
+        return futureDates == p.futureDates;
     }
     synchronized public void scheduleAccountListReload() {
         Logger.debug("async-acc", "scheduleAccountListReload() enter");
     }
     synchronized public void scheduleAccountListReload() {
         Logger.debug("async-acc", "scheduleAccountListReload() enter");
@@ -294,17 +419,24 @@ public final class MobileLedgerProfile {
             db.endTransaction();
         }
     }
             db.endTransaction();
         }
     }
-    public void storeAccount(SQLiteDatabase db, LedgerAccount acc) {
+    public void storeAccount(SQLiteDatabase db, LedgerAccount acc, boolean storeUiFields) {
         // replace into is a bad idea because it would reset hidden to its default value
         // we like the default, but for new accounts only
         // replace into is a bad idea because it would reset hidden to its default value
         // we like the default, but for new accounts only
-        db.execSQL("update accounts set level = ?, keep = 1, expanded=? " +
-                   "where profile=? and name = ?",
-                new Object[]{acc.getLevel(), acc.isExpanded(), uuid, acc.getName()
-                });
+        String sql = "update accounts set keep = 1";
+        List<Object> params = new ArrayList<>();
+        if (storeUiFields) {
+            sql += ", expanded=?";
+            params.add(acc.isExpanded() ? 1 : 0);
+        }
+        sql += " where profile=? and name=?";
+        params.add(uuid);
+        params.add(acc.getName());
+        db.execSQL(sql, params.toArray());
+
         db.execSQL("insert into accounts(profile, name, name_upper, parent_name, level, " +
         db.execSQL("insert into accounts(profile, name, name_upper, parent_name, level, " +
-                   "expanded, keep) " + "select ?,?,?,?,?,?,1 where (select changes() = 0)",
+                   "expanded, keep) " + "select ?,?,?,?,?,0,1 where (select changes() = 0)",
                 new Object[]{uuid, acc.getName(), acc.getName().toUpperCase(), acc.getParentName(),
                 new Object[]{uuid, acc.getName(), acc.getName().toUpperCase(), acc.getParentName(),
-                             acc.getLevel(), acc.isExpanded()
+                             acc.getLevel()
                 });
 //        debug("accounts", String.format("Stored account '%s' in DB [%s]", acc.getName(), uuid));
     }
                 });
 //        debug("accounts", String.format("Stored account '%s' in DB [%s]", acc.getName(), uuid));
     }
@@ -407,53 +539,6 @@ public final class MobileLedgerProfile {
             db.endTransaction();
         }
     }
             db.endTransaction();
         }
     }
-    @NonNull
-    public LedgerAccount loadAccount(String name) {
-        SQLiteDatabase db = App.getDatabase();
-        return loadAccount(db, name);
-    }
-    @Nullable
-    public LedgerAccount tryLoadAccount(String acct_name) {
-        SQLiteDatabase db = App.getDatabase();
-        return tryLoadAccount(db, acct_name);
-    }
-    @NonNull
-    public LedgerAccount loadAccount(SQLiteDatabase db, String accName) {
-        LedgerAccount acc = tryLoadAccount(db, accName);
-
-        if (acc == null)
-            throw new RuntimeException("Unable to load account with name " + accName);
-
-        return acc;
-    }
-    @Nullable
-    public LedgerAccount tryLoadAccount(SQLiteDatabase db, String accName) {
-        try (Cursor cursor = db.rawQuery("SELECT a.expanded, a.amounts_expanded, (select 1 from accounts a2 " +
-                                         "where a2.profile = a.profile and a2.name like a" +
-                                         ".name||':%' limit 1) " +
-                                         "FROM accounts a WHERE a.profile = ? and a.name=?",
-                new String[]{uuid, accName}))
-        {
-            if (cursor.moveToFirst()) {
-                LedgerAccount acc = new LedgerAccount(this, accName);
-                acc.setExpanded(cursor.getInt(0) == 1);
-                acc.setAmountsExpanded(cursor.getInt(1) == 1);
-                acc.setHasSubAccounts(cursor.getInt(2) == 1);
-
-                try (Cursor c2 = db.rawQuery(
-                        "SELECT value, currency FROM account_values WHERE profile = ? " +
-                        "AND account = ?", new String[]{uuid, accName}))
-                {
-                    while (c2.moveToNext()) {
-                        acc.addAmount(c2.getFloat(0), c2.getString(1));
-                    }
-                }
-
-                return acc;
-            }
-            return null;
-        }
-    }
     public LedgerTransaction loadTransaction(int transactionId) {
         LedgerTransaction tr = new LedgerTransaction(transactionId, this.uuid);
         tr.loadData(App.getDatabase());
     public LedgerTransaction loadTransaction(int transactionId) {
         LedgerTransaction tr = new LedgerTransaction(transactionId, this.uuid);
         tr.loadData(App.getDatabase());
@@ -475,72 +560,36 @@ public final class MobileLedgerProfile {
         db.execSQL("UPDATE transactions set keep=0 where profile=?", new String[]{uuid});
 
     }
         db.execSQL("UPDATE transactions set keep=0 where profile=?", new String[]{uuid});
 
     }
-    public void markAccountsAsNotPresent(SQLiteDatabase db) {
+    private void markAccountsAsNotPresent(SQLiteDatabase db) {
         db.execSQL("update account_values set keep=0 where profile=?;", new String[]{uuid});
         db.execSQL("update accounts set keep=0 where profile=?;", new String[]{uuid});
 
     }
         db.execSQL("update account_values set keep=0 where profile=?;", new String[]{uuid});
         db.execSQL("update accounts set keep=0 where profile=?;", new String[]{uuid});
 
     }
-    public void deleteNotPresentAccounts(SQLiteDatabase db) {
+    private void deleteNotPresentAccounts(SQLiteDatabase db) {
         db.execSQL("delete from account_values where keep=0 and profile=?", new String[]{uuid});
         db.execSQL("delete from accounts where keep=0 and profile=?", new String[]{uuid});
     }
         db.execSQL("delete from account_values where keep=0 and profile=?", new String[]{uuid});
         db.execSQL("delete from accounts where keep=0 and profile=?", new String[]{uuid});
     }
-    public void markTransactionAsPresent(SQLiteDatabase db, LedgerTransaction transaction) {
+    private void markTransactionAsPresent(SQLiteDatabase db, LedgerTransaction transaction) {
         db.execSQL("UPDATE transactions SET keep = 1 WHERE profile = ? and id=?",
                 new Object[]{uuid, transaction.getId()
                 });
     }
         db.execSQL("UPDATE transactions SET keep = 1 WHERE profile = ? and id=?",
                 new Object[]{uuid, transaction.getId()
                 });
     }
-    public void markTransactionsBeforeTransactionAsPresent(SQLiteDatabase db,
-                                                           LedgerTransaction transaction) {
+    private void markTransactionsBeforeTransactionAsPresent(SQLiteDatabase db,
+                                                            LedgerTransaction transaction) {
         db.execSQL("UPDATE transactions SET keep=1 WHERE profile = ? and id < ?",
                 new Object[]{uuid, transaction.getId()
                 });
 
     }
         db.execSQL("UPDATE transactions SET keep=1 WHERE profile = ? and id < ?",
                 new Object[]{uuid, transaction.getId()
                 });
 
     }
-    public void deleteNotPresentTransactions(SQLiteDatabase db) {
+    private void deleteNotPresentTransactions(SQLiteDatabase db) {
         db.execSQL("DELETE FROM transactions WHERE profile=? AND keep = 0", new String[]{uuid});
     }
         db.execSQL("DELETE FROM transactions WHERE profile=? AND keep = 0", new String[]{uuid});
     }
-    public void setLastUpdateStamp() {
+    private void setLastUpdateStamp() {
         debug("db", "Updating transaction value stamp");
         Date now = new Date();
         setLongOption(MLDB.OPT_LAST_SCRAPE, now.getTime());
         Data.lastUpdateDate.postValue(now);
     }
         debug("db", "Updating transaction value stamp");
         Date now = new Date();
         setLongOption(MLDB.OPT_LAST_SCRAPE, now.getTime());
         Data.lastUpdateDate.postValue(now);
     }
-    public List<LedgerAccount> loadChildAccountsOf(LedgerAccount acc) {
-        List<LedgerAccount> result = new ArrayList<>();
-        SQLiteDatabase db = App.getDatabase();
-        try (Cursor c = db.rawQuery(
-                "SELECT a.name FROM accounts a WHERE a.profile = ? and a.name like ?||':%'",
-                new String[]{uuid, acc.getName()}))
-        {
-            while (c.moveToNext()) {
-                LedgerAccount a = loadAccount(db, c.getString(0));
-                result.add(a);
-            }
-        }
-
-        return result;
-    }
-    public List<LedgerAccount> loadVisibleChildAccountsOf(LedgerAccount acc) {
-        List<LedgerAccount> result = new ArrayList<>();
-        ArrayList<LedgerAccount> visibleList = new ArrayList<>();
-        visibleList.add(acc);
-
-        SQLiteDatabase db = App.getDatabase();
-        try (Cursor c = db.rawQuery(
-                "SELECT a.name FROM accounts a WHERE a.profile = ? and a.name like ?||':%'",
-                new String[]{uuid, acc.getName()}))
-        {
-            while (c.moveToNext()) {
-                LedgerAccount a = loadAccount(db, c.getString(0));
-                if (a.isVisible(visibleList)) {
-                    result.add(a);
-                    visibleList.add(a);
-                }
-            }
-        }
-
-        return result;
-    }
     public void wipeAllData() {
         SQLiteDatabase db = App.getDatabase();
         db.beginTransaction();
     public void wipeAllData() {
         SQLiteDatabase db = App.getDatabase();
         db.beginTransaction();
@@ -600,9 +649,119 @@ public final class MobileLedgerProfile {
     public Calendar getLastTransactionDate() {
         return lastTransactionDate;
     }
     public Calendar getLastTransactionDate() {
         return lastTransactionDate;
     }
-    public void setAccounts(ArrayList<LedgerAccount> list) {
-        accounts.postValue(list);
+    private void applyTransactionFilter(List<LedgerTransaction> list) {
+        final String accFilter = Data.accountFilter.getValue();
+        if (TextUtils.isEmpty(accFilter)) {
+            displayedTransactions.postValue(list);
+        }
+        else {
+            ArrayList<LedgerTransaction> newList = new ArrayList<>();
+            for (LedgerTransaction tr : list) {
+                if (tr.hasAccountNamedLike(accFilter))
+                    newList.add(tr);
+            }
+            displayedTransactions.postValue(newList);
+        }
+    }
+    synchronized public void storeAccountListAsync(List<LedgerAccount> list,
+                                                   boolean storeUiFields) {
+        if (accountListSaver != null)
+            accountListSaver.interrupt();
+        accountListSaver = new AccountListSaver(this, list, storeUiFields);
+        accountListSaver.start();
+    }
+    public void setAndStoreAccountListFromWeb(ArrayList<LedgerAccount> list) {
+        SQLiteDatabase db = App.getDatabase();
+        db.beginTransactionNonExclusive();
+        try {
+            markAccountsAsNotPresent(db);
+            for (LedgerAccount acc : list) {
+                storeAccount(db, acc, false);
+                for (LedgerAmount amt : acc.getAmounts()) {
+                    storeAccountValue(db, acc.getName(), amt.getCurrency(), amt.getAmount());
+                }
+            }
+            deleteNotPresentAccounts(db);
+            setLastUpdateStamp();
+            db.setTransactionSuccessful();
+        }
+        finally {
+            db.endTransaction();
+        }
+
+        mergeAccountList(list);
+        updateDisplayedAccounts();
+    }
+    public synchronized Locker lockAccountsForWriting() {
+        accountsLocker.lockForWriting();
+        return accountsLocker;
+    }
+    public void setAndStoreTransactionList(ArrayList<LedgerTransaction> list) {
+        storeTransactionListAsync(this, list);
+        SQLiteDatabase db = App.getDatabase();
+        db.beginTransactionNonExclusive();
+        try {
+            markTransactionsAsNotPresent(db);
+            for (LedgerTransaction tr : list)
+                storeTransaction(db, tr);
+            deleteNotPresentTransactions(db);
+            setLastUpdateStamp();
+            db.setTransactionSuccessful();
+        }
+        finally {
+            db.endTransaction();
+        }
+
+        allTransactions.postValue(list);
+    }
+    private void storeTransactionListAsync(MobileLedgerProfile mobileLedgerProfile,
+                                           List<LedgerTransaction> list) {
+        if (transactionListSaver != null)
+            transactionListSaver.interrupt();
+
+        transactionListSaver = new TransactionListSaver(this, list);
+        transactionListSaver.start();
+    }
+    public void setAndStoreAccountAndTransactionListFromWeb(List<LedgerAccount> accounts,
+                                                            List<LedgerTransaction> transactions) {
+        storeAccountAndTransactionListAsync(accounts, transactions, false);
+
+        mergeAccountList(accounts);
+        updateDisplayedAccounts();
+
+        allTransactions.postValue(transactions);
+    }
+    private void storeAccountAndTransactionListAsync(List<LedgerAccount> accounts,
+                                                     List<LedgerTransaction> transactions,
+                                                     boolean storeAccUiFields) {
+        if (accountAndTransactionListSaver != null)
+            accountAndTransactionListSaver.interrupt();
+
+        accountAndTransactionListSaver =
+                new AccountAndTransactionListSaver(this, accounts, transactions, storeAccUiFields);
+        accountAndTransactionListSaver.start();
+    }
+    synchronized public void updateDisplayedAccounts() {
+        if (displayedAccountsUpdater != null) {
+            displayedAccountsUpdater.interrupt();
+        }
+        displayedAccountsUpdater = new AccountListDisplayedFilter(this, allAccounts);
+        displayedAccountsUpdater.start();
+    }
+    public List<LedgerAccount> getAllAccounts() {
+        return allAccounts;
+    }
+    private void updateAccountsMap(List<LedgerAccount> newAccounts) {
+        accountMap.clear();
+        for (LedgerAccount acc : newAccounts) {
+            accountMap.put(acc.getName(), acc);
+        }
+    }
+    @Nullable
+    public LedgerAccount locateAccount(String name) {
+        return accountMap.get(name);
     }
     }
+
     public enum FutureDates {
         None(0), OneWeek(7), TwoWeeks(14), OneMonth(30), TwoMonths(60), ThreeMonths(90),
         SixMonths(180), OneYear(365), All(-1);
     public enum FutureDates {
         None(0), OneWeek(7), TwoWeeks(14), OneMonth(30), TwoMonths(60), ThreeMonths(90),
         SixMonths(180), OneYear(365), All(-1);
@@ -657,13 +816,17 @@ public final class MobileLedgerProfile {
         public void run() {
             Logger.debug("async-acc", "AccountListLoader::run() entered");
             String profileUUID = profile.getUuid();
         public void run() {
             Logger.debug("async-acc", "AccountListLoader::run() entered");
             String profileUUID = profile.getUuid();
-            ArrayList<LedgerAccount> newList = new ArrayList<>();
+            ArrayList<LedgerAccount> list = new ArrayList<>();
+            HashMap<String, LedgerAccount> map = new HashMap<>();
 
 
-            String sql = "SELECT a.name from accounts a WHERE a.profile = ?";
+            String sql = "SELECT a.name, a.expanded, a.amounts_expanded";
+            sql += " from accounts a WHERE a.profile = ?";
             sql += " ORDER BY a.name";
 
             SQLiteDatabase db = App.getDatabase();
             sql += " ORDER BY a.name";
 
             SQLiteDatabase db = App.getDatabase();
+            Logger.debug("async-acc", "AccountListLoader::run() connected to DB");
             try (Cursor cursor = db.rawQuery(sql, new String[]{profileUUID})) {
             try (Cursor cursor = db.rawQuery(sql, new String[]{profileUUID})) {
+                Logger.debug("async-acc", "AccountListLoader::run() executed query");
                 while (cursor.moveToNext()) {
                     if (isInterrupted())
                         return;
                 while (cursor.moveToNext()) {
                     if (isInterrupted())
                         return;
@@ -672,17 +835,200 @@ public final class MobileLedgerProfile {
 //                    debug("accounts",
 //                            String.format("Read account '%s' from DB [%s]", accName,
 //                            profileUUID));
 //                    debug("accounts",
 //                            String.format("Read account '%s' from DB [%s]", accName,
 //                            profileUUID));
-                    LedgerAccount acc = profile.loadAccount(db, accName);
-                    if (acc.isVisible(newList))
-                        newList.add(acc);
+                    String parentName = LedgerAccount.extractParentName(accName);
+                    LedgerAccount parent;
+                    if (parentName != null) {
+                        parent = map.get(parentName);
+                        if (parent == null)
+                            throw new IllegalStateException(
+                                    String.format("Can't load account '%s': parent '%s' not loaded",
+                                            accName, parentName));
+                        parent.setHasSubAccounts(true);
+                    }
+                    else
+                        parent = null;
+
+                    LedgerAccount acc = new LedgerAccount(profile, accName, parent);
+                    acc.setExpanded(cursor.getInt(1) == 1);
+                    acc.setAmountsExpanded(cursor.getInt(2) == 1);
+                    acc.setHasSubAccounts(false);
+
+                    try (Cursor c2 = db.rawQuery(
+                            "SELECT value, currency FROM account_values WHERE profile = ?" + " " +
+                            "AND account = ?", new String[]{profileUUID, accName}))
+                    {
+                        while (c2.moveToNext()) {
+                            acc.addAmount(c2.getFloat(0), c2.getString(1));
+                        }
+                    }
+
+                    list.add(acc);
+                    map.put(accName, acc);
                 }
                 }
+                Logger.debug("async-acc", "AccountListLoader::run() query execution done");
             }
 
             if (isInterrupted())
                 return;
 
             Logger.debug("async-acc", "AccountListLoader::run() posting new list");
             }
 
             if (isInterrupted())
                 return;
 
             Logger.debug("async-acc", "AccountListLoader::run() posting new list");
-            profile.accounts.postValue(newList);
+            profile.allAccounts = list;
+            profile.updateAccountsMap(list);
+            profile.updateDisplayedAccounts();
+        }
+    }
+
+    static class AccountListDisplayedFilter extends Thread {
+        private final MobileLedgerProfile profile;
+        private final List<LedgerAccount> list;
+        AccountListDisplayedFilter(MobileLedgerProfile profile, List<LedgerAccount> list) {
+            this.profile = profile;
+            this.list = list;
+        }
+        @Override
+        public void run() {
+            List<LedgerAccount> newDisplayed = new ArrayList<>();
+            Logger.debug("dFilter", "waiting for synchronized block");
+            Logger.debug("dFilter", String.format(Locale.US,
+                    "entered synchronized block (about to examine %d accounts)", list.size()));
+            for (LedgerAccount a : list) {
+                if (isInterrupted()) {
+                    return;
+                }
+
+                if (a.isVisible()) {
+                    newDisplayed.add(a);
+                }
+            }
+            if (!isInterrupted()) {
+                profile.displayedAccounts.postValue(newDisplayed);
+            }
+            Logger.debug("dFilter", "left synchronized block");
+        }
+    }
+
+    private static class AccountListSaver extends Thread {
+        private final MobileLedgerProfile profile;
+        private final List<LedgerAccount> list;
+        private final boolean storeUiFields;
+        AccountListSaver(MobileLedgerProfile profile, List<LedgerAccount> list,
+                         boolean storeUiFields) {
+            this.list = list;
+            this.profile = profile;
+            this.storeUiFields = storeUiFields;
+        }
+        @Override
+        public void run() {
+            SQLiteDatabase db = App.getDatabase();
+            db.beginTransactionNonExclusive();
+            try {
+                profile.markAccountsAsNotPresent(db);
+                if (isInterrupted())
+                    return;
+                for (LedgerAccount acc : list) {
+                    profile.storeAccount(db, acc, storeUiFields);
+                    if (isInterrupted())
+                        return;
+                }
+                profile.deleteNotPresentAccounts(db);
+                if (isInterrupted())
+                    return;
+                profile.setLastUpdateStamp();
+                db.setTransactionSuccessful();
+            }
+            finally {
+                db.endTransaction();
+            }
+        }
+    }
+
+    private static class TransactionListSaver extends Thread {
+        private final MobileLedgerProfile profile;
+        private final List<LedgerTransaction> list;
+        TransactionListSaver(MobileLedgerProfile profile, List<LedgerTransaction> list) {
+            this.list = list;
+            this.profile = profile;
+        }
+        @Override
+        public void run() {
+            SQLiteDatabase db = App.getDatabase();
+            db.beginTransactionNonExclusive();
+            try {
+                profile.markTransactionsAsNotPresent(db);
+                if (isInterrupted())
+                    return;
+                for (LedgerTransaction tr : list) {
+                    profile.storeTransaction(db, tr);
+                    if (isInterrupted())
+                        return;
+                }
+                profile.deleteNotPresentTransactions(db);
+                if (isInterrupted())
+                    return;
+                profile.setLastUpdateStamp();
+                db.setTransactionSuccessful();
+            }
+            finally {
+                db.endTransaction();
+            }
+        }
+    }
+
+    private static class AccountAndTransactionListSaver extends Thread {
+        private final MobileLedgerProfile profile;
+        private final List<LedgerAccount> accounts;
+        private final List<LedgerTransaction> transactions;
+        private final boolean storeAccUiFields;
+        AccountAndTransactionListSaver(MobileLedgerProfile profile, List<LedgerAccount> accounts,
+                                       List<LedgerTransaction> transactions,
+                                       boolean storeAccUiFields) {
+            this.accounts = accounts;
+            this.transactions = transactions;
+            this.profile = profile;
+            this.storeAccUiFields = storeAccUiFields;
+        }
+        @Override
+        public void run() {
+            SQLiteDatabase db = App.getDatabase();
+            db.beginTransactionNonExclusive();
+            try {
+                profile.markAccountsAsNotPresent(db);
+                if (isInterrupted())
+                    return;
+
+                profile.markTransactionsAsNotPresent(db);
+                if (isInterrupted()) {
+                    return;
+                }
+
+                for (LedgerAccount acc : accounts) {
+                    profile.storeAccount(db, acc, storeAccUiFields);
+                    if (isInterrupted())
+                        return;
+                }
+
+                for (LedgerTransaction tr : transactions) {
+                    profile.storeTransaction(db, tr);
+                    if (isInterrupted()) {
+                        return;
+                    }
+                }
+
+                profile.deleteNotPresentAccounts(db);
+                if (isInterrupted()) {
+                    return;
+                }
+                profile.deleteNotPresentTransactions(db);
+                if (isInterrupted())
+                    return;
+
+                profile.setLastUpdateStamp();
+
+                db.setTransactionSuccessful();
+            }
+            finally {
+                db.endTransaction();
+            }
         }
     }
 }
         }
     }
 }
index c0ed9bd7a4f3421c28b7df2bfb86323d03e9f26a..fbbc47d7ff5080647b7a7f4e6994dcc48283fa56 100644 (file)
@@ -20,7 +20,6 @@ package net.ktnx.mobileledger.ui.account_summary;
 import android.content.Context;
 import android.content.res.Resources;
 import android.text.TextUtils;
 import android.content.Context;
 import android.content.res.Resources;
 import android.text.TextUtils;
-import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
@@ -39,10 +38,11 @@ import net.ktnx.mobileledger.async.DbOpQueue;
 import net.ktnx.mobileledger.model.LedgerAccount;
 import net.ktnx.mobileledger.model.MobileLedgerProfile;
 import net.ktnx.mobileledger.ui.activity.MainActivity;
 import net.ktnx.mobileledger.model.LedgerAccount;
 import net.ktnx.mobileledger.model.MobileLedgerProfile;
 import net.ktnx.mobileledger.ui.activity.MainActivity;
+import net.ktnx.mobileledger.utils.Locker;
 
 import org.jetbrains.annotations.NotNull;
 
 
 import org.jetbrains.annotations.NotNull;
 
-import java.util.ArrayList;
+import java.util.List;
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
 
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
 
@@ -61,9 +61,7 @@ public class AccountSummaryAdapter
             @Override
             public boolean areContentsTheSame(@NotNull LedgerAccount oldItem,
                                               @NotNull LedgerAccount newItem) {
             @Override
             public boolean areContentsTheSame(@NotNull LedgerAccount oldItem,
                                               @NotNull LedgerAccount newItem) {
-                return (oldItem.isExpanded() == newItem.isExpanded()) &&
-                       (oldItem.amountsExpanded() == newItem.amountsExpanded() &&
-                        TextUtils.equals(oldItem.getAmountsString(), newItem.getAmountsString()));
+                return oldItem.equals(newItem);
             }
         });
     }
             }
         });
     }
@@ -86,18 +84,20 @@ public class AccountSummaryAdapter
         return listDiffer.getCurrentList()
                          .size();
     }
         return listDiffer.getCurrentList()
                          .size();
     }
-    public void setAccounts(MobileLedgerProfile profile, ArrayList<LedgerAccount> newList) {
+    public void setAccounts(MobileLedgerProfile profile, List<LedgerAccount> newList) {
         this.profile = profile;
         listDiffer.submitList(newList);
     }
         this.profile = profile;
         listDiffer.submitList(newList);
     }
-    class LedgerRowHolder extends RecyclerView.ViewHolder {
+    static class LedgerRowHolder extends RecyclerView.ViewHolder {
         TextView tvAccountName, tvAccountAmounts;
         ConstraintLayout row;
         View expanderContainer;
         ImageView expander;
         View accountExpanderContainer;
         TextView tvAccountName, tvAccountAmounts;
         ConstraintLayout row;
         View expanderContainer;
         ImageView expander;
         View accountExpanderContainer;
+        LedgerAccount mAccount;
         public LedgerRowHolder(@NonNull View itemView) {
             super(itemView);
         public LedgerRowHolder(@NonNull View itemView) {
             super(itemView);
+
             row = itemView.findViewById(R.id.account_summary_row);
             tvAccountName = itemView.findViewById(R.id.account_row_acc_name);
             tvAccountAmounts = itemView.findViewById(R.id.account_row_acc_amounts);
             row = itemView.findViewById(R.id.account_summary_row);
             tvAccountName = itemView.findViewById(R.id.account_row_acc_name);
             tvAccountAmounts = itemView.findViewById(R.id.account_row_acc_amounts);
@@ -118,94 +118,70 @@ public class AccountSummaryAdapter
             expander.setOnClickListener(v -> toggleAccountExpanded());
             tvAccountAmounts.setOnClickListener(v -> toggleAmountsExpanded());
         }
             expander.setOnClickListener(v -> toggleAccountExpanded());
             tvAccountAmounts.setOnClickListener(v -> toggleAmountsExpanded());
         }
-        private @NonNull
-        LedgerAccount getAccount() {
-            final ArrayList<LedgerAccount> accountList = profile.getAccounts()
-                                                                .getValue();
-            if (accountList == null)
-                throw new IllegalStateException("No account list");
-
-            return accountList.get(getAdapterPosition());
-        }
         private void toggleAccountExpanded() {
         private void toggleAccountExpanded() {
-            LedgerAccount acc = getAccount();
-            if (!acc.hasSubAccounts())
+            if (!mAccount.hasSubAccounts())
                 return;
             debug("accounts", "Account expander clicked");
 
                 return;
             debug("accounts", "Account expander clicked");
 
-            acc.toggleExpanded();
-            expanderContainer.animate()
-                             .rotation(acc.isExpanded() ? 0 : 180);
-
-            MobileLedgerProfile profile = acc.getProfile();
-            if (profile == null)
+            // make sure we use the same object as the one in the allAccounts list
+            MobileLedgerProfile profile = mAccount.getProfile();
+            if (profile == null) {
                 return;
                 return;
+            }
+            try (Locker ignored = profile.lockAccountsForWriting()) {
+                LedgerAccount realAccount = profile.locateAccount(mAccount.getName());
+                if (realAccount == null)
+                    return;
+
+                mAccount = realAccount;
+                mAccount.toggleExpanded();
+            }
+            expanderContainer.animate()
+                             .rotation(mAccount.isExpanded() ? 0 : 180);
+            profile.updateDisplayedAccounts();
 
             DbOpQueue.add("update accounts set expanded=? where name=? and profile=?",
 
             DbOpQueue.add("update accounts set expanded=? where name=? and profile=?",
-                    new Object[]{acc.isExpanded(), acc.getName(), profile.getUuid()
-                    }, profile::scheduleAccountListReload);
+                    new Object[]{mAccount.isExpanded(), mAccount.getName(), profile.getUuid()
+                    });
 
         }
         private void toggleAmountsExpanded() {
 
         }
         private void toggleAmountsExpanded() {
-            LedgerAccount acc = getAccount();
-            if (acc.getAmountCount() <= AMOUNT_LIMIT)
+            if (mAccount.getAmountCount() <= AMOUNT_LIMIT)
                 return;
 
                 return;
 
-            acc.toggleAmountsExpanded();
-            if (acc.amountsExpanded()) {
-                tvAccountAmounts.setText(acc.getAmountsString());
+            mAccount.toggleAmountsExpanded();
+            if (mAccount.amountsExpanded()) {
+                tvAccountAmounts.setText(mAccount.getAmountsString());
                 accountExpanderContainer.setVisibility(View.GONE);
             }
             else {
                 accountExpanderContainer.setVisibility(View.GONE);
             }
             else {
-                tvAccountAmounts.setText(acc.getAmountsString(AMOUNT_LIMIT));
+                tvAccountAmounts.setText(mAccount.getAmountsString(AMOUNT_LIMIT));
                 accountExpanderContainer.setVisibility(View.VISIBLE);
             }
 
                 accountExpanderContainer.setVisibility(View.VISIBLE);
             }
 
-            MobileLedgerProfile profile = acc.getProfile();
+            MobileLedgerProfile profile = mAccount.getProfile();
             if (profile == null)
                 return;
 
             DbOpQueue.add("update accounts set amounts_expanded=? where name=? and profile=?",
             if (profile == null)
                 return;
 
             DbOpQueue.add("update accounts set amounts_expanded=? where name=? and profile=?",
-                    new Object[]{acc.amountsExpanded(), acc.getName(), profile.getUuid()
+                    new Object[]{mAccount.amountsExpanded(), mAccount.getName(), profile.getUuid()
                     });
 
         }
         private boolean onItemLongClick(View v) {
             MainActivity activity = (MainActivity) v.getContext();
             AlertDialog.Builder builder = new AlertDialog.Builder(activity);
                     });
 
         }
         private boolean onItemLongClick(View v) {
             MainActivity activity = (MainActivity) v.getContext();
             AlertDialog.Builder builder = new AlertDialog.Builder(activity);
-            View row;
-            int id = v.getId();
-            switch (id) {
-                case R.id.account_summary_row:
-                    row = v;
-                    break;
-                case R.id.account_row_acc_amounts:
-                case R.id.account_row_amounts_expander_container:
-                    row = (View) v.getParent();
-                    break;
-                case R.id.account_row_acc_name:
-                case R.id.account_expander_container:
-                    row = (View) v.getParent()
-                                  .getParent();
-                    break;
-                case R.id.account_expander:
-                    row = (View) v.getParent()
-                                  .getParent()
-                                  .getParent();
-                    break;
-                default:
-                    Log.e("error",
-                            String.format("Don't know how to handle long click on id %d", id));
-                    return false;
-            }
-            LedgerAccount acc = getAccount();
-            builder.setTitle(acc.getName());
+            final String accountName = mAccount.getName();
+            builder.setTitle(accountName);
             builder.setItems(R.array.acc_ctx_menu, (dialog, which) -> {
                 switch (which) {
                     case 0:
                         // show transactions
             builder.setItems(R.array.acc_ctx_menu, (dialog, which) -> {
                 switch (which) {
                     case 0:
                         // show transactions
-                        activity.showAccountTransactions(acc.getName());
+                        activity.showAccountTransactions(accountName);
                         break;
                         break;
+                    default:
+                        throw new RuntimeException(
+                                String.format("Unknown menu item id (%d)", which));
                 }
                 dialog.dismiss();
             });
                 }
                 dialog.dismiss();
             });
@@ -215,6 +191,7 @@ public class AccountSummaryAdapter
         public void bindToAccount(LedgerAccount acc) {
             Context ctx = row.getContext();
             Resources rm = ctx.getResources();
         public void bindToAccount(LedgerAccount acc) {
             Context ctx = row.getContext();
             Resources rm = ctx.getResources();
+            mAccount = acc;
 
             row.setTag(acc);
 
 
             row.setTag(acc);
 
index 9f2344fb605bbad3c2a09cc3a4110b9a64108592..dbc200eb8f6e8ff592ae02048c9682efd8326877 100644 (file)
@@ -40,7 +40,8 @@ import net.ktnx.mobileledger.utils.Logger;
 
 import org.jetbrains.annotations.NotNull;
 
 
 import org.jetbrains.annotations.NotNull;
 
-import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
 
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
 
@@ -95,15 +96,17 @@ public class AccountSummaryFragment extends MobileLedgerListFragment {
             Data.scheduleTransactionListRetrieval(mainActivity);
         });
 
             Data.scheduleTransactionListRetrieval(mainActivity);
         });
 
-        Data.profile.observe(getViewLifecycleOwner(), profile -> profile.getAccounts()
-                                                                        .observe(
-                                                                                getViewLifecycleOwner(),
-                                                                                (accounts) -> onAccountsChanged(
-                                                                                        profile,
-                                                                                        accounts)));
+        MobileLedgerProfile profile = Data.profile.getValue();
+        if (profile != null) {
+            profile.getDisplayedAccounts()
+                   .observe(getViewLifecycleOwner(),
+                           (accounts) -> onAccountsChanged(profile, accounts));
+        }
     }
     }
-    private void onAccountsChanged(MobileLedgerProfile profile, ArrayList<LedgerAccount> accounts) {
-        Logger.debug("async-acc", "fragment: got new account list");
+    private void onAccountsChanged(MobileLedgerProfile profile, List<LedgerAccount> accounts) {
+        Logger.debug("async-acc",
+                String.format(Locale.US, "fragment: got new account list (%d items)",
+                        accounts.size()));
         modelAdapter.setAccounts(profile, accounts);
     }
 }
         modelAdapter.setAccounts(profile, accounts);
     }
 }
index 3a210ef07720d04b9396e5b36d37ca504952e987..33bcbb005c32a855819091f4bedebf21b4a1d802 100644 (file)
@@ -30,7 +30,6 @@ import android.os.Build;
 import android.os.Bundle;
 import android.util.Log;
 import android.view.View;
 import android.os.Bundle;
 import android.util.Log;
 import android.view.View;
-import android.view.ViewGroup;
 import android.view.animation.AnimationUtils;
 import android.widget.LinearLayout;
 import android.widget.ProgressBar;
 import android.view.animation.AnimationUtils;
 import android.widget.LinearLayout;
 import android.widget.ProgressBar;
@@ -55,7 +54,6 @@ import net.ktnx.mobileledger.R;
 import net.ktnx.mobileledger.async.RefreshDescriptionsTask;
 import net.ktnx.mobileledger.async.RetrieveTransactionsTask;
 import net.ktnx.mobileledger.model.Data;
 import net.ktnx.mobileledger.async.RefreshDescriptionsTask;
 import net.ktnx.mobileledger.async.RetrieveTransactionsTask;
 import net.ktnx.mobileledger.model.Data;
-import net.ktnx.mobileledger.model.LedgerAccount;
 import net.ktnx.mobileledger.model.MobileLedgerProfile;
 import net.ktnx.mobileledger.ui.account_summary.AccountSummaryFragment;
 import net.ktnx.mobileledger.ui.profiles.ProfileDetailFragment;
 import net.ktnx.mobileledger.model.MobileLedgerProfile;
 import net.ktnx.mobileledger.ui.account_summary.AccountSummaryFragment;
 import net.ktnx.mobileledger.ui.profiles.ProfileDetailFragment;
@@ -387,6 +385,15 @@ public class MainActivity extends ProfileThemedActivity {
      * called when the current profile has changed
      */
     private void onProfileChanged(MobileLedgerProfile profile) {
      * called when the current profile has changed
      */
     private void onProfileChanged(MobileLedgerProfile profile) {
+        if (this.profile == null) {
+            if (profile == null)
+                return;
+        }
+        else {
+            if (this.profile.equals(profile))
+                return;
+        }
+
         boolean haveProfile = profile != null;
 
         if (haveProfile)
         boolean haveProfile = profile != null;
 
         if (haveProfile)
@@ -395,7 +402,7 @@ public class MainActivity extends ProfileThemedActivity {
             setTitle(R.string.app_name);
 
         if (this.profile != null)
             setTitle(R.string.app_name);
 
         if (this.profile != null)
-            this.profile.getAccounts()
+            this.profile.getDisplayedAccounts()
                         .removeObservers(this);
 
         this.profile = profile;
                         .removeObservers(this);
 
         this.profile = profile;
@@ -525,7 +532,7 @@ public class MainActivity extends ProfileThemedActivity {
     public void onLatestTransactionsClicked(View view) {
         drawer.closeDrawers();
 
     public void onLatestTransactionsClicked(View view) {
         drawer.closeDrawers();
 
-        showTransactionsFragment((String) null);
+        showTransactionsFragment(null);
     }
     public void showTransactionsFragment(String accName) {
         Data.accountFilter.setValue(accName);
     }
     public void showTransactionsFragment(String accName) {
         Data.accountFilter.setValue(accName);
@@ -630,36 +637,8 @@ public class MainActivity extends ProfileThemedActivity {
     public void fabHide() {
         fab.hide();
     }
     public void fabHide() {
         fab.hide();
     }
-    public void onAccountSummaryRowViewClicked(View view) {
-        ViewGroup row;
-        switch (view.getId()) {
-            case R.id.account_expander:
-                row = (ViewGroup) view.getParent()
-                                      .getParent()
-                                      .getParent();
-                break;
-            case R.id.account_expander_container:
-            case R.id.account_row_acc_name:
-                row = (ViewGroup) view.getParent()
-                                      .getParent();
-                break;
-            default:
-                row = (ViewGroup) view.getParent();
-                break;
-        }
-
-        LedgerAccount acc = (LedgerAccount) row.getTag();
-        switch (view.getId()) {
-            case R.id.account_row_acc_name:
-            case R.id.account_expander:
-            case R.id.account_expander_container:
-                break;
-            case R.id.account_row_acc_amounts:
-                break;
-        }
-    }
 
 
-    public class SectionsPagerAdapter extends FragmentPagerAdapter {
+    public static class SectionsPagerAdapter extends FragmentPagerAdapter {
 
         SectionsPagerAdapter(FragmentManager fm) {
             super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
 
         SectionsPagerAdapter(FragmentManager fm) {
             super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
index 7a8dc50e6316c82fc08442a2682d6d5045684b81..13b6a8bae93ef11a42e9ab262a9b60d0103bc684 100644 (file)
@@ -1,5 +1,5 @@
 /*
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2020 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -61,6 +61,7 @@ import org.jetbrains.annotations.NotNull;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.ArrayList;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.ArrayList;
+import java.util.UUID;
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
 
 
 import static net.ktnx.mobileledger.utils.Logger.debug;
 
@@ -391,8 +392,7 @@ public class ProfileDetailFragment extends Fragment {
 
         huePickerView.setOnClickListener(v -> {
             HueRingDialog d = new HueRingDialog(ProfileDetailFragment.this.requireContext(),
 
         huePickerView.setOnClickListener(v -> {
             HueRingDialog d = new HueRingDialog(ProfileDetailFragment.this.requireContext(),
-                    model.initialThemeHue,
-                    (Integer) v.getTag());
+                    model.initialThemeHue, (Integer) v.getTag());
             d.show();
             d.setColorSelectedListener(model::setThemeId);
         });
             d.show();
             d.setColorSelectedListener(model::setThemeId);
         });
@@ -439,7 +439,7 @@ public class ProfileDetailFragment extends Fragment {
             triggerProfileChange();
         }
         else {
             triggerProfileChange();
         }
         else {
-            mProfile = new MobileLedgerProfile();
+            mProfile = new MobileLedgerProfile(String.valueOf(UUID.randomUUID()));
             model.updateProfile(mProfile);
             mProfile.storeInDB();
             final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
             model.updateProfile(mProfile);
             mProfile.storeInDB();
             final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
index c61b9193d69e5071c781d43e122a4ab648524189..d251cc8dc79962110cf42b9f72f62c481c9a6e85 100644 (file)
@@ -33,6 +33,8 @@ import android.widget.TextView;
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.AsyncListDiffer;
+import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.RecyclerView;
 
 import net.ktnx.mobileledger.App;
 import androidx.recyclerview.widget.RecyclerView;
 
 import net.ktnx.mobileledger.App;
@@ -40,6 +42,7 @@ import net.ktnx.mobileledger.R;
 import net.ktnx.mobileledger.model.Data;
 import net.ktnx.mobileledger.model.LedgerTransaction;
 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
 import net.ktnx.mobileledger.model.Data;
 import net.ktnx.mobileledger.model.LedgerTransaction;
 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
+import net.ktnx.mobileledger.model.MobileLedgerProfile;
 import net.ktnx.mobileledger.model.TransactionListItem;
 import net.ktnx.mobileledger.utils.Colors;
 import net.ktnx.mobileledger.utils.Globals;
 import net.ktnx.mobileledger.model.TransactionListItem;
 import net.ktnx.mobileledger.utils.Colors;
 import net.ktnx.mobileledger.utils.Globals;
@@ -48,9 +51,53 @@ import net.ktnx.mobileledger.utils.SimpleDate;
 
 import java.text.DateFormat;
 import java.util.GregorianCalendar;
 
 import java.text.DateFormat;
 import java.util.GregorianCalendar;
+import java.util.Locale;
 import java.util.TimeZone;
 
 public class TransactionListAdapter extends RecyclerView.Adapter<TransactionRowHolder> {
 import java.util.TimeZone;
 
 public class TransactionListAdapter extends RecyclerView.Adapter<TransactionRowHolder> {
+    private MobileLedgerProfile profile;
+    private AsyncListDiffer<TransactionListItem> listDiffer;
+    public TransactionListAdapter() {
+        super();
+        listDiffer = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback<TransactionListItem>() {
+            @Override
+            public boolean areItemsTheSame(@NonNull TransactionListItem oldItem,
+                                           @NonNull TransactionListItem newItem) {
+                if (oldItem.getType() != newItem.getType())
+                    return false;
+                switch (oldItem.getType()) {
+                    case DELIMITER:
+                        return (oldItem.getDate()
+                                       .equals(newItem.getDate()));
+                    case TRANSACTION:
+                        return oldItem.getTransaction()
+                                      .getId() == newItem.getTransaction()
+                                                         .getId();
+                    default:
+                        throw new IllegalStateException(
+                                String.format(Locale.US, "Unexpected transaction item type %s",
+                                        oldItem.getType()));
+                }
+            }
+            @Override
+            public boolean areContentsTheSame(@NonNull TransactionListItem oldItem,
+                                              @NonNull TransactionListItem newItem) {
+                switch (oldItem.getType()) {
+                    case DELIMITER:
+                        // Delimiters items are "same" for same dates and the contents are the date
+                        return true;
+                    case TRANSACTION:
+                        return oldItem.getTransaction()
+                                      .equals(newItem.getTransaction());
+                    default:
+                        throw new IllegalStateException(
+                                String.format(Locale.US, "Unexpected transaction item type %s",
+                                        oldItem.getType()));
+
+                }
+            }
+        });
+    }
     public void onBindViewHolder(@NonNull TransactionRowHolder holder, int position) {
         TransactionListItem item = TransactionListViewModel.getTransactionListItem(position);
 
     public void onBindViewHolder(@NonNull TransactionRowHolder holder, int position) {
         TransactionListItem item = TransactionListViewModel.getTransactionListItem(position);
 
index 393a9cdce68ec3dfa864e98462b0c9e24df58599..d72b12e5341d1764cc3dbd8decc98e8f93f21f43 100644 (file)
@@ -1,5 +1,5 @@
 /*
 /*
- * Copyright © 2019 Damyan Ivanov.
+ * Copyright © 2020 Damyan Ivanov.
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
  * This file is part of MoLe.
  * MoLe is free software: you can distribute it and/or modify it
  * under the term of the GNU General Public License as published by
@@ -41,7 +41,7 @@ public class ObservableValue<T> {
     public void notifyObservers() {
         impl.notifyObservers();
     }
     public void notifyObservers() {
         impl.notifyObservers();
     }
-    public void notifyObservers(Object arg) {
+    public void notifyObservers(T arg) {
         impl.notifyObservers(arg);
     }
     public void deleteObservers() {
         impl.notifyObservers(arg);
     }
     public void deleteObservers() {
diff --git a/app/src/test/java/net/ktnx/mobileledger/model/LedgerAccountTest.java b/app/src/test/java/net/ktnx/mobileledger/model/LedgerAccountTest.java
new file mode 100644 (file)
index 0000000..438e971
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe 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 terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.model;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class LedgerAccountTest {
+
+    @Test
+    public void extractParentName() {
+        assertNull(LedgerAccount.extractParentName("Top-level Account"));
+        assertEquals("top", LedgerAccount.extractParentName("top:second"));
+        assertEquals("top:second level", LedgerAccount.extractParentName("top:second level:leaf"));
+    }
+}
\ No newline at end of file
diff --git a/app/src/test/java/net/ktnx/mobileledger/model/MobileLedgerProfileTest.java b/app/src/test/java/net/ktnx/mobileledger/model/MobileLedgerProfileTest.java
new file mode 100644 (file)
index 0000000..8416c10
--- /dev/null
@@ -0,0 +1,90 @@
+/*
+ * Copyright © 2020 Damyan Ivanov.
+ * This file is part of MoLe.
+ * MoLe is free software: you can distribute it and/or modify it
+ * under the term of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your opinion), any later version.
+ *
+ * MoLe 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 terms for details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package net.ktnx.mobileledger.model;
+
+import org.junit.Test;
+import org.junit.internal.ArrayComparisonFailure;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+
+public class MobileLedgerProfileTest {
+    private List<LedgerAccount> listFromArray(LedgerAccount[] array) {
+        ArrayList<LedgerAccount> result = new ArrayList<>();
+        Collections.addAll(result, array);
+
+        return result;
+    }
+    private void aTest(LedgerAccount[] oldList, LedgerAccount[] newList,
+                       LedgerAccount[] expectedResult) {
+        List<LedgerAccount> result = MobileLedgerProfile.mergeAccountLists(listFromArray(oldList),
+                listFromArray(newList));
+        assertArrayEquals(expectedResult, result.toArray());
+    }
+    private void negTest(LedgerAccount[] oldList, LedgerAccount[] newList,
+                         LedgerAccount[] expectedResult) {
+        List<LedgerAccount> result = MobileLedgerProfile.mergeAccountLists(listFromArray(oldList),
+                listFromArray(newList));
+        assertThrows(ArrayComparisonFailure.class,
+                () -> assertArrayEquals(expectedResult, result.toArray()));
+    }
+    private LedgerAccount[] emptyArray() {
+        return new LedgerAccount[]{};
+    }
+    @Test
+    public void mergeEmptyLists() {
+        aTest(emptyArray(), emptyArray(), emptyArray());
+    }
+    @Test
+    public void mergeIntoEmptyLists() {
+        LedgerAccount acc1 = new LedgerAccount(null, "Acc1", null);
+        aTest(emptyArray(), new LedgerAccount[]{acc1}, new LedgerAccount[]{acc1});
+    }
+    @Test
+    public void mergeEmptyList() {
+        LedgerAccount acc1 = new LedgerAccount(null, "Acc1", null);
+        aTest(new LedgerAccount[]{acc1}, emptyArray(), emptyArray());
+    }
+    @Test
+    public void mergeEqualLists() {
+        LedgerAccount acc1 = new LedgerAccount(null, "Acc1", null);
+        aTest(new LedgerAccount[]{acc1}, new LedgerAccount[]{acc1}, new LedgerAccount[]{acc1});
+    }
+    @Test
+    public void mergeFlags() {
+        LedgerAccount acc1a = new LedgerAccount(null, "Acc1", null);
+        LedgerAccount acc1b = new LedgerAccount(null, "Acc1", null);
+        acc1b.setExpanded(true);
+        acc1b.setAmountsExpanded(true);
+        List<LedgerAccount> merged =
+                MobileLedgerProfile.mergeAccountLists(listFromArray(new LedgerAccount[]{acc1a}),
+                        listFromArray(new LedgerAccount[]{acc1b}));
+        assertArrayEquals(new LedgerAccount[]{acc1b}, merged.toArray());
+        assertSame(merged.get(0), acc1a);
+        // restore original values, modified by the merge
+        acc1a.setExpanded(false);
+        acc1a.setAmountsExpanded(false);
+        negTest(new LedgerAccount[]{acc1a}, new LedgerAccount[]{acc1b},
+                new LedgerAccount[]{new LedgerAccount(null, "Acc1", null)});
+    }
+}
\ No newline at end of file