]> git.ktnx.net Git - mobile-ledger.git/blob - app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java
6afe539b2ba242ac5e405e5a2624bb3dc530e678
[mobile-ledger.git] / app / src / main / java / net / ktnx / mobileledger / async / RetrieveTransactionsTask.java
1 /*
2  * Copyright © 2019 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 net.ktnx.mobileledger.App;
28 import net.ktnx.mobileledger.err.HTTPException;
29 import net.ktnx.mobileledger.json.v1_15.AccountListParser;
30 import net.ktnx.mobileledger.json.v1_15.ParsedBalance;
31 import net.ktnx.mobileledger.json.v1_15.ParsedLedgerAccount;
32 import net.ktnx.mobileledger.json.v1_15.ParsedLedgerTransaction;
33 import net.ktnx.mobileledger.json.v1_15.TransactionListParser;
34 import net.ktnx.mobileledger.model.Data;
35 import net.ktnx.mobileledger.model.LedgerAccount;
36 import net.ktnx.mobileledger.model.LedgerTransaction;
37 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
38 import net.ktnx.mobileledger.model.MobileLedgerProfile;
39 import net.ktnx.mobileledger.ui.activity.MainActivity;
40 import net.ktnx.mobileledger.utils.NetworkUtil;
41
42 import java.io.BufferedReader;
43 import java.io.IOException;
44 import java.io.InputStream;
45 import java.io.InputStreamReader;
46 import java.lang.ref.WeakReference;
47 import java.net.HttpURLConnection;
48 import java.net.MalformedURLException;
49 import java.net.URLDecoder;
50 import java.nio.charset.StandardCharsets;
51 import java.text.ParseException;
52 import java.util.ArrayList;
53 import java.util.HashMap;
54 import java.util.Locale;
55 import java.util.Stack;
56 import java.util.regex.Matcher;
57 import java.util.regex.Pattern;
58
59 import static net.ktnx.mobileledger.utils.Logger.debug;
60
61
62 public class RetrieveTransactionsTask
63         extends AsyncTask<Void, RetrieveTransactionsTask.Progress, String> {
64     private static final int MATCHING_TRANSACTIONS_LIMIT = 150;
65     private static final Pattern reComment = Pattern.compile("^\\s*;");
66     private static final Pattern reTransactionStart = Pattern.compile("<tr class=\"title\" " +
67                                                                       "id=\"transaction-(\\d+)\"><td class=\"date\"[^\"]*>([\\d.-]+)</td>");
68     private static final Pattern reTransactionDescription =
69             Pattern.compile("<tr class=\"posting\" title=\"(\\S+)\\s(.+)");
70     private static final Pattern reTransactionDetails =
71             Pattern.compile("^\\s+([!*]\\s+)?(\\S[\\S\\s]+\\S)\\s\\s+([-+]?\\d[\\d,.]*)(?:\\s+(\\S+)$)?");
72     private static final Pattern reEnd = Pattern.compile("\\bid=\"addmodal\"");
73     private static final Pattern reDecimalPoint = Pattern.compile("\\.\\d\\d?$");
74     private static final Pattern reDecimalComma = Pattern.compile(",\\d\\d?$");
75     private WeakReference<MainActivity> contextRef;
76     // %3A is '='
77     private Pattern reAccountName = Pattern.compile("/register\\?q=inacct%3A([a-zA-Z0-9%]+)\"");
78     private Pattern reAccountValue = Pattern.compile(
79             "<span class=\"[^\"]*\\bamount\\b[^\"]*\">\\s*([-+]?[\\d.,]+)(?:\\s+(\\S+))?</span>");
80     private MobileLedgerProfile profile;
81     public RetrieveTransactionsTask(WeakReference<MainActivity> contextRef,
82                                     @NonNull MobileLedgerProfile profile) {
83         this.contextRef = contextRef;
84         this.profile = profile;
85     }
86     private static void L(String msg) {
87         //debug("transaction-parser", msg);
88     }
89     @Override
90     protected void onProgressUpdate(Progress... values) {
91         super.onProgressUpdate(values);
92         MainActivity context = getContext();
93         if (context == null) return;
94         context.onRetrieveProgress(values[0]);
95     }
96     @Override
97     protected void onPreExecute() {
98         super.onPreExecute();
99         MainActivity context = getContext();
100         if (context == null) return;
101         context.onRetrieveStart();
102     }
103     @Override
104     protected void onPostExecute(String error) {
105         super.onPostExecute(error);
106         MainActivity context = getContext();
107         if (context == null) return;
108         context.onRetrieveDone(error);
109     }
110     @Override
111     protected void onCancelled() {
112         super.onCancelled();
113         MainActivity context = getContext();
114         if (context == null) return;
115         context.onRetrieveDone(null);
116     }
117     private String retrieveTransactionListLegacy()
118             throws IOException, ParseException, HTTPException {
119         Progress progress = new Progress();
120         int maxTransactionId = Progress.INDETERMINATE;
121         ArrayList<LedgerAccount> accountList = new ArrayList<>();
122         HashMap<String, Void> accountNames = new HashMap<>();
123         HashMap<String, LedgerAccount> syntheticAccounts = new HashMap<>();
124         LedgerAccount lastAccount = null, prevAccount = null;
125
126         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "journal");
127         http.setAllowUserInteraction(false);
128         publishProgress(progress);
129         switch (http.getResponseCode()) {
130             case 200:
131                 break;
132             default:
133                 throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
134         }
135
136         SQLiteDatabase db = App.getDatabase();
137         try (InputStream resp = http.getInputStream()) {
138             if (http.getResponseCode() != 200)
139                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
140             db.beginTransaction();
141             try {
142                 prepareDbForRetrieval(db, profile);
143
144                 int matchedTransactionsCount = 0;
145
146
147                 ParserState state = ParserState.EXPECTING_ACCOUNT;
148                 String line;
149                 BufferedReader buf =
150                         new BufferedReader(new InputStreamReader(resp, StandardCharsets.UTF_8));
151
152                 int processedTransactionCount = 0;
153                 int transactionId = 0;
154                 LedgerTransaction transaction = null;
155                 LINES:
156                 while ((line = buf.readLine()) != null) {
157                     throwIfCancelled();
158                     Matcher m;
159                     m = reComment.matcher(line);
160                     if (m.find()) {
161                         // TODO: comments are ignored for now
162 //                            Log.v("transaction-parser", "Ignoring comment");
163                         continue;
164                     }
165                     //L(String.format("State is %d", updating));
166                     switch (state) {
167                         case EXPECTING_ACCOUNT:
168                             if (line.equals("<h2>General Journal</h2>")) {
169                                 state = ParserState.EXPECTING_TRANSACTION;
170                                 L("→ expecting transaction");
171                                 // commit the current transaction and start a new one
172                                 // the account list in the UI should reflect the (committed)
173                                 // state of the database
174                                 db.setTransactionSuccessful();
175                                 db.endTransaction();
176                                 Data.accounts.setList(accountList);
177                                 db.beginTransaction();
178                                 continue;
179                             }
180                             m = reAccountName.matcher(line);
181                             if (m.find()) {
182                                 String acct_encoded = m.group(1);
183                                 String acct_name = URLDecoder.decode(acct_encoded, "UTF-8");
184                                 acct_name = acct_name.replace("\"", "");
185                                 L(String.format("found account: %s", acct_name));
186
187                                 prevAccount = lastAccount;
188                                 lastAccount = profile.tryLoadAccount(db, acct_name);
189                                 if (lastAccount == null) lastAccount = new LedgerAccount(acct_name);
190                                 else lastAccount.removeAmounts();
191                                 profile.storeAccount(db, lastAccount);
192
193                                 if (prevAccount != null) prevAccount
194                                         .setHasSubAccounts(prevAccount.isParentOf(lastAccount));
195                                 // make sure the parent account(s) are present,
196                                 // synthesising them if necessary
197                                 // this happens when the (missing-in-HTML) parent account has
198                                 // only one child so we create a synthetic parent account record,
199                                 // copying the amounts when child's amounts are parsed
200                                 String parentName = lastAccount.getParentName();
201                                 if (parentName != null) {
202                                     Stack<String> toAppend = new Stack<>();
203                                     while (parentName != null) {
204                                         if (accountNames.containsKey(parentName)) break;
205                                         toAppend.push(parentName);
206                                         parentName = new LedgerAccount(parentName).getParentName();
207                                     }
208                                     syntheticAccounts.clear();
209                                     while (!toAppend.isEmpty()) {
210                                         String aName = toAppend.pop();
211                                         LedgerAccount acc = profile.tryLoadAccount(db, aName);
212                                         if (acc == null) {
213                                             acc = new LedgerAccount(aName);
214                                             acc.setHiddenByStar(lastAccount.isHiddenByStar());
215                                             acc.setExpanded(!lastAccount.hasSubAccounts() ||
216                                                             lastAccount.isExpanded());
217                                         }
218                                         acc.setHasSubAccounts(true);
219                                         acc.removeAmounts();    // filled below when amounts are parsed
220                                         if (acc.isVisible(accountList))
221                                             accountList.add(acc);
222                                         L(String.format("gap-filling with %s", aName));
223                                         accountNames.put(aName, null);
224                                         profile.storeAccount(db, acc);
225                                         syntheticAccounts.put(aName, acc);
226                                     }
227                                 }
228
229                                 if (lastAccount.isVisible(accountList))
230                                     accountList.add(lastAccount);
231                                 accountNames.put(acct_name, null);
232
233                                 state = ParserState.EXPECTING_ACCOUNT_AMOUNT;
234                                 L("→ expecting account amount");
235                             }
236                             break;
237
238                         case EXPECTING_ACCOUNT_AMOUNT:
239                             m = reAccountValue.matcher(line);
240                             boolean match_found = false;
241                             while (m.find()) {
242                                 throwIfCancelled();
243
244                                 match_found = true;
245                                 String value = m.group(1);
246                                 String currency = m.group(2);
247                                 if (currency == null) currency = "";
248
249                                 {
250                                     Matcher tmpM = reDecimalComma.matcher(value);
251                                     if (tmpM.find()) {
252                                         value = value.replace(".", "");
253                                         value = value.replace(',', '.');
254                                     }
255
256                                     tmpM = reDecimalPoint.matcher(value);
257                                     if (tmpM.find()) {
258                                         value = value.replace(",", "");
259                                         value = value.replace(" ", "");
260                                     }
261                                 }
262                                 L("curr=" + currency + ", value=" + value);
263                                 final float val = Float.parseFloat(value);
264                                 profile.storeAccountValue(db, lastAccount.getName(), currency, val);
265                                 lastAccount.addAmount(val, currency);
266                                 for (LedgerAccount syn : syntheticAccounts.values()) {
267                                     L(String.format(Locale.ENGLISH, "propagating %s %1.2f to %s",
268                                             currency, val, syn.getName()));
269                                     syn.addAmount(val, currency);
270                                     profile.storeAccountValue(db, syn.getName(), currency, val);
271                                 }
272                             }
273
274                             if (match_found) {
275                                 syntheticAccounts.clear();
276                                 state = ParserState.EXPECTING_ACCOUNT;
277                                 L("→ expecting account");
278                             }
279
280                             break;
281
282                         case EXPECTING_TRANSACTION:
283                             if (!line.isEmpty() && (line.charAt(0) == ' ')) continue;
284                             m = reTransactionStart.matcher(line);
285                             if (m.find()) {
286                                 transactionId = Integer.valueOf(m.group(1));
287                                 state = ParserState.EXPECTING_TRANSACTION_DESCRIPTION;
288                                 L(String.format(Locale.ENGLISH,
289                                         "found transaction %d → expecting description",
290                                         transactionId));
291                                 progress.setProgress(++processedTransactionCount);
292                                 if (maxTransactionId < transactionId)
293                                     maxTransactionId = transactionId;
294                                 if ((progress.getTotal() == Progress.INDETERMINATE) ||
295                                     (progress.getTotal() < transactionId))
296                                     progress.setTotal(transactionId);
297                                 publishProgress(progress);
298                             }
299                             m = reEnd.matcher(line);
300                             if (m.find()) {
301                                 L("--- transaction value complete ---");
302                                 break LINES;
303                             }
304                             break;
305
306                         case EXPECTING_TRANSACTION_DESCRIPTION:
307                             if (!line.isEmpty() && (line.charAt(0) == ' ')) continue;
308                             m = reTransactionDescription.matcher(line);
309                             if (m.find()) {
310                                 if (transactionId == 0) throw new TransactionParserException(
311                                         "Transaction Id is 0 while expecting " + "description");
312
313                                 String date = m.group(1);
314                                 try {
315                                     int equalsIndex = date.indexOf('=');
316                                     if (equalsIndex >= 0) date = date.substring(equalsIndex + 1);
317                                     transaction =
318                                             new LedgerTransaction(transactionId, date, m.group(2));
319                                 }
320                                 catch (ParseException e) {
321                                     e.printStackTrace();
322                                     return String.format("Error parsing date '%s'", date);
323                                 }
324                                 state = ParserState.EXPECTING_TRANSACTION_DETAILS;
325                                 L(String.format(Locale.ENGLISH,
326                                         "transaction %d created for %s (%s) →" +
327                                         " expecting details", transactionId, date, m.group(2)));
328                             }
329                             break;
330
331                         case EXPECTING_TRANSACTION_DETAILS:
332                             if (line.isEmpty()) {
333                                 // transaction data collected
334                                 if (transaction.existsInDb(db)) {
335                                     profile.markTransactionAsPresent(db, transaction);
336                                     matchedTransactionsCount++;
337
338                                     if (matchedTransactionsCount == MATCHING_TRANSACTIONS_LIMIT) {
339                                         profile.markTransactionsBeforeTransactionAsPresent(db,
340                                                 transaction);
341                                         progress.setTotal(progress.getProgress());
342                                         publishProgress(progress);
343                                         break LINES;
344                                     }
345                                 }
346                                 else {
347                                     profile.storeTransaction(db, transaction);
348                                     matchedTransactionsCount = 0;
349                                     progress.setTotal(maxTransactionId);
350                                 }
351
352                                 state = ParserState.EXPECTING_TRANSACTION;
353                                 L(String.format("transaction %s saved → expecting transaction",
354                                         transaction.getId()));
355                                 transaction.finishLoading();
356
357 // sounds like a good idea, but transaction-1 may not be the first one chronologically
358 // for example, when you add the initial seeding transaction after entering some others
359 //                                            if (transactionId == 1) {
360 //                                                L("This was the initial transaction. Terminating " +
361 //                                                  "parser");
362 //                                                break LINES;
363 //                                            }
364                             }
365                             else {
366                                 m = reTransactionDetails.matcher(line);
367                                 if (m.find()) {
368                                     String postingStatus = m.group(1);
369                                     String acc_name = m.group(2);
370                                     String amount = m.group(3);
371                                     String currency = m.group(4);
372                                     if (currency == null) currency = "";
373                                     amount = amount.replace(',', '.');
374                                     transaction.addAccount(new LedgerTransactionAccount(acc_name,
375                                             Float.valueOf(amount), currency, null));
376                                     L(String.format(Locale.ENGLISH, "%d: %s = %s",
377                                             transaction.getId(), acc_name, amount));
378                                 }
379                                 else throw new IllegalStateException(
380                                         String.format("Can't parse transaction %d " + "details: %s",
381                                                 transactionId, line));
382                             }
383                             break;
384                         default:
385                             throw new RuntimeException(
386                                     String.format("Unknown parser updating %s", state.name()));
387                     }
388                 }
389
390                 throwIfCancelled();
391
392                 profile.deleteNotPresentTransactions(db);
393                 db.setTransactionSuccessful();
394
395                 profile.setLastUpdateStamp();
396
397                 return null;
398             }
399             finally {
400                 db.endTransaction();
401             }
402         }
403     }
404     private void prepareDbForRetrieval(SQLiteDatabase db, MobileLedgerProfile profile) {
405         db.execSQL("UPDATE transactions set keep=0 where profile=?",
406                 new String[]{profile.getUuid()});
407         db.execSQL("update account_values set keep=0 where profile=?;",
408                 new String[]{profile.getUuid()});
409         db.execSQL("update accounts set keep=0 where profile=?;", new String[]{profile.getUuid()});
410     }
411     private boolean retrieveAccountList() throws IOException, HTTPException {
412         Progress progress = new Progress();
413
414         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "accounts");
415         http.setAllowUserInteraction(false);
416         switch (http.getResponseCode()) {
417             case 200:
418                 break;
419             case 404:
420                 return false;
421             default:
422                 throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
423         }
424         publishProgress(progress);
425         SQLiteDatabase db = App.getDatabase();
426         ArrayList<LedgerAccount> accountList = new ArrayList<>();
427         boolean listFilledOK = false;
428         try (InputStream resp = http.getInputStream()) {
429             if (http.getResponseCode() != 200)
430                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
431
432             db.beginTransaction();
433             try {
434                 profile.markAccountsAsNotPresent(db);
435
436                 AccountListParser parser = new AccountListParser(resp);
437
438                 LedgerAccount prevAccount = null;
439
440                 while (true) {
441                     throwIfCancelled();
442                     ParsedLedgerAccount parsedAccount = parser.nextAccount();
443                     if (parsedAccount == null) break;
444
445                     LedgerAccount acc = profile.tryLoadAccount(db, parsedAccount.getAname());
446                     if (acc == null) acc = new LedgerAccount(parsedAccount.getAname());
447                     else acc.removeAmounts();
448
449                     profile.storeAccount(db, acc);
450                     String lastCurrency = null;
451                     float lastCurrencyAmount = 0;
452                     for (ParsedBalance b : parsedAccount.getAibalance()) {
453                         final String currency = b.getAcommodity();
454                         final float amount = b.getAquantity().asFloat();
455                         if (currency.equals(lastCurrency)) lastCurrencyAmount += amount;
456                         else {
457                             if (lastCurrency != null) {
458                                 profile.storeAccountValue(db, acc.getName(), lastCurrency,
459                                         lastCurrencyAmount);
460                                 acc.addAmount(lastCurrencyAmount, lastCurrency);
461                             }
462                             lastCurrency = currency;
463                             lastCurrencyAmount = amount;
464                         }
465                     }
466                     if (lastCurrency != null) {
467                         profile.storeAccountValue(db, acc.getName(), lastCurrency,
468                                 lastCurrencyAmount);
469                         acc.addAmount(lastCurrencyAmount, lastCurrency);
470                     }
471
472                     if (acc.isVisible(accountList)) accountList.add(acc);
473
474                     if (prevAccount != null) {
475                         prevAccount.setHasSubAccounts(
476                                 acc.getName().startsWith(prevAccount.getName() + ":"));
477                     }
478
479                     prevAccount = acc;
480                 }
481                 throwIfCancelled();
482
483                 profile.deleteNotPresentAccounts(db);
484                 throwIfCancelled();
485                 db.setTransactionSuccessful();
486                 listFilledOK = true;
487             }
488             finally {
489                 db.endTransaction();
490             }
491         }
492         // should not be set in the DB transaction, because of a possible deadlock
493         // with the main and DbOpQueueRunner threads
494         if (listFilledOK) Data.accounts.setList(accountList);
495
496         return true;
497     }
498     private boolean retrieveTransactionList() throws IOException, ParseException, HTTPException {
499         Progress progress = new Progress();
500         int maxTransactionId = Progress.INDETERMINATE;
501
502         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "transactions");
503         http.setAllowUserInteraction(false);
504         publishProgress(progress);
505         switch (http.getResponseCode()) {
506             case 200:
507                 break;
508             case 404:
509                 return false;
510             default:
511                 throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
512         }
513         SQLiteDatabase db = App.getDatabase();
514         try (InputStream resp = http.getInputStream()) {
515             if (http.getResponseCode() != 200)
516                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
517             throwIfCancelled();
518             db.beginTransaction();
519             try {
520                 profile.markTransactionsAsNotPresent(db);
521
522                 int matchedTransactionsCount = 0;
523                 TransactionListParser parser = new TransactionListParser(resp);
524
525                 int processedTransactionCount = 0;
526
527                 DetectedTransactionOrder transactionOrder = DetectedTransactionOrder.UNKNOWN;
528                 int orderAccumulator = 0;
529                 int lastTransactionId = 0;
530
531                 while (true) {
532                     throwIfCancelled();
533                     ParsedLedgerTransaction parsedTransaction = parser.nextTransaction();
534                     throwIfCancelled();
535                     if (parsedTransaction == null) break;
536
537                     LedgerTransaction transaction = parsedTransaction.asLedgerTransaction();
538                     if (transaction.getId() > lastTransactionId) orderAccumulator++;
539                     else orderAccumulator--;
540                     lastTransactionId = transaction.getId();
541                     if (transactionOrder == DetectedTransactionOrder.UNKNOWN) {
542                         if (orderAccumulator > 30) {
543                             transactionOrder = DetectedTransactionOrder.FILE;
544                             debug("rtt", String.format(Locale.ENGLISH,
545                                     "Detected native file order after %d transactions (factor %d)",
546                                     processedTransactionCount, orderAccumulator));
547                             progress.setTotal(Data.transactions.size());
548                         }
549                         else if (orderAccumulator < -30) {
550                             transactionOrder = DetectedTransactionOrder.REVERSE_CHRONOLOGICAL;
551                             debug("rtt", String.format(Locale.ENGLISH,
552                                     "Detected reverse chronological order after %d transactions (factor %d)",
553                                     processedTransactionCount, orderAccumulator));
554                         }
555                     }
556
557                     if (transaction.existsInDb(db)) {
558                         profile.markTransactionAsPresent(db, transaction);
559                         matchedTransactionsCount++;
560
561                         if ((transactionOrder == DetectedTransactionOrder.REVERSE_CHRONOLOGICAL) &&
562                             (matchedTransactionsCount == MATCHING_TRANSACTIONS_LIMIT))
563                         {
564                             profile.markTransactionsBeforeTransactionAsPresent(db, transaction);
565                             progress.setTotal(progress.getProgress());
566                             publishProgress(progress);
567                             db.setTransactionSuccessful();
568                             profile.setLastUpdateStamp();
569                             return true;
570                         }
571                     }
572                     else {
573                         profile.storeTransaction(db, transaction);
574                         matchedTransactionsCount = 0;
575                         progress.setTotal(maxTransactionId);
576                     }
577
578
579                     if ((transactionOrder != DetectedTransactionOrder.UNKNOWN) &&
580                         ((progress.getTotal() == Progress.INDETERMINATE) ||
581                          (progress.getTotal() < transaction.getId())))
582                         progress.setTotal(transaction.getId());
583
584                     progress.setProgress(++processedTransactionCount);
585                     publishProgress(progress);
586                 }
587
588                 throwIfCancelled();
589                 profile.deleteNotPresentTransactions(db);
590                 throwIfCancelled();
591                 db.setTransactionSuccessful();
592                 profile.setLastUpdateStamp();
593             }
594             finally {
595                 db.endTransaction();
596             }
597         }
598
599         return true;
600     }
601
602     @SuppressLint("DefaultLocale")
603     @Override
604     protected String doInBackground(Void... params) {
605         Data.backgroundTaskStarted();
606         try {
607             if (!retrieveAccountList() || !retrieveTransactionList())
608                 return retrieveTransactionListLegacy();
609             return null;
610         }
611         catch (MalformedURLException e) {
612             e.printStackTrace();
613             return "Invalid server URL";
614         }
615         catch (HTTPException e) {
616             e.printStackTrace();
617             return String.format("HTTP error %d: %s", e.getResponseCode(), e.getResponseMessage());
618         }
619         catch (IOException e) {
620             e.printStackTrace();
621             return e.getLocalizedMessage();
622         }
623         catch (ParseException e) {
624             e.printStackTrace();
625             return "Network error";
626         }
627         catch (OperationCanceledException e) {
628             e.printStackTrace();
629             return "Operation cancelled";
630         }
631         finally {
632             Data.backgroundTaskFinished();
633         }
634     }
635     private MainActivity getContext() {
636         return contextRef.get();
637     }
638     private void throwIfCancelled() {
639         if (isCancelled()) throw new OperationCanceledException(null);
640     }
641     enum DetectedTransactionOrder {UNKNOWN, REVERSE_CHRONOLOGICAL, FILE}
642
643     private enum ParserState {
644         EXPECTING_ACCOUNT, EXPECTING_ACCOUNT_AMOUNT, EXPECTING_JOURNAL, EXPECTING_TRANSACTION,
645         EXPECTING_TRANSACTION_DESCRIPTION, EXPECTING_TRANSACTION_DETAILS
646     }
647
648     public class Progress {
649         public static final int INDETERMINATE = -1;
650         private int progress;
651         private int total;
652         Progress() {
653             this(INDETERMINATE, INDETERMINATE);
654         }
655         Progress(int progress, int total) {
656             this.progress = progress;
657             this.total = total;
658         }
659         public int getProgress() {
660             return progress;
661         }
662         protected void setProgress(int progress) {
663             this.progress = progress;
664         }
665         public int getTotal() {
666             return total;
667         }
668         protected void setTotal(int total) {
669             this.total = total;
670         }
671     }
672
673     private class TransactionParserException extends IllegalStateException {
674         TransactionParserException(String message) {
675             super(message);
676         }
677     }
678 }