a6b88c9146f3641b4f13c2da330daf4b9d7f7e3f
[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 Mobile-Ledger.
4  * Mobile-Ledger 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  * Mobile-Ledger 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 Mobile-Ledger. 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 import android.util.Log;
25
26 import net.ktnx.mobileledger.R;
27 import net.ktnx.mobileledger.model.Data;
28 import net.ktnx.mobileledger.model.LedgerAccount;
29 import net.ktnx.mobileledger.model.LedgerTransaction;
30 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
31 import net.ktnx.mobileledger.model.MobileLedgerProfile;
32 import net.ktnx.mobileledger.ui.activity.MainActivity;
33 import net.ktnx.mobileledger.ui.transaction_list.TransactionListViewModel;
34 import net.ktnx.mobileledger.utils.MLDB;
35 import net.ktnx.mobileledger.utils.NetworkUtil;
36
37 import java.io.BufferedReader;
38 import java.io.FileNotFoundException;
39 import java.io.IOException;
40 import java.io.InputStream;
41 import java.io.InputStreamReader;
42 import java.lang.ref.WeakReference;
43 import java.net.HttpURLConnection;
44 import java.net.MalformedURLException;
45 import java.net.URLDecoder;
46 import java.util.ArrayList;
47 import java.util.Date;
48 import java.util.HashMap;
49 import java.util.Stack;
50 import java.util.regex.Matcher;
51 import java.util.regex.Pattern;
52
53
54 public class RetrieveTransactionsTask
55         extends AsyncTask<Void, RetrieveTransactionsTask.Progress, Void> {
56     private static final int MATCHING_TRANSACTIONS_LIMIT = 50;
57     private static final Pattern reComment = Pattern.compile("^\\s*;");
58     private static final Pattern reTransactionStart = Pattern.compile("<tr class=\"title\" " +
59                                                                       "id=\"transaction-(\\d+)\"><td class=\"date\"[^\"]*>([\\d.-]+)</td>");
60     private static final Pattern reTransactionDescription =
61             Pattern.compile("<tr class=\"posting\" title=\"(\\S+)\\s(.+)");
62     private static final Pattern reTransactionDetails =
63             Pattern.compile("^\\s+(\\S[\\S\\s]+\\S)\\s\\s+([-+]?\\d[\\d,.]*)(?:\\s+(\\S+)$)?");
64     private static final Pattern reEnd = Pattern.compile("\\bid=\"addmodal\"");
65     private WeakReference<MainActivity> contextRef;
66     private int error;
67     // %3A is '='
68     private boolean success;
69     private Pattern reAccountName = Pattern.compile("/register\\?q=inacct%3A([a-zA-Z0-9%]+)\"");
70     private Pattern reAccountValue = Pattern.compile(
71             "<span class=\"[^\"]*\\bamount\\b[^\"]*\">\\s*([-+]?[\\d.,]+)(?:\\s+(\\S+))?</span>");
72     public RetrieveTransactionsTask(WeakReference<MainActivity> contextRef) {
73         this.contextRef = contextRef;
74     }
75     private static void L(String msg) {
76         Log.d("transaction-parser", msg);
77     }
78     @Override
79     protected void onProgressUpdate(Progress... values) {
80         super.onProgressUpdate(values);
81         MainActivity context = getContext();
82         if (context == null) return;
83         context.onRetrieveProgress(values[0]);
84     }
85     @Override
86     protected void onPreExecute() {
87         super.onPreExecute();
88         MainActivity context = getContext();
89         if (context == null) return;
90         context.onRetrieveStart();
91     }
92     @Override
93     protected void onPostExecute(Void aVoid) {
94         super.onPostExecute(aVoid);
95         MainActivity context = getContext();
96         if (context == null) return;
97         context.onRetrieveDone(success);
98     }
99     @Override
100     protected void onCancelled() {
101         super.onCancelled();
102         MainActivity context = getContext();
103         if (context == null) return;
104         context.onRetrieveDone(false);
105     }
106     @SuppressLint("DefaultLocale")
107     @Override
108     protected Void doInBackground(Void... params) {
109         MobileLedgerProfile profile = Data.profile.get();
110         Progress progress = new Progress();
111         int maxTransactionId = Progress.INDETERMINATE;
112         success = false;
113         ArrayList<LedgerAccount> accountList = new ArrayList<>();
114         ArrayList<LedgerTransaction> transactionList = new ArrayList<>();
115         HashMap<String, Void> accountNames = new HashMap<>();
116         LedgerAccount lastAccount = null;
117         boolean onlyStarred = Data.optShowOnlyStarred.get();
118         Data.backgroundTaskCount.incrementAndGet();
119         try {
120             HttpURLConnection http = NetworkUtil.prepareConnection("journal");
121             http.setAllowUserInteraction(false);
122             publishProgress(progress);
123             MainActivity ctx = getContext();
124             if (ctx == null) return null;
125             try (SQLiteDatabase db = MLDB.getWritableDatabase()) {
126                 try (InputStream resp = http.getInputStream()) {
127                     if (http.getResponseCode() != 200) throw new IOException(
128                             String.format("HTTP error %d", http.getResponseCode()));
129                     db.beginTransaction();
130                     try {
131                         db.execSQL("UPDATE transactions set keep=0");
132                         db.execSQL("update account_values set keep=0;");
133                         db.execSQL("update accounts set keep=0;");
134
135                         ParserState state = ParserState.EXPECTING_ACCOUNT;
136                         String line;
137                         BufferedReader buf =
138                                 new BufferedReader(new InputStreamReader(resp, "UTF-8"));
139
140                         int processedTransactionCount = 0;
141                         int transactionId = 0;
142                         int matchedTransactionsCount = 0;
143                         LedgerTransaction transaction = null;
144                         LINES:
145                         while ((line = buf.readLine()) != null) {
146                             throwIfCancelled();
147                             Matcher m;
148                             m = reComment.matcher(line);
149                             if (m.find()) {
150                                 // TODO: comments are ignored for now
151                                 Log.v("transaction-parser", "Ignoring comment");
152                                 continue;
153                             }
154                             //L(String.format("State is %d", updating));
155                             switch (state) {
156                                 case EXPECTING_ACCOUNT:
157                                     if (line.equals("<h2>General Journal</h2>")) {
158                                         state = ParserState.EXPECTING_TRANSACTION;
159                                         L("→ expecting transaction");
160                                         Data.accounts.set(accountList);
161                                         continue;
162                                     }
163                                     m = reAccountName.matcher(line);
164                                     if (m.find()) {
165                                         String acct_encoded = m.group(1);
166                                         String acct_name = URLDecoder.decode(acct_encoded, "UTF-8");
167                                         acct_name = acct_name.replace("\"", "");
168                                         L(String.format("found account: %s", acct_name));
169
170                                         lastAccount = profile.loadAccount(acct_name);
171                                         if (lastAccount == null) {
172                                             lastAccount = new LedgerAccount(acct_name);
173                                             profile.storeAccount(lastAccount);
174                                         }
175
176                                         // make sure the parent account(s) are present,
177                                         // synthesising them if necessary
178                                         String parentName = lastAccount.getParentName();
179                                         if (parentName != null) {
180                                             Stack<String> toAppend = new Stack<>();
181                                             while (parentName != null) {
182                                                 if (accountNames.containsKey(parentName)) break;
183                                                 toAppend.push(parentName);
184                                                 parentName = new LedgerAccount(parentName)
185                                                         .getParentName();
186                                             }
187                                             while (!toAppend.isEmpty()) {
188                                                 String aName = toAppend.pop();
189                                                 LedgerAccount acc = new LedgerAccount(aName);
190                                                 acc.setHidden(lastAccount.isHidden());
191                                                 if (!onlyStarred || !acc.isHidden())
192                                                     accountList.add(acc);
193                                                 L(String.format("gap-filling with %s", aName));
194                                                 accountNames.put(aName, null);
195                                                 profile.storeAccount(acc);
196                                             }
197                                         }
198
199                                         if (!onlyStarred || !lastAccount.isHidden())
200                                             accountList.add(lastAccount);
201                                         accountNames.put(acct_name, null);
202
203                                         state = ParserState.EXPECTING_ACCOUNT_AMOUNT;
204                                         L("→ expecting account amount");
205                                     }
206                                     break;
207
208                                 case EXPECTING_ACCOUNT_AMOUNT:
209                                     m = reAccountValue.matcher(line);
210                                     boolean match_found = false;
211                                     while (m.find()) {
212                                         throwIfCancelled();
213
214                                         match_found = true;
215                                         String value = m.group(1);
216                                         String currency = m.group(2);
217                                         if (currency == null) currency = "";
218                                         value = value.replace(',', '.');
219                                         L("curr=" + currency + ", value=" + value);
220                                         profile.storeAccountValue(lastAccount.getName(), currency,
221                                                 Float.valueOf(value));
222                                         lastAccount.addAmount(Float.parseFloat(value), currency);
223                                     }
224
225                                     if (match_found) {
226                                         state = ParserState.EXPECTING_ACCOUNT;
227                                         L("→ expecting account");
228                                     }
229
230                                     break;
231
232                                 case EXPECTING_TRANSACTION:
233                                     if (!line.isEmpty() && (line.charAt(0) == ' ')) continue;
234                                     m = reTransactionStart.matcher(line);
235                                     if (m.find()) {
236                                         transactionId = Integer.valueOf(m.group(1));
237                                         state = ParserState.EXPECTING_TRANSACTION_DESCRIPTION;
238                                         L(String.format(
239                                                 "found transaction %d → expecting description",
240                                                 transactionId));
241                                         progress.setProgress(++processedTransactionCount);
242                                         if (maxTransactionId < transactionId)
243                                             maxTransactionId = transactionId;
244                                         if ((progress.getTotal() == Progress.INDETERMINATE) ||
245                                             (progress.getTotal() < transactionId))
246                                             progress.setTotal(transactionId);
247                                         publishProgress(progress);
248                                     }
249                                     m = reEnd.matcher(line);
250                                     if (m.find()) {
251                                         L("--- transaction value complete ---");
252                                         success = true;
253                                         break LINES;
254                                     }
255                                     break;
256
257                                 case EXPECTING_TRANSACTION_DESCRIPTION:
258                                     if (!line.isEmpty() && (line.charAt(0) == ' ')) continue;
259                                     m = reTransactionDescription.matcher(line);
260                                     if (m.find()) {
261                                         if (transactionId == 0)
262                                             throw new TransactionParserException(
263                                                     "Transaction Id is 0 while expecting " +
264                                                     "description");
265
266                                         transaction =
267                                                 new LedgerTransaction(transactionId, m.group(1),
268                                                         m.group(2));
269                                         state = ParserState.EXPECTING_TRANSACTION_DETAILS;
270                                         L(String.format("transaction %d created for %s (%s) →" +
271                                                         " expecting details", transactionId,
272                                                 m.group(1), m.group(2)));
273                                     }
274                                     break;
275
276                                 case EXPECTING_TRANSACTION_DETAILS:
277                                     if (line.isEmpty()) {
278                                         // transaction data collected
279                                         if (transaction.existsInDb(db)) {
280                                             db.execSQL("UPDATE transactions SET keep = 1 WHERE " +
281                                                        "profile = ? and id=?",
282                                                     new Object[]{profile.getUuid(),
283                                                                  transaction.getId()
284                                                     });
285                                             matchedTransactionsCount++;
286
287                                             if (matchedTransactionsCount ==
288                                                 MATCHING_TRANSACTIONS_LIMIT)
289                                             {
290                                                 db.execSQL("UPDATE transactions SET keep=1 WHERE " +
291                                                            "profile = ? and id < ?",
292                                                         new Object[]{profile.getUuid(),
293                                                                      transaction.getId()
294                                                         });
295                                                 success = true;
296                                                 progress.setTotal(progress.getProgress());
297                                                 publishProgress(progress);
298                                                 break LINES;
299                                             }
300                                         }
301                                         else {
302                                             profile.storeTransaction(transaction);
303                                             matchedTransactionsCount = 0;
304                                             progress.setTotal(maxTransactionId);
305                                         }
306
307                                         state = ParserState.EXPECTING_TRANSACTION;
308                                         L(String.format(
309                                                 "transaction %s saved → expecting transaction",
310                                                 transaction.getId()));
311                                         transaction.finishLoading();
312                                         transactionList.add(transaction);
313
314 // sounds like a good idea, but transaction-1 may not be the first one chronologically
315 // for example, when you add the initial seeding transaction after entering some others
316 //                                            if (transactionId == 1) {
317 //                                                L("This was the initial transaction. Terminating " +
318 //                                                  "parser");
319 //                                                break LINES;
320 //                                            }
321                                     }
322                                     else {
323                                         m = reTransactionDetails.matcher(line);
324                                         if (m.find()) {
325                                             String acc_name = m.group(1);
326                                             String amount = m.group(2);
327                                             String currency = m.group(3);
328                                             if (currency == null) currency = "";
329                                             amount = amount.replace(',', '.');
330                                             transaction.addAccount(
331                                                     new LedgerTransactionAccount(acc_name,
332                                                             Float.valueOf(amount), currency));
333                                             L(String.format("%d: %s = %s", transaction.getId(),
334                                                     acc_name, amount));
335                                         }
336                                         else throw new IllegalStateException(String.format(
337                                                 "Can't parse transaction %d " + "details: %s",
338                                                 transactionId, line));
339                                     }
340                                     break;
341                                 default:
342                                     throw new RuntimeException(
343                                             String.format("Unknown parser updating %s",
344                                                     state.name()));
345                             }
346                         }
347
348                         throwIfCancelled();
349
350                         db.execSQL("DELETE FROM transactions WHERE profile=? AND keep = 0",
351                                 new String[]{profile.getUuid()});
352                         db.setTransactionSuccessful();
353
354                         Log.d("db", "Updating transaction value stamp");
355                         Date now = new Date();
356                         profile.setLongOption(MLDB.OPT_LAST_SCRAPE, now.getTime());
357                         Data.lastUpdateDate.set(now);
358                         TransactionListViewModel.scheduleTransactionListReload();
359                     }
360                     finally {
361                         db.endTransaction();
362                     }
363                 }
364             }
365         }
366         catch (MalformedURLException e) {
367             error = R.string.err_bad_backend_url;
368             e.printStackTrace();
369         }
370         catch (FileNotFoundException e) {
371             error = R.string.err_bad_auth;
372             e.printStackTrace();
373         }
374         catch (IOException e) {
375             error = R.string.err_net_io_error;
376             e.printStackTrace();
377         }
378         catch (OperationCanceledException e) {
379             error = R.string.err_cancelled;
380             e.printStackTrace();
381         }
382         finally {
383             Data.backgroundTaskCount.decrementAndGet();
384         }
385         return null;
386     }
387     private MainActivity getContext() {
388         return contextRef.get();
389     }
390     private void throwIfCancelled() {
391         if (isCancelled()) throw new OperationCanceledException(null);
392     }
393
394     private enum ParserState {
395         EXPECTING_ACCOUNT, EXPECTING_ACCOUNT_AMOUNT, EXPECTING_JOURNAL, EXPECTING_TRANSACTION,
396         EXPECTING_TRANSACTION_DESCRIPTION, EXPECTING_TRANSACTION_DETAILS
397     }
398
399     public class Progress {
400         public static final int INDETERMINATE = -1;
401         private int progress;
402         private int total;
403         Progress() {
404             this(INDETERMINATE, INDETERMINATE);
405         }
406         Progress(int progress, int total) {
407             this.progress = progress;
408             this.total = total;
409         }
410         public int getProgress() {
411             return progress;
412         }
413         protected void setProgress(int progress) {
414             this.progress = progress;
415         }
416         public int getTotal() {
417             return total;
418         }
419         protected void setTotal(int total) {
420             this.total = total;
421         }
422     }
423
424     private class TransactionParserException extends IllegalStateException {
425         TransactionParserException(String message) {
426             super(message);
427         }
428     }
429 }