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)
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<Void, RetrieveTransactionsTask.Progress, String> {
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;
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;
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<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);
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("<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;
}
- 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
// "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();
}
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()));
- 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();
- 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);
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<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();
- 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;
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;
}
}
- private class TransactionParserException extends IllegalStateException {
+ private static class TransactionParserException extends IllegalStateException {
TransactionParserException(String message) {
super(message);
}
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 {
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;
- 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
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<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() {
- 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 getParentName() {
- return parentName;
+ return (parent == null) ? null : parent.getName();
}
public boolean hasSubAccounts() {
return hasSubAccounts;
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;
+ }
}
/*
- * 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
package net.ktnx.mobileledger.model;
import android.annotation.SuppressLint;
+
import androidx.annotation.NonNull;
public class LedgerAmount {
this.amount = amount;
}
- public
- LedgerAmount(float amount) {
+ public LedgerAmount(float amount) {
this.amount = amount;
this.currency = null;
}
@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;
}
}
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;
+ }
}
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;
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<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
- 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 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<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;
+ allAccounts = new ArrayList<>();
+ displayedAccounts = new MutableLiveData<>();
+ allTransactions = new MutableLiveData<>(new ArrayList<>());
+ displayedTransactions = new MutableLiveData<>(new ArrayList<>());
}
public MobileLedgerProfile(MobileLedgerProfile origin) {
uuid = origin.uuid;
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
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");
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<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, " +
- "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));
}
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());
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<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 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 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();
+ 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;
// 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<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();
+ }
}
}
}
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 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;
@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);
}
});
}
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);
}
- 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);
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() {
- 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();
});
public void bindToAccount(LedgerAccount acc) {
Context ctx = row.getContext();
Resources rm = ctx.getResources();
+ mAccount = acc;
row.setTag(acc);
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;
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);
}
}
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 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;
* 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)
setTitle(R.string.app_name);
if (this.profile != null)
- this.profile.getAccounts()
+ this.profile.getDisplayedAccounts()
.removeObservers(this);
this.profile = profile;
public void onLatestTransactionsClicked(View view) {
drawer.closeDrawers();
- showTransactionsFragment((String) null);
+ showTransactionsFragment(null);
}
public void showTransactionsFragment(String accName) {
Data.accountFilter.setValue(accName);
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);
/*
- * 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
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
+import java.util.UUID;
import static net.ktnx.mobileledger.utils.Logger.debug;
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);
});
triggerProfileChange();
}
else {
- mProfile = new MobileLedgerProfile();
+ mProfile = new MobileLedgerProfile(String.valueOf(UUID.randomUUID()));
model.updateProfile(mProfile);
mProfile.storeInDB();
final ArrayList<MobileLedgerProfile> profiles = Data.profiles.getValue();
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 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 java.text.DateFormat;
import java.util.GregorianCalendar;
+import java.util.Locale;
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);
/*
- * 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
public void notifyObservers() {
impl.notifyObservers();
}
- public void notifyObservers(Object arg) {
+ public void notifyObservers(T arg) {
impl.notifyObservers(arg);
}
public void deleteObservers() {
--- /dev/null
+/*
+ * 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
--- /dev/null
+/*
+ * 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