From 0a73337c99e2074aa7e7228204289896342ec636 Mon Sep 17 00:00:00 2001 From: Damyan Ivanov Date: Sat, 1 Aug 2020 16:27:03 +0300 Subject: [PATCH] rework account list management to be fully asynchronous 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) --- .../async/RetrieveTransactionsTask.java | 673 +++++++----------- .../mobileledger/model/LedgerAccount.java | 88 ++- .../ktnx/mobileledger/model/LedgerAmount.java | 24 +- .../mobileledger/model/LedgerTransaction.java | 23 + .../model/MobileLedgerProfile.java | 582 ++++++++++++--- .../AccountSummaryAdapter.java | 101 +-- .../AccountSummaryFragment.java | 21 +- .../ui/activity/MainActivity.java | 45 +- .../ui/profiles/ProfileDetailFragment.java | 8 +- .../TransactionListAdapter.java | 47 ++ .../mobileledger/utils/ObservableValue.java | 4 +- .../mobileledger/model/LedgerAccountTest.java | 33 + .../model/MobileLedgerProfileTest.java | 90 +++ 13 files changed, 1070 insertions(+), 669 deletions(-) create mode 100644 app/src/test/java/net/ktnx/mobileledger/model/LedgerAccountTest.java create mode 100644 app/src/test/java/net/ktnx/mobileledger/model/MobileLedgerProfileTest.java diff --git a/app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java b/app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java index ef6eb817..5360cd00 100644 --- a/app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java +++ b/app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java @@ -52,12 +52,10 @@ import java.text.ParseException; 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 static net.ktnx.mobileledger.utils.Logger.debug; - public class RetrieveTransactionsTask extends AsyncTask { @@ -94,7 +92,7 @@ public class RetrieveTransactionsTask 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; @@ -109,7 +107,7 @@ public class RetrieveTransactionsTask 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; @@ -147,14 +145,15 @@ public class RetrieveTransactionsTask 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; - ArrayList accountList = new ArrayList<>(); - HashMap accountNames = new HashMap<>(); - HashMap syntheticAccounts = new HashMap<>(); - LedgerAccount lastAccount = null, prevAccount = null; + ArrayList list = new ArrayList<>(); + HashMap map = new HashMap<>(); + ArrayList displayed = new ArrayList<>(); + ArrayList transactions = new ArrayList<>(); + LedgerAccount lastAccount = null; + ArrayList syntheticAccounts = new ArrayList<>(); 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()); - SQLiteDatabase db = App.getDatabase(); 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"); - continue; - } - //L(String.format("State is %d", updating)); - switch (state) { - case EXPECTING_ACCOUNT: - if (line.equals("

General Journal

")) { - 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("

General Journal

")) { + 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; } - 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 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 @@ -400,47 +338,53 @@ public class RetrieveTransactionsTask // "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 map, + ArrayList 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(); @@ -457,85 +401,85 @@ public class RetrieveTransactionsTask } publishProgress(progress); SQLiteDatabase db = App.getDatabase(); - ArrayList accountList = new ArrayList<>(); - boolean listFilledOK = false; + ArrayList list = new ArrayList<>(); + HashMap map = new HashMap<>(); + HashMap 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())); - 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 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(); - 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(); - int maxTransactionId = Progress.INDETERMINATE; + int maxTransactionId = Data.transactions.size(); HttpURLConnection http = NetworkUtil.prepareConnection(profile, "transactions"); http.setAllowUserInteraction(false); @@ -548,93 +492,30 @@ public class RetrieveTransactionsTask default: 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())); 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 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(); - profile.deleteNotPresentTransactions(db); + ParsedLedgerTransaction parsedTransaction = parser.nextTransaction(); 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; @@ -680,14 +561,12 @@ public class RetrieveTransactionsTask if (isCancelled()) throw new OperationCanceledException(null); } - enum DetectedTransactionOrder {UNKNOWN, REVERSE_CHRONOLOGICAL, FILE} - 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 } - public class Progress { + public static class Progress { 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); } diff --git a/app/src/main/java/net/ktnx/mobileledger/model/LedgerAccount.java b/app/src/main/java/net/ktnx/mobileledger/model/LedgerAccount.java index 19a33ad6..92ce6c80 100644 --- a/app/src/main/java/net/ktnx/mobileledger/model/LedgerAccount.java +++ b/app/src/main/java/net/ktnx/mobileledger/model/LedgerAccount.java @@ -23,7 +23,6 @@ import androidx.annotation.Nullable; 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 { @@ -31,26 +30,32 @@ public class LedgerAccount { private String name; private String shortName; private int level; - private String parentName; + private LedgerAccount parent; private boolean expanded; private List amounts; private boolean hasSubAccounts; private boolean amountsExpanded; private WeakReference profileWeakReference; - public LedgerAccount(MobileLedgerProfile profile, String name) { + public LedgerAccount(MobileLedgerProfile profile, String name, @Nullable LedgerAccount parent) { 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); } - - 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 @@ -62,42 +67,40 @@ public class LedgerAccount { 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: - // - it has an expanded parent or is a top account - public boolean isVisible(List 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() { - 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; @@ -157,7 +160,7 @@ public class LedgerAccount { } public String getParentName() { - return parentName; + return (parent == null) ? null : parent.getName(); } 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 propagateAmountsTo(LedgerAccount acc) { + for (LedgerAmount a : amounts) + a.propagateToAccount(acc); + } + public List getAmounts() { + return amounts; + } } diff --git a/app/src/main/java/net/ktnx/mobileledger/model/LedgerAmount.java b/app/src/main/java/net/ktnx/mobileledger/model/LedgerAmount.java index 6aebdb50..3ecbc072 100644 --- a/app/src/main/java/net/ktnx/mobileledger/model/LedgerAmount.java +++ b/app/src/main/java/net/ktnx/mobileledger/model/LedgerAmount.java @@ -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 @@ -18,6 +18,7 @@ package net.ktnx.mobileledger.model; import android.annotation.SuppressLint; + import androidx.annotation.NonNull; public class LedgerAmount { @@ -30,8 +31,7 @@ public class LedgerAmount { this.amount = amount; } - public - LedgerAmount(float amount) { + public LedgerAmount(float amount) { this.amount = amount; this.currency = null; } @@ -39,7 +39,21 @@ public class LedgerAmount { @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; } } diff --git a/app/src/main/java/net/ktnx/mobileledger/model/LedgerTransaction.java b/app/src/main/java/net/ktnx/mobileledger/model/LedgerTransaction.java index 04b63b64..638a692c 100644 --- a/app/src/main/java/net/ktnx/mobileledger/model/LedgerTransaction.java +++ b/app/src/main/java/net/ktnx/mobileledger/model/LedgerTransaction.java @@ -219,4 +219,27 @@ public class LedgerTransaction { 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; + } } diff --git a/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java b/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java index fc940e9a..6b847ec1 100644 --- a/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java +++ b/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java @@ -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.os.Build; +import android.text.TextUtils; import android.util.SparseArray; -import androidx.annotation.NonNull; 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.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 org.jetbrains.annotations.Contract; + 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.UUID; +import java.util.Map; +import java.util.Objects; import static net.ktnx.mobileledger.utils.Logger.debug; public final class MobileLedgerProfile { + private final MutableLiveData> displayedAccounts; + private final MutableLiveData> allTransactions; + private final MutableLiveData> displayedTransactions; // 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 allAccounts; 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; - // 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 MutableLiveData> 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 accountMap = new HashMap<>(); 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; @@ -91,6 +110,13 @@ public final class MobileLedgerProfile { 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 @@ -147,8 +173,107 @@ public final class MobileLedgerProfile { db.endTransaction(); } } - public LiveData> getAccounts() { - return accounts; + public static ArrayList mergeAccountLists(List oldList, + List newList) { + LedgerAccount oldAcc, newAcc; + ArrayList merged = new ArrayList<>(); + + Iterator oldIterator = oldList.iterator(); + Iterator 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 newList) { + + try (LockHolder l = accountsLocker.lockForWriting()) { + allAccounts = mergeAccountLists(allAccounts, newList); + updateAccountsMap(allAccounts); + } + } + public LiveData> 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"); @@ -294,17 +419,24 @@ public final class MobileLedgerProfile { 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 - 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 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, " + - "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(), - acc.getLevel(), acc.isExpanded() + acc.getLevel() }); // debug("accounts", String.format("Stored account '%s' in DB [%s]", acc.getName(), uuid)); } @@ -407,53 +539,6 @@ public final class MobileLedgerProfile { 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()); @@ -475,72 +560,36 @@ public final class MobileLedgerProfile { 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}); } - 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}); } - 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() }); } - 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() }); } - public void deleteNotPresentTransactions(SQLiteDatabase db) { + private void deleteNotPresentTransactions(SQLiteDatabase db) { 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); } - public List loadChildAccountsOf(LedgerAccount acc) { - List 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 loadVisibleChildAccountsOf(LedgerAccount acc) { - List result = new ArrayList<>(); - ArrayList 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(); @@ -600,9 +649,119 @@ public final class MobileLedgerProfile { public Calendar getLastTransactionDate() { return lastTransactionDate; } - public void setAccounts(ArrayList list) { - accounts.postValue(list); + private void applyTransactionFilter(List list) { + final String accFilter = Data.accountFilter.getValue(); + if (TextUtils.isEmpty(accFilter)) { + displayedTransactions.postValue(list); + } + else { + ArrayList newList = new ArrayList<>(); + for (LedgerTransaction tr : list) { + if (tr.hasAccountNamedLike(accFilter)) + newList.add(tr); + } + displayedTransactions.postValue(newList); + } + } + synchronized public void storeAccountListAsync(List list, + boolean storeUiFields) { + if (accountListSaver != null) + accountListSaver.interrupt(); + accountListSaver = new AccountListSaver(this, list, storeUiFields); + accountListSaver.start(); + } + public void setAndStoreAccountListFromWeb(ArrayList 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 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 list) { + if (transactionListSaver != null) + transactionListSaver.interrupt(); + + transactionListSaver = new TransactionListSaver(this, list); + transactionListSaver.start(); + } + public void setAndStoreAccountAndTransactionListFromWeb(List accounts, + List transactions) { + storeAccountAndTransactionListAsync(accounts, transactions, false); + + mergeAccountList(accounts); + updateDisplayedAccounts(); + + allTransactions.postValue(transactions); + } + private void storeAccountAndTransactionListAsync(List accounts, + List 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 getAllAccounts() { + return allAccounts; + } + private void updateAccountsMap(List 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); @@ -657,13 +816,17 @@ public final class MobileLedgerProfile { public void run() { Logger.debug("async-acc", "AccountListLoader::run() entered"); String profileUUID = profile.getUuid(); - ArrayList newList = new ArrayList<>(); + ArrayList list = new ArrayList<>(); + HashMap 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(); + Logger.debug("async-acc", "AccountListLoader::run() connected to DB"); try (Cursor cursor = db.rawQuery(sql, new String[]{profileUUID})) { + Logger.debug("async-acc", "AccountListLoader::run() executed query"); 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)); - 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"); - profile.accounts.postValue(newList); + profile.allAccounts = list; + profile.updateAccountsMap(list); + profile.updateDisplayedAccounts(); + } + } + + static class AccountListDisplayedFilter extends Thread { + private final MobileLedgerProfile profile; + private final List list; + AccountListDisplayedFilter(MobileLedgerProfile profile, List list) { + this.profile = profile; + this.list = list; + } + @Override + public void run() { + List 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 list; + private final boolean storeUiFields; + AccountListSaver(MobileLedgerProfile profile, List 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 list; + TransactionListSaver(MobileLedgerProfile profile, List 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 accounts; + private final List transactions; + private final boolean storeAccUiFields; + AccountAndTransactionListSaver(MobileLedgerProfile profile, List accounts, + List 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(); + } } } } diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryAdapter.java index c0ed9bd7..fbbc47d7 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryAdapter.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryAdapter.java @@ -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.util.Log; 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.utils.Locker; import org.jetbrains.annotations.NotNull; -import java.util.ArrayList; +import java.util.List; 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) { - 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(); } - public void setAccounts(MobileLedgerProfile profile, ArrayList newList) { + public void setAccounts(MobileLedgerProfile profile, List 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; + LedgerAccount mAccount; 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); @@ -118,94 +118,70 @@ public class AccountSummaryAdapter expander.setOnClickListener(v -> toggleAccountExpanded()); tvAccountAmounts.setOnClickListener(v -> toggleAmountsExpanded()); } - private @NonNull - LedgerAccount getAccount() { - final ArrayList accountList = profile.getAccounts() - .getValue(); - if (accountList == null) - throw new IllegalStateException("No account list"); - - return accountList.get(getAdapterPosition()); - } private void toggleAccountExpanded() { - LedgerAccount acc = getAccount(); - if (!acc.hasSubAccounts()) + if (!mAccount.hasSubAccounts()) 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; + } + 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=?", - new Object[]{acc.isExpanded(), acc.getName(), profile.getUuid() - }, profile::scheduleAccountListReload); + new Object[]{mAccount.isExpanded(), mAccount.getName(), profile.getUuid() + }); } private void toggleAmountsExpanded() { - LedgerAccount acc = getAccount(); - if (acc.getAmountCount() <= AMOUNT_LIMIT) + if (mAccount.getAmountCount() <= AMOUNT_LIMIT) return; - acc.toggleAmountsExpanded(); - if (acc.amountsExpanded()) { - tvAccountAmounts.setText(acc.getAmountsString()); + mAccount.toggleAmountsExpanded(); + if (mAccount.amountsExpanded()) { + tvAccountAmounts.setText(mAccount.getAmountsString()); accountExpanderContainer.setVisibility(View.GONE); } else { - tvAccountAmounts.setText(acc.getAmountsString(AMOUNT_LIMIT)); + tvAccountAmounts.setText(mAccount.getAmountsString(AMOUNT_LIMIT)); 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=?", - 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); - 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 - activity.showAccountTransactions(acc.getName()); + activity.showAccountTransactions(accountName); break; + default: + throw new RuntimeException( + String.format("Unknown menu item id (%d)", which)); } dialog.dismiss(); }); @@ -215,6 +191,7 @@ public class AccountSummaryAdapter public void bindToAccount(LedgerAccount acc) { Context ctx = row.getContext(); Resources rm = ctx.getResources(); + mAccount = acc; row.setTag(acc); diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryFragment.java index 9f2344fb..dbc200eb 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryFragment.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryFragment.java @@ -40,7 +40,8 @@ import net.ktnx.mobileledger.utils.Logger; 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; @@ -95,15 +96,17 @@ public class AccountSummaryFragment extends MobileLedgerListFragment { 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 accounts) { - Logger.debug("async-acc", "fragment: got new account list"); + private void onAccountsChanged(MobileLedgerProfile profile, List accounts) { + Logger.debug("async-acc", + String.format(Locale.US, "fragment: got new account list (%d items)", + accounts.size())); modelAdapter.setAccounts(profile, accounts); } } diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java index 3a210ef0..33bcbb00 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java @@ -30,7 +30,6 @@ import android.os.Build; 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; @@ -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.model.LedgerAccount; 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) { + if (this.profile == null) { + if (profile == null) + return; + } + else { + if (this.profile.equals(profile)) + return; + } + boolean haveProfile = profile != null; if (haveProfile) @@ -395,7 +402,7 @@ public class MainActivity extends ProfileThemedActivity { setTitle(R.string.app_name); if (this.profile != null) - this.profile.getAccounts() + this.profile.getDisplayedAccounts() .removeObservers(this); this.profile = profile; @@ -525,7 +532,7 @@ public class MainActivity extends ProfileThemedActivity { public void onLatestTransactionsClicked(View view) { drawer.closeDrawers(); - showTransactionsFragment((String) null); + showTransactionsFragment(null); } 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 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); diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java index 7a8dc50e..13b6a8ba 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfileDetailFragment.java @@ -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 @@ -61,6 +61,7 @@ import org.jetbrains.annotations.NotNull; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; +import java.util.UUID; 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(), - model.initialThemeHue, - (Integer) v.getTag()); + model.initialThemeHue, (Integer) v.getTag()); d.show(); d.setColorSelectedListener(model::setThemeId); }); @@ -439,7 +439,7 @@ public class ProfileDetailFragment extends Fragment { triggerProfileChange(); } else { - mProfile = new MobileLedgerProfile(); + mProfile = new MobileLedgerProfile(String.valueOf(UUID.randomUUID())); model.updateProfile(mProfile); mProfile.storeInDB(); final ArrayList profiles = Data.profiles.getValue(); diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListAdapter.java index c61b9193..d251cc8d 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListAdapter.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListAdapter.java @@ -33,6 +33,8 @@ import android.widget.TextView; 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; @@ -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.MobileLedgerProfile; 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.util.Locale; import java.util.TimeZone; public class TransactionListAdapter extends RecyclerView.Adapter { + private MobileLedgerProfile profile; + private AsyncListDiffer listDiffer; + public TransactionListAdapter() { + super(); + listDiffer = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback() { + @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); diff --git a/app/src/main/java/net/ktnx/mobileledger/utils/ObservableValue.java b/app/src/main/java/net/ktnx/mobileledger/utils/ObservableValue.java index 393a9cdc..d72b12e5 100644 --- a/app/src/main/java/net/ktnx/mobileledger/utils/ObservableValue.java +++ b/app/src/main/java/net/ktnx/mobileledger/utils/ObservableValue.java @@ -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 @@ -41,7 +41,7 @@ public class ObservableValue { public void notifyObservers() { impl.notifyObservers(); } - public void notifyObservers(Object arg) { + public void notifyObservers(T arg) { 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 index 00000000..438e9716 --- /dev/null +++ b/app/src/test/java/net/ktnx/mobileledger/model/LedgerAccountTest.java @@ -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 . + */ + +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 index 00000000..8416c101 --- /dev/null +++ b/app/src/test/java/net/ktnx/mobileledger/model/MobileLedgerProfileTest.java @@ -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 . + */ + +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 listFromArray(LedgerAccount[] array) { + ArrayList result = new ArrayList<>(); + Collections.addAll(result, array); + + return result; + } + private void aTest(LedgerAccount[] oldList, LedgerAccount[] newList, + LedgerAccount[] expectedResult) { + List result = MobileLedgerProfile.mergeAccountLists(listFromArray(oldList), + listFromArray(newList)); + assertArrayEquals(expectedResult, result.toArray()); + } + private void negTest(LedgerAccount[] oldList, LedgerAccount[] newList, + LedgerAccount[] expectedResult) { + List 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 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 -- 2.39.2