]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java
new account rows are created without amount hint
[mobile-ledger.git] / app / src / main / java / net / ktnx / mobileledger / async / RetrieveTransactionsTask.java
1 /*
2  * Copyright © 2020 Damyan Ivanov.
3  * This file is part of MoLe.
4  * MoLe is free software: you can distribute it and/or modify it
5  * under the term of the GNU General Public License as published by
6  * the Free Software Foundation, either version 3 of the License, or
7  * (at your opinion), any later version.
8  *
9  * MoLe is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12  * GNU General Public License terms for details.
13  *
14  * You should have received a copy of the GNU General Public License
15  * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
16  */
17
18 package net.ktnx.mobileledger.async;
19
20 import android.annotation.SuppressLint;
21 import android.database.sqlite.SQLiteDatabase;
22 import android.os.AsyncTask;
23 import android.os.OperationCanceledException;
24
25 import androidx.annotation.NonNull;
26
27 import com.fasterxml.jackson.databind.RuntimeJsonMappingException;
28
29 import net.ktnx.mobileledger.App;
30 import net.ktnx.mobileledger.err.HTTPException;
31 import net.ktnx.mobileledger.json.API;
32 import net.ktnx.mobileledger.json.AccountListParser;
33 import net.ktnx.mobileledger.json.ApiNotSupportedException;
34 import net.ktnx.mobileledger.json.TransactionListParser;
35 import net.ktnx.mobileledger.model.Data;
36 import net.ktnx.mobileledger.model.LedgerAccount;
37 import net.ktnx.mobileledger.model.LedgerTransaction;
38 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
39 import net.ktnx.mobileledger.model.MobileLedgerProfile;
40 import net.ktnx.mobileledger.ui.MainModel;
41 import net.ktnx.mobileledger.utils.Logger;
42 import net.ktnx.mobileledger.utils.NetworkUtil;
43
44 import java.io.BufferedReader;
45 import java.io.IOException;
46 import java.io.InputStream;
47 import java.io.InputStreamReader;
48 import java.net.HttpURLConnection;
49 import java.net.MalformedURLException;
50 import java.net.URLDecoder;
51 import java.nio.charset.StandardCharsets;
52 import java.text.ParseException;
53 import java.util.ArrayList;
54 import java.util.Collections;
55 import java.util.HashMap;
56 import java.util.List;
57 import java.util.Locale;
58 import java.util.Objects;
59 import java.util.regex.Matcher;
60 import java.util.regex.Pattern;
61
62
63 public class RetrieveTransactionsTask extends
64         AsyncTask<Void, RetrieveTransactionsTask.Progress, RetrieveTransactionsTask.Result> {
65     private static final int MATCHING_TRANSACTIONS_LIMIT = 150;
66     private static final Pattern reComment = Pattern.compile("^\\s*;");
67     private static final Pattern reTransactionStart = Pattern.compile(
68             "<tr class=\"title\" " + "id=\"transaction-(\\d+)" + "\"><td class=\"date" +
69             "\"[^\"]*>([\\d.-]+)</td>");
70     private static final Pattern reTransactionDescription =
71             Pattern.compile("<tr class=\"posting\" title=\"(\\S+)\\s(.+)");
72     private static final Pattern reTransactionDetails = Pattern.compile(
73             "^\\s+" + "([!*]\\s+)?" + "(\\S[\\S\\s]+\\S)\\s\\s+" + "(?:([^\\d\\s+\\-]+)\\s*)?" +
74             "([-+]?\\d[\\d,.]*)" + "(?:\\s*([^\\d\\s+\\-]+)\\s*$)?");
75     private static final Pattern reEnd = Pattern.compile("\\bid=\"addmodal\"");
76     private static final Pattern reDecimalPoint = Pattern.compile("\\.\\d\\d?$");
77     private static final Pattern reDecimalComma = Pattern.compile(",\\d\\d?$");
78     // %3A is '='
79     private final Pattern reAccountName =
80             Pattern.compile("/register\\?q=inacct%3A([a-zA-Z0-9%]+)\"");
81     private final Pattern reAccountValue = Pattern.compile(
82             "<span class=\"[^\"]*\\bamount\\b[^\"]*\">\\s*([-+]?[\\d.,]+)(?:\\s+(\\S+))?</span>");
83     private final MainModel mainModel;
84     private final MobileLedgerProfile profile;
85     private final List<LedgerAccount> prevAccounts;
86     private int expectedPostingsCount = -1;
87     public RetrieveTransactionsTask(@NonNull MainModel mainModel,
88                                     @NonNull MobileLedgerProfile profile,
89                                     List<LedgerAccount> accounts) {
90         this.mainModel = mainModel;
91         this.profile = profile;
92         this.prevAccounts = accounts;
93     }
94     private static void L(String msg) {
95         //debug("transaction-parser", msg);
96     }
97     static LedgerTransactionAccount parseTransactionAccountLine(String line) {
98         Matcher m = reTransactionDetails.matcher(line);
99         if (m.find()) {
100             String postingStatus = m.group(1);
101             String acc_name = m.group(2);
102             String currencyPre = m.group(3);
103             String amount = Objects.requireNonNull(m.group(4));
104             String currencyPost = m.group(5);
105
106             String currency = null;
107             if ((currencyPre != null) && (currencyPre.length() > 0)) {
108                 if ((currencyPost != null) && (currencyPost.length() > 0))
109                     return null;
110                 currency = currencyPre;
111             }
112             else if ((currencyPost != null) && (currencyPost.length() > 0)) {
113                 currency = currencyPost;
114             }
115
116             amount = amount.replace(',', '.');
117
118             return new LedgerTransactionAccount(acc_name, Float.parseFloat(amount), currency, null);
119         }
120         else {
121             return null;
122         }
123     }
124     public MobileLedgerProfile getProfile() {
125         return profile;
126     }
127     @Override
128     protected void onProgressUpdate(Progress... values) {
129         super.onProgressUpdate(values);
130         Data.backgroundTaskProgress.postValue(values[0]);
131     }
132     @Override
133     protected void onPostExecute(Result result) {
134         super.onPostExecute(result);
135         Progress progress = new Progress();
136         progress.setState(ProgressState.FINISHED);
137         progress.setError(result.error);
138         onProgressUpdate(progress);
139     }
140     @Override
141     protected void onCancelled() {
142         super.onCancelled();
143         Progress progress = new Progress();
144         progress.setState(ProgressState.FINISHED);
145         onProgressUpdate(progress);
146     }
147     private void retrieveTransactionListLegacy(List<LedgerAccount> accounts,
148                                                List<LedgerTransaction> transactions)
149             throws IOException, HTTPException {
150         Progress progress = Progress.indeterminate();
151         progress.setState(ProgressState.RUNNING);
152         progress.setTotal(expectedPostingsCount);
153         int maxTransactionId = -1;
154         HashMap<String, LedgerAccount> map = new HashMap<>();
155         LedgerAccount lastAccount = null;
156         ArrayList<LedgerAccount> syntheticAccounts = new ArrayList<>();
157
158         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "journal");
159         http.setAllowUserInteraction(false);
160         publishProgress(progress);
161         if (http.getResponseCode() != 200)
162             throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
163
164         try (InputStream resp = http.getInputStream()) {
165             if (http.getResponseCode() != 200)
166                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
167
168             int matchedTransactionsCount = 0;
169
170             ParserState state = ParserState.EXPECTING_ACCOUNT;
171             String line;
172             BufferedReader buf =
173                     new BufferedReader(new InputStreamReader(resp, StandardCharsets.UTF_8));
174
175             int processedTransactionCount = 0;
176             int transactionId = 0;
177             LedgerTransaction transaction = null;
178             LINES:
179             while ((line = buf.readLine()) != null) {
180                 throwIfCancelled();
181                 Matcher m;
182                 m = reComment.matcher(line);
183                 if (m.find()) {
184                     // TODO: comments are ignored for now
185 //                            Log.v("transaction-parser", "Ignoring comment");
186                     continue;
187                 }
188                 //L(String.format("State is %d", updating));
189                 switch (state) {
190                     case EXPECTING_ACCOUNT:
191                         if (line.equals("<h2>General Journal</h2>")) {
192                             state = ParserState.EXPECTING_TRANSACTION;
193                             L("→ expecting transaction");
194                             continue;
195                         }
196                         m = reAccountName.matcher(line);
197                         if (m.find()) {
198                             String acct_encoded = m.group(1);
199                             String accName = URLDecoder.decode(acct_encoded, "UTF-8");
200                             accName = accName.replace("\"", "");
201                             L(String.format("found account: %s", accName));
202
203                             lastAccount = map.get(accName);
204                             if (lastAccount != null) {
205                                 L(String.format("ignoring duplicate account '%s'", accName));
206                                 continue;
207                             }
208                             String parentAccountName = LedgerAccount.extractParentName(accName);
209                             LedgerAccount parentAccount;
210                             if (parentAccountName != null) {
211                                 parentAccount = ensureAccountExists(parentAccountName, map,
212                                         syntheticAccounts);
213                             }
214                             else {
215                                 parentAccount = null;
216                             }
217                             lastAccount = new LedgerAccount(profile, accName, parentAccount);
218
219                             accounts.add(lastAccount);
220                             map.put(accName, lastAccount);
221
222                             state = ParserState.EXPECTING_ACCOUNT_AMOUNT;
223                             L("→ expecting account amount");
224                         }
225                         break;
226
227                     case EXPECTING_ACCOUNT_AMOUNT:
228                         m = reAccountValue.matcher(line);
229                         boolean match_found = false;
230                         while (m.find()) {
231                             throwIfCancelled();
232
233                             match_found = true;
234                             String value = Objects.requireNonNull(m.group(1));
235                             String currency = m.group(2);
236                             if (currency == null)
237                                 currency = "";
238
239                             {
240                                 Matcher tmpM = reDecimalComma.matcher(value);
241                                 if (tmpM.find()) {
242                                     value = value.replace(".", "");
243                                     value = value.replace(',', '.');
244                                 }
245
246                                 tmpM = reDecimalPoint.matcher(value);
247                                 if (tmpM.find()) {
248                                     value = value.replace(",", "");
249                                     value = value.replace(" ", "");
250                                 }
251                             }
252                             L("curr=" + currency + ", value=" + value);
253                             final float val = Float.parseFloat(value);
254                             lastAccount.addAmount(val, currency);
255                             for (LedgerAccount syn : syntheticAccounts) {
256                                 L(String.format(Locale.ENGLISH, "propagating %s %1.2f to %s",
257                                         currency, val, syn.getName()));
258                                 syn.addAmount(val, currency);
259                             }
260                         }
261
262                         if (match_found) {
263                             syntheticAccounts.clear();
264                             state = ParserState.EXPECTING_ACCOUNT;
265                             L("→ expecting account");
266                         }
267
268                         break;
269
270                     case EXPECTING_TRANSACTION:
271                         if (!line.isEmpty() && (line.charAt(0) == ' '))
272                             continue;
273                         m = reTransactionStart.matcher(line);
274                         if (m.find()) {
275                             transactionId = Integer.parseInt(Objects.requireNonNull(m.group(1)));
276                             state = ParserState.EXPECTING_TRANSACTION_DESCRIPTION;
277                             L(String.format(Locale.ENGLISH,
278                                     "found transaction %d → expecting description", transactionId));
279                             progress.setProgress(++processedTransactionCount);
280                             if (maxTransactionId < transactionId)
281                                 maxTransactionId = transactionId;
282                             if ((progress.isIndeterminate()) ||
283                                 (progress.getTotal() < transactionId))
284                                 progress.setTotal(transactionId);
285                             publishProgress(progress);
286                         }
287                         m = reEnd.matcher(line);
288                         if (m.find()) {
289                             L("--- transaction value complete ---");
290                             break LINES;
291                         }
292                         break;
293
294                     case EXPECTING_TRANSACTION_DESCRIPTION:
295                         if (!line.isEmpty() && (line.charAt(0) == ' '))
296                             continue;
297                         m = reTransactionDescription.matcher(line);
298                         if (m.find()) {
299                             if (transactionId == 0)
300                                 throw new TransactionParserException(
301                                         "Transaction Id is 0 while expecting description");
302
303                             String date = Objects.requireNonNull(m.group(1));
304                             try {
305                                 int equalsIndex = date.indexOf('=');
306                                 if (equalsIndex >= 0)
307                                     date = date.substring(equalsIndex + 1);
308                                 transaction =
309                                         new LedgerTransaction(transactionId, date, m.group(2));
310                             }
311                             catch (ParseException e) {
312                                 throw new TransactionParserException(
313                                         String.format("Error parsing date '%s'", date));
314                             }
315                             state = ParserState.EXPECTING_TRANSACTION_DETAILS;
316                             L(String.format(Locale.ENGLISH,
317                                     "transaction %d created for %s (%s) →" + " expecting details",
318                                     transactionId, date, m.group(2)));
319                         }
320                         break;
321
322                     case EXPECTING_TRANSACTION_DETAILS:
323                         if (line.isEmpty()) {
324                             // transaction data collected
325
326                             transaction.finishLoading();
327                             transactions.add(transaction);
328
329                             state = ParserState.EXPECTING_TRANSACTION;
330                             L(String.format("transaction %s parsed → expecting transaction",
331                                     transaction.getId()));
332
333 // sounds like a good idea, but transaction-1 may not be the first one chronologically
334 // for example, when you add the initial seeding transaction after entering some others
335 //                                            if (transactionId == 1) {
336 //                                                L("This was the initial transaction.
337 //                                                Terminating " +
338 //                                                  "parser");
339 //                                                break LINES;
340 //                                            }
341                         }
342                         else {
343                             LedgerTransactionAccount lta = parseTransactionAccountLine(line);
344                             if (lta != null) {
345                                 transaction.addAccount(lta);
346                                 L(String.format(Locale.ENGLISH, "%d: %s = %s", transaction.getId(),
347                                         lta.getAccountName(), lta.getAmount()));
348                             }
349                             else
350                                 throw new IllegalStateException(
351                                         String.format("Can't parse transaction %d details: %s",
352                                                 transactionId, line));
353                         }
354                         break;
355                     default:
356                         throw new RuntimeException(
357                                 String.format("Unknown parser updating %s", state.name()));
358                 }
359             }
360
361             throwIfCancelled();
362         }
363     }
364     @NonNull
365     public LedgerAccount ensureAccountExists(String accountName, HashMap<String, LedgerAccount> map,
366                                              ArrayList<LedgerAccount> createdAccounts) {
367         LedgerAccount acc = map.get(accountName);
368
369         if (acc != null)
370             return acc;
371
372         String parentName = LedgerAccount.extractParentName(accountName);
373         LedgerAccount parentAccount;
374         if (parentName != null) {
375             parentAccount = ensureAccountExists(parentName, map, createdAccounts);
376         }
377         else {
378             parentAccount = null;
379         }
380
381         acc = new LedgerAccount(profile, accountName, parentAccount);
382         createdAccounts.add(acc);
383         return acc;
384     }
385     public void addNumberOfPostings(int number) {
386         expectedPostingsCount += number;
387     }
388     private List<LedgerAccount> retrieveAccountList()
389             throws IOException, HTTPException, ApiNotSupportedException {
390         final API apiVersion = profile.getApiVersion();
391         if (apiVersion.equals(API.auto)) {
392             return retrieveAccountListAnyVersion();
393         }
394         else if (apiVersion.equals(API.html)) {
395             Logger.debug("json",
396                     "Declining using JSON API for /accounts with configured legacy API version");
397             return null;
398         }
399         else {
400             return retrieveAccountListForVersion(apiVersion);
401         }
402     }
403     private List<LedgerAccount> retrieveAccountListAnyVersion()
404             throws HTTPException, ApiNotSupportedException {
405         for (API ver : API.allVersions) {
406             try {
407                 return retrieveAccountListForVersion(ver);
408             }
409             catch (Exception e) {
410                 Logger.debug("json",
411                         String.format(Locale.US, "Error during account list retrieval using API %s",
412                                 ver.getDescription()));
413             }
414
415             throw new ApiNotSupportedException();
416         }
417
418         throw new RuntimeException("This should never be reached");
419     }
420     private List<LedgerAccount> retrieveAccountListForVersion(API version)
421             throws IOException, HTTPException {
422         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "accounts");
423         http.setAllowUserInteraction(false);
424         switch (http.getResponseCode()) {
425             case 200:
426                 break;
427             case 404:
428                 return null;
429             default:
430                 throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
431         }
432         publishProgress(Progress.indeterminate());
433         SQLiteDatabase db = App.getDatabase();
434         ArrayList<LedgerAccount> list = new ArrayList<>();
435         HashMap<String, LedgerAccount> map = new HashMap<>();
436         HashMap<String, LedgerAccount> currentMap = new HashMap<>();
437         for (LedgerAccount acc : prevAccounts)
438             currentMap.put(acc.getName(), acc);
439         throwIfCancelled();
440         try (InputStream resp = http.getInputStream()) {
441             throwIfCancelled();
442             if (http.getResponseCode() != 200)
443                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
444
445             AccountListParser parser = AccountListParser.forApiVersion(version, resp);
446             expectedPostingsCount = 0;
447
448             while (true) {
449                 throwIfCancelled();
450                 LedgerAccount acc = parser.nextAccount(this, map);
451                 if (acc == null)
452                     break;
453                 list.add(acc);
454             }
455             throwIfCancelled();
456         }
457
458         // the current account tree may have changed, update the new-to be tree to match
459         for (LedgerAccount acc : list) {
460             LedgerAccount prevData = currentMap.get(acc.getName());
461             if (prevData != null) {
462                 acc.setExpanded(prevData.isExpanded());
463                 acc.setAmountsExpanded(prevData.amountsExpanded());
464             }
465         }
466
467         return list;
468     }
469     private List<LedgerTransaction> retrieveTransactionList()
470             throws ParseException, HTTPException, IOException, ApiNotSupportedException {
471         final API apiVersion = profile.getApiVersion();
472         if (apiVersion.equals(API.auto)) {
473             return retrieveTransactionListAnyVersion();
474         }
475         else if (apiVersion.equals(API.html)) {
476             Logger.debug("json",
477                     "Declining using JSON API for /accounts with configured legacy API version");
478             return null;
479         }
480         else {
481             return retrieveTransactionListForVersion(apiVersion);
482         }
483
484     }
485     private List<LedgerTransaction> retrieveTransactionListAnyVersion()
486             throws ApiNotSupportedException {
487         for (API ver : API.allVersions) {
488             try {
489                 return retrieveTransactionListForVersion(ver);
490             }
491             catch (Exception | HTTPException e) {
492                 Logger.debug("json",
493                         String.format(Locale.US, "Error during account list retrieval using API %s",
494                                 ver.getDescription()));
495             }
496
497             throw new ApiNotSupportedException();
498         }
499
500         throw new RuntimeException("This should never be reached");
501     }
502     private List<LedgerTransaction> retrieveTransactionListForVersion(API apiVersion)
503             throws IOException, ParseException, HTTPException {
504         Progress progress = new Progress();
505         progress.setTotal(expectedPostingsCount);
506
507         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "transactions");
508         http.setAllowUserInteraction(false);
509         publishProgress(progress);
510         switch (http.getResponseCode()) {
511             case 200:
512                 break;
513             case 404:
514                 return null;
515             default:
516                 throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
517         }
518         ArrayList<LedgerTransaction> trList = new ArrayList<>();
519         try (InputStream resp = http.getInputStream()) {
520             throwIfCancelled();
521
522             TransactionListParser parser = TransactionListParser.forApiVersion(apiVersion, resp);
523
524             int processedPostings = 0;
525
526             while (true) {
527                 throwIfCancelled();
528                 LedgerTransaction transaction = parser.nextTransaction();
529                 throwIfCancelled();
530                 if (transaction == null)
531                     break;
532
533                 trList.add(transaction);
534
535                 progress.setProgress(processedPostings += transaction.getAccounts()
536                                                                      .size());
537 //                Logger.debug("trParser",
538 //                        String.format(Locale.US, "Parsed transaction %d - %s", transaction
539 //                        .getId(),
540 //                                transaction.getDescription()));
541 //                for (LedgerTransactionAccount acc : transaction.getAccounts()) {
542 //                    Logger.debug("trParser",
543 //                            String.format(Locale.US, "  %s", acc.getAccountName()));
544 //                }
545                 publishProgress(progress);
546             }
547
548             throwIfCancelled();
549         }
550
551         // json interface returns transactions if file order and the rest of the machinery
552         // expects them in reverse chronological order
553         Collections.sort(trList, (o1, o2) -> {
554             int res = o2.getDate()
555                         .compareTo(o1.getDate());
556             if (res != 0)
557                 return res;
558             return Integer.compare(o2.getId(), o1.getId());
559         });
560         return trList;
561     }
562
563     @SuppressLint("DefaultLocale")
564     @Override
565     protected Result doInBackground(Void... params) {
566         Data.backgroundTaskStarted();
567         List<LedgerAccount> accounts;
568         List<LedgerTransaction> transactions;
569         try {
570             accounts = retrieveAccountList();
571             // accounts is null in API-version auto-detection and means
572             // requesting 'html' API version via the JSON classes
573             // this can't work, and the null results in the legacy code below
574             // being called
575             if (accounts == null)
576                 transactions = null;
577             else
578                 transactions = retrieveTransactionList();
579
580             if (accounts == null || transactions == null) {
581                 accounts = new ArrayList<>();
582                 transactions = new ArrayList<>();
583                 retrieveTransactionListLegacy(accounts, transactions);
584             }
585             mainModel.setAndStoreAccountAndTransactionListFromWeb(accounts, transactions);
586
587             return new Result(accounts, transactions);
588         }
589         catch (MalformedURLException e) {
590             e.printStackTrace();
591             return new Result("Invalid server URL");
592         }
593         catch (HTTPException e) {
594             e.printStackTrace();
595             return new Result(String.format("HTTP error %d: %s", e.getResponseCode(),
596                     e.getResponseMessage()));
597         }
598         catch (IOException e) {
599             e.printStackTrace();
600             return new Result(e.getLocalizedMessage());
601         }
602         catch (RuntimeJsonMappingException e) {
603             e.printStackTrace();
604             return new Result(Result.ERR_JSON_PARSER_ERROR);
605         }
606         catch (ParseException e) {
607             e.printStackTrace();
608             return new Result("Network error");
609         }
610         catch (OperationCanceledException e) {
611             e.printStackTrace();
612             return new Result("Operation cancelled");
613         }
614         catch (ApiNotSupportedException e) {
615             e.printStackTrace();
616             return new Result("Server version not supported");
617         }
618         finally {
619             Data.backgroundTaskFinished();
620         }
621     }
622     public void throwIfCancelled() {
623         if (isCancelled())
624             throw new OperationCanceledException(null);
625     }
626     private enum ParserState {
627         EXPECTING_ACCOUNT, EXPECTING_ACCOUNT_AMOUNT, EXPECTING_TRANSACTION,
628         EXPECTING_TRANSACTION_DESCRIPTION, EXPECTING_TRANSACTION_DETAILS
629     }
630
631     public enum ProgressState {STARTING, RUNNING, FINISHED}
632
633     public static class Progress {
634         private int progress;
635         private int total;
636         private ProgressState state = ProgressState.RUNNING;
637         private String error = null;
638         private boolean indeterminate;
639         Progress() {
640             indeterminate = true;
641         }
642         Progress(int progress, int total) {
643             this.indeterminate = false;
644             this.progress = progress;
645             this.total = total;
646         }
647         public static Progress indeterminate() {
648             return new Progress();
649         }
650         public static Progress finished(String error) {
651             Progress p = new Progress();
652             p.setState(ProgressState.FINISHED);
653             p.setError(error);
654             return p;
655         }
656         public int getProgress() {
657             ensureState(ProgressState.RUNNING);
658             return progress;
659         }
660         protected void setProgress(int progress) {
661             this.progress = progress;
662             this.state = ProgressState.RUNNING;
663         }
664         public int getTotal() {
665             ensureState(ProgressState.RUNNING);
666             return total;
667         }
668         protected void setTotal(int total) {
669             this.total = total;
670             state = ProgressState.RUNNING;
671             indeterminate = total == -1;
672         }
673         private void ensureState(ProgressState wanted) {
674             if (state != wanted)
675                 throw new IllegalStateException(
676                         String.format("Bad state: %s, expected %s", state, wanted));
677         }
678         public ProgressState getState() {
679             return state;
680         }
681         public void setState(ProgressState state) {
682             this.state = state;
683         }
684         public String getError() {
685             ensureState(ProgressState.FINISHED);
686             return error;
687         }
688         public void setError(String error) {
689             this.error = error;
690             state = ProgressState.FINISHED;
691         }
692         public boolean isIndeterminate() {
693             return indeterminate;
694         }
695         public void setIndeterminate(boolean indeterminate) {
696             this.indeterminate = indeterminate;
697         }
698     }
699
700     private static class TransactionParserException extends IllegalStateException {
701         TransactionParserException(String message) {
702             super(message);
703         }
704     }
705
706     public static class Result {
707         public static String ERR_JSON_PARSER_ERROR = "err_json_parser";
708         public String error;
709         public List<LedgerAccount> accounts;
710         public List<LedgerTransaction> transactions;
711         Result(String error) {
712             this.error = error;
713         }
714         Result(List<LedgerAccount> accounts, List<LedgerTransaction> transactions) {
715             this.accounts = accounts;
716             this.transactions = transactions;
717         }
718     }
719 }