From 5bba2c06a81c87327fdcf3f2a85c3206d932c2f9 Mon Sep 17 00:00:00 2001 From: Damyan Ivanov Date: Wed, 16 Sep 2020 16:30:50 +0300 Subject: [PATCH] major rework of parsed transaction/descriptions/accounts storage new approach avoids mass deletion followed by mass inserts and allows activation of foreign key constraints in SQLite the list of accounts and transactions are managed by an asynchronous differ members of Data that are relevant only to the MainActivity and its fragments are moved to a new MainModel ViewModel class --- .../async/RefreshDescriptionsTask.java | 73 --- .../async/RetrieveTransactionsTask.java | 123 +++-- .../async/TransactionAccumulator.java | 58 +++ .../async/TransactionDateFinder.java | 46 +- .../async/UpdateTransactionsTask.java | 49 +- .../json/v1_15/ParsedLedgerTransaction.java | 2 + .../net/ktnx/mobileledger/model/Data.java | 38 -- .../mobileledger/model/LedgerTransaction.java | 23 +- .../model/MobileLedgerProfile.java | 474 +++--------------- .../model/TransactionListItem.java | 13 +- .../net/ktnx/mobileledger/ui/MainModel.java | 410 +++++++++++++++ .../AccountSummaryAdapter.java | 14 +- .../AccountSummaryFragment.java | 14 +- .../ui/activity/MainActivity.java | 81 +-- .../activity/NewTransactionItemsAdapter.java | 2 +- .../profiles/ProfilesRecyclerViewAdapter.java | 5 +- .../TransactionListAdapter.java | 33 +- .../TransactionListFragment.java | 58 +-- .../TransactionListViewModel.java | 52 -- .../TransactionLoaderStep.java | 9 +- .../utils/MobileLedgerDatabase.java | 4 +- .../ktnx/mobileledger/utils/SimpleDate.java | 5 + app/src/main/res/raw/create_db.sql | 7 +- app/src/main/res/raw/sql_38.sql | 19 + app/src/main/res/raw/sql_39.sql | 19 + 25 files changed, 843 insertions(+), 788 deletions(-) delete mode 100644 app/src/main/java/net/ktnx/mobileledger/async/RefreshDescriptionsTask.java create mode 100644 app/src/main/java/net/ktnx/mobileledger/async/TransactionAccumulator.java create mode 100644 app/src/main/java/net/ktnx/mobileledger/ui/MainModel.java delete mode 100644 app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListViewModel.java create mode 100644 app/src/main/res/raw/sql_38.sql create mode 100644 app/src/main/res/raw/sql_39.sql diff --git a/app/src/main/java/net/ktnx/mobileledger/async/RefreshDescriptionsTask.java b/app/src/main/java/net/ktnx/mobileledger/async/RefreshDescriptionsTask.java deleted file mode 100644 index 50e70479..00000000 --- a/app/src/main/java/net/ktnx/mobileledger/async/RefreshDescriptionsTask.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright © 2020 Damyan Ivanov. - * This file is part of MoLe. - * MoLe is free software: you can distribute it and/or modify it - * under the term of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your opinion), any later version. - * - * MoLe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License terms for details. - * - * You should have received a copy of the GNU General Public License - * along with MoLe. If not, see . - */ - -package net.ktnx.mobileledger.async; - -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.os.AsyncTask; - -import net.ktnx.mobileledger.App; - -import java.util.HashMap; -import java.util.Map; - -import static net.ktnx.mobileledger.utils.Logger.debug; - -public class RefreshDescriptionsTask extends AsyncTask { - @Override - protected Void doInBackground(Void... voids) { - Map unique = new HashMap<>(); - - debug("descriptions", "Starting refresh"); - SQLiteDatabase db = App.getDatabase(); - -// Data.backgroundTaskStarted(); - try { - db.beginTransactionNonExclusive(); - try { - db.execSQL("UPDATE description_history set keep=0"); - try (Cursor c = db - .rawQuery("SELECT distinct description from transactions", null)) - { - while (c.moveToNext()) { - String description = c.getString(0); - String descriptionUpper = description.toUpperCase(); - if (unique.containsKey(descriptionUpper)) continue; - - db.execSQL( - "replace into description_history(description, description_upper, " + - "keep) values(?, ?, 1)", new String[]{description, descriptionUpper}); - unique.put(descriptionUpper, true); - } - } - db.execSQL("DELETE from description_history where keep=0"); - db.setTransactionSuccessful(); - debug("descriptions", "Refresh successful"); - } - finally { - db.endTransaction(); - } - } - finally { -// Data.backgroundTaskFinished(); - debug("descriptions", "Refresh done"); - } - - return null; - } -} diff --git a/app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java b/app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java index ac9ec509..71131a07 100644 --- a/app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java +++ b/app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java @@ -36,6 +36,7 @@ import net.ktnx.mobileledger.model.LedgerAccount; import net.ktnx.mobileledger.model.LedgerTransaction; import net.ktnx.mobileledger.model.LedgerTransactionAccount; import net.ktnx.mobileledger.model.MobileLedgerProfile; +import net.ktnx.mobileledger.ui.MainModel; import net.ktnx.mobileledger.utils.NetworkUtil; import java.io.BufferedReader; @@ -48,15 +49,17 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.text.ParseException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; -public class RetrieveTransactionsTask - extends AsyncTask { +public class RetrieveTransactionsTask extends + AsyncTask { private static final int MATCHING_TRANSACTIONS_LIMIT = 150; private static final Pattern reComment = Pattern.compile("^\\s*;"); private static final Pattern reTransactionStart = Pattern.compile( @@ -74,10 +77,16 @@ public class RetrieveTransactionsTask private Pattern reAccountName = Pattern.compile("/register\\?q=inacct%3A([a-zA-Z0-9%]+)\""); private Pattern reAccountValue = Pattern.compile( "\\s*([-+]?[\\d.,]+)(?:\\s+(\\S+))?"); + private MainModel mainModel; private MobileLedgerProfile profile; + private List prevAccounts; private int expectedPostingsCount = -1; - public RetrieveTransactionsTask(@NonNull MobileLedgerProfile profile) { + public RetrieveTransactionsTask(@NonNull MainModel mainModel, + @NonNull MobileLedgerProfile profile, + List accounts) { + this.mainModel = mainModel; this.profile = profile; + this.prevAccounts = accounts; } private static void L(String msg) { //debug("transaction-parser", msg); @@ -115,11 +124,11 @@ public class RetrieveTransactionsTask Data.backgroundTaskProgress.postValue(values[0]); } @Override - protected void onPostExecute(String error) { - super.onPostExecute(error); + protected void onPostExecute(Result result) { + super.onPostExecute(result); Progress progress = new Progress(); progress.setState(ProgressState.FINISHED); - progress.setError(error); + progress.setError(result.error); onProgressUpdate(progress); } @Override @@ -129,15 +138,14 @@ public class RetrieveTransactionsTask progress.setState(ProgressState.FINISHED); onProgressUpdate(progress); } - private String retrieveTransactionListLegacy() throws IOException, HTTPException { + private void retrieveTransactionListLegacy(List accounts, + List transactions) + throws IOException, HTTPException { Progress progress = Progress.indeterminate(); progress.setState(ProgressState.RUNNING); progress.setTotal(expectedPostingsCount); int maxTransactionId = -1; - ArrayList list = new ArrayList<>(); HashMap map = new HashMap<>(); - ArrayList displayed = new ArrayList<>(); - ArrayList transactions = new ArrayList<>(); LedgerAccount lastAccount = null; ArrayList syntheticAccounts = new ArrayList<>(); @@ -202,7 +210,7 @@ public class RetrieveTransactionsTask } lastAccount = new LedgerAccount(profile, accName, parentAccount); - list.add(lastAccount); + accounts.add(lastAccount); map.put(accName, lastAccount); state = ParserState.EXPECTING_ACCOUNT_AMOUNT; @@ -284,7 +292,7 @@ public class RetrieveTransactionsTask if (m.find()) { if (transactionId == 0) throw new TransactionParserException( - "Transaction Id is 0 while expecting " + "description"); + "Transaction Id is 0 while expecting description"); String date = Objects.requireNonNull(m.group(1)); try { @@ -295,8 +303,8 @@ public class RetrieveTransactionsTask new LedgerTransaction(transactionId, date, m.group(2)); } catch (ParseException e) { - e.printStackTrace(); - return String.format("Error parsing date '%s'", date); + throw new TransactionParserException( + String.format("Error parsing date '%s'", date)); } state = ParserState.EXPECTING_TRANSACTION_DETAILS; L(String.format(Locale.ENGLISH, @@ -345,10 +353,6 @@ public class RetrieveTransactionsTask } throwIfCancelled(); - - profile.setAndStoreAccountAndTransactionListFromWeb(list, transactions); - - return null; } } private @NonNull @@ -372,14 +376,14 @@ public class RetrieveTransactionsTask createdAccounts.add(acc); return acc; } - private boolean retrieveAccountList() throws IOException, HTTPException { + private List retrieveAccountList() throws IOException, HTTPException { HttpURLConnection http = NetworkUtil.prepareConnection(profile, "accounts"); http.setAllowUserInteraction(false); switch (http.getResponseCode()) { case 200: break; case 404: - return false; + return null; default: throw new HTTPException(http.getResponseCode(), http.getResponseMessage()); } @@ -388,9 +392,11 @@ public class RetrieveTransactionsTask ArrayList list = new ArrayList<>(); HashMap map = new HashMap<>(); HashMap currentMap = new HashMap<>(); - for (LedgerAccount acc : Objects.requireNonNull(profile.getAllAccounts())) + for (LedgerAccount acc : prevAccounts) currentMap.put(acc.getName(), acc); + throwIfCancelled(); try (InputStream resp = http.getInputStream()) { + throwIfCancelled(); if (http.getResponseCode() != 200) throw new IOException(String.format("HTTP error %d", http.getResponseCode())); @@ -459,12 +465,11 @@ public class RetrieveTransactionsTask } } - profile.setAndStoreAccountListFromWeb(list); - return true; + return list; } - private boolean retrieveTransactionList() throws IOException, ParseException, HTTPException { + private List retrieveTransactionList() + throws IOException, ParseException, HTTPException { Progress progress = new Progress(); - int maxTransactionId = Data.transactions.size(); progress.setTotal(expectedPostingsCount); HttpURLConnection http = NetworkUtil.prepareConnection(profile, "transactions"); @@ -474,13 +479,13 @@ public class RetrieveTransactionsTask case 200: break; case 404: - return false; + return null; default: throw new HTTPException(http.getResponseCode(), http.getResponseMessage()); } + ArrayList trList = new ArrayList<>(); try (InputStream resp = http.getInputStream()) { throwIfCancelled(); - ArrayList trList = new ArrayList<>(); TransactionListParser parser = new TransactionListParser(resp); @@ -498,44 +503,73 @@ public class RetrieveTransactionsTask progress.setProgress(processedPostings += transaction.getAccounts() .size()); +// Logger.debug("trParser", +// String.format(Locale.US, "Parsed transaction %d - %s", transaction +// .getId(), +// transaction.getDescription())); +// for (LedgerTransactionAccount acc : transaction.getAccounts()) { +// Logger.debug("trParser", +// String.format(Locale.US, " %s", acc.getAccountName())); +// } publishProgress(progress); } throwIfCancelled(); - profile.setAndStoreTransactionList(trList); } - return true; + // json interface returns transactions if file order and the rest of the machinery + // expects them in reverse chronological order + Collections.sort(trList, (o1, o2) -> { + int res = o2.getDate() + .compareTo(o1.getDate()); + if (res != 0) + return res; + return Integer.compare(o2.getId(), o1.getId()); + }); + return trList; } @SuppressLint("DefaultLocale") @Override - protected String doInBackground(Void... params) { + protected Result doInBackground(Void... params) { Data.backgroundTaskStarted(); + List accounts; + List transactions; try { - if (!retrieveAccountList() || !retrieveTransactionList()) - return retrieveTransactionListLegacy(); - return null; + accounts = retrieveAccountList(); + if (accounts == null) + transactions = null; + else + transactions = retrieveTransactionList(); + if (accounts == null || transactions == null) { + accounts = new ArrayList<>(); + transactions = new ArrayList<>(); + retrieveTransactionListLegacy(accounts, transactions); + } + mainModel.setAndStoreAccountAndTransactionListFromWeb(accounts, transactions); + + return new Result(accounts, transactions); } catch (MalformedURLException e) { e.printStackTrace(); - return "Invalid server URL"; + return new Result("Invalid server URL"); } catch (HTTPException e) { e.printStackTrace(); - return String.format("HTTP error %d: %s", e.getResponseCode(), e.getResponseMessage()); + return new Result(String.format("HTTP error %d: %s", e.getResponseCode(), + e.getResponseMessage())); } catch (IOException e) { e.printStackTrace(); - return e.getLocalizedMessage(); + return new Result(e.getLocalizedMessage()); } catch (ParseException e) { e.printStackTrace(); - return "Network error"; + return new Result("Network error"); } catch (OperationCanceledException e) { e.printStackTrace(); - return "Operation cancelled"; + return new Result("Operation cancelled"); } finally { Data.backgroundTaskFinished(); @@ -624,4 +658,17 @@ public class RetrieveTransactionsTask super(message); } } + + public static class Result { + public String error; + public List accounts; + public List transactions; + Result(String error) { + this.error = error; + } + Result(List accounts, List transactions) { + this.accounts = accounts; + this.transactions = transactions; + } + } } diff --git a/app/src/main/java/net/ktnx/mobileledger/async/TransactionAccumulator.java b/app/src/main/java/net/ktnx/mobileledger/async/TransactionAccumulator.java new file mode 100644 index 00000000..8f57e769 --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/async/TransactionAccumulator.java @@ -0,0 +1,58 @@ +/* + * Copyright © 2020 Damyan Ivanov. + * This file is part of MoLe. + * MoLe is free software: you can distribute it and/or modify it + * under the term of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your opinion), any later version. + * + * MoLe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License terms for details. + * + * You should have received a copy of the GNU General Public License + * along with MoLe. If not, see . + */ + +package net.ktnx.mobileledger.async; + +import net.ktnx.mobileledger.model.LedgerTransaction; +import net.ktnx.mobileledger.model.TransactionListItem; +import net.ktnx.mobileledger.ui.MainModel; +import net.ktnx.mobileledger.utils.SimpleDate; + +import java.util.ArrayList; + +public class TransactionAccumulator { + private final ArrayList list = new ArrayList<>(); + private final MainModel model; + private SimpleDate earliestDate, latestDate; + private SimpleDate lastDate = SimpleDate.today(); + private boolean done; + public TransactionAccumulator(MainModel model) { + this.model = model; + } + public void put(LedgerTransaction transaction, SimpleDate date) { + if (done) + throw new IllegalStateException("Can't put new items after done()"); + if (null == latestDate) + latestDate = date; + earliestDate = date; + + if (!date.equals(lastDate)) { + boolean showMonth = date.month != lastDate.month || date.year != lastDate.year; + list.add(new TransactionListItem(date, showMonth)); + } + + list.add(new TransactionListItem(transaction)); + + lastDate = date; + } + public void done() { + done = true; + model.setDisplayedTransactions(list); + model.setFirstTransactionDate(earliestDate); + model.setLastTransactionDate(latestDate); + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/async/TransactionDateFinder.java b/app/src/main/java/net/ktnx/mobileledger/async/TransactionDateFinder.java index c15e5990..e90a2ae3 100644 --- a/app/src/main/java/net/ktnx/mobileledger/async/TransactionDateFinder.java +++ b/app/src/main/java/net/ktnx/mobileledger/async/TransactionDateFinder.java @@ -19,9 +19,8 @@ package net.ktnx.mobileledger.async; import android.os.AsyncTask; -import net.ktnx.mobileledger.model.Data; import net.ktnx.mobileledger.model.TransactionListItem; -import net.ktnx.mobileledger.utils.LockHolder; +import net.ktnx.mobileledger.ui.MainModel; import net.ktnx.mobileledger.utils.Logger; import net.ktnx.mobileledger.utils.SimpleDate; @@ -29,32 +28,45 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Objects; -public class TransactionDateFinder extends AsyncTask { +public class TransactionDateFinder extends AsyncTask { + private MainModel model; @Override protected void onPostExecute(Integer pos) { - Data.foundTransactionItemIndex.setValue(pos); + model.foundTransactionItemIndex.setValue(pos); } @Override - protected Integer doInBackground(SimpleDate... simpleDates) { - SimpleDate date = simpleDates[0]; + protected Integer doInBackground(Params... param) { + this.model = param[0].model; + SimpleDate date = param[0].date; Logger.debug("go-to-date", String.format(Locale.US, "Looking for date %04d-%02d-%02d", date.year, date.month, date.day)); - Logger.debug("go-to-date", String.format(Locale.US, "List contains %d transactions", - Data.transactions.size())); + List transactions = Objects.requireNonNull( + param[0].model.getDisplayedTransactions() + .getValue()); + Logger.debug("go-to-date", + String.format(Locale.US, "List contains %d transactions", transactions.size())); + + TransactionListItem target = new TransactionListItem(date, true); + int found = + Collections.binarySearch(transactions, target, new TransactionListItemComparator()); + if (found >= 0) + return found; + else + return 1 - found; + } - try (LockHolder locker = Data.transactions.lockForWriting()) { - List transactions = Data.transactions.getList(); - TransactionListItem target = new TransactionListItem(date, true); - int found = Collections.binarySearch(transactions, target, - new TransactionListItemComparator()); - if (found >= 0) - return found; - else - return 1 - found; + public static class Params { + public MainModel model; + public SimpleDate date; + public Params(MainModel model, SimpleDate date) { + this.model = model; + this.date = date; } } + static class TransactionListItemComparator implements Comparator { @Override public int compare(TransactionListItem a, TransactionListItem b) { diff --git a/app/src/main/java/net/ktnx/mobileledger/async/UpdateTransactionsTask.java b/app/src/main/java/net/ktnx/mobileledger/async/UpdateTransactionsTask.java index 404e5127..43de7402 100644 --- a/app/src/main/java/net/ktnx/mobileledger/async/UpdateTransactionsTask.java +++ b/app/src/main/java/net/ktnx/mobileledger/async/UpdateTransactionsTask.java @@ -25,26 +25,24 @@ import net.ktnx.mobileledger.App; import net.ktnx.mobileledger.model.Data; import net.ktnx.mobileledger.model.LedgerTransaction; import net.ktnx.mobileledger.model.MobileLedgerProfile; -import net.ktnx.mobileledger.model.TransactionListItem; +import net.ktnx.mobileledger.ui.MainModel; import net.ktnx.mobileledger.utils.SimpleDate; -import java.util.ArrayList; - import static net.ktnx.mobileledger.utils.Logger.debug; -public class UpdateTransactionsTask extends AsyncTask { - protected String doInBackground(String[] filterAccName) { +public class UpdateTransactionsTask extends AsyncTask { + protected String doInBackground(MainModel[] model) { final MobileLedgerProfile profile = Data.getProfile(); String profile_uuid = profile.getUuid(); Data.backgroundTaskStarted(); try { - ArrayList newList = new ArrayList<>(); - String sql; String[] params; - if (filterAccName[0] == null) { + final String accFilter = model[0].getAccountFilter() + .getValue(); + if (accFilter == null) { sql = "SELECT id, year, month, day FROM transactions WHERE profile=? ORDER BY " + "year desc, month desc, day desc, id desc"; params = new String[]{profile_uuid}; @@ -57,45 +55,26 @@ public class UpdateTransactionsTask extends AsyncTask { "and ta.account_name LIKE ?||'%' AND ta" + ".amount <> 0 ORDER BY tr.year desc, tr.month desc, tr.day desc, tr.id " + "desc"; - params = new String[]{profile_uuid, filterAccName[0]}; + params = new String[]{profile_uuid, accFilter}; } debug("UTT", sql); - SimpleDate latestDate = null, earliestDate = null; + TransactionAccumulator accumulator = new TransactionAccumulator(model[0]); + SQLiteDatabase db = App.getDatabase(); - boolean odd = true; - SimpleDate lastDate = SimpleDate.today(); try (Cursor cursor = db.rawQuery(sql, params)) { while (cursor.moveToNext()) { if (isCancelled()) return null; - int transaction_id = cursor.getInt(0); - SimpleDate date = - new SimpleDate(cursor.getInt(1), cursor.getInt(2), cursor.getInt(3)); - - if (null == latestDate) - latestDate = date; - earliestDate = date; - - if (!date.equals(lastDate)) { - boolean showMonth = - (date.month != lastDate.month) || (date.year != lastDate.year); - newList.add(new TransactionListItem(date, showMonth)); - } - newList.add( - new TransactionListItem(new LedgerTransaction(transaction_id), odd)); -// debug("UTT", String.format("got transaction %d", transaction_id)); - - lastDate = date; - odd = !odd; + accumulator.put(new LedgerTransaction(cursor.getInt(0)), + new SimpleDate(cursor.getInt(1), cursor.getInt(2), cursor.getInt(3))); } - Data.transactions.setList(newList); - Data.latestTransactionDate.postValue(latestDate); - Data.earliestTransactionDate.postValue(earliestDate); - debug("UTT", "transaction list value updated"); } + accumulator.done(); + debug("UTT", "transaction list value updated"); + return null; } finally { diff --git a/app/src/main/java/net/ktnx/mobileledger/json/v1_15/ParsedLedgerTransaction.java b/app/src/main/java/net/ktnx/mobileledger/json/v1_15/ParsedLedgerTransaction.java index 8b1fab78..9a9bfdc0 100644 --- a/app/src/main/java/net/ktnx/mobileledger/json/v1_15/ParsedLedgerTransaction.java +++ b/app/src/main/java/net/ktnx/mobileledger/json/v1_15/ParsedLedgerTransaction.java @@ -153,6 +153,8 @@ public class ParsedLedgerTransaction implements net.ktnx.mobileledger.json.Parse tr.addAccount(p.asLedgerAccount()); } } + + tr.markDataAsLoaded(); return tr; } } diff --git a/app/src/main/java/net/ktnx/mobileledger/model/Data.java b/app/src/main/java/net/ktnx/mobileledger/model/Data.java index c538f57a..74e274d6 100644 --- a/app/src/main/java/net/ktnx/mobileledger/model/Data.java +++ b/app/src/main/java/net/ktnx/mobileledger/model/Data.java @@ -19,7 +19,6 @@ package net.ktnx.mobileledger.model; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; -import android.os.AsyncTask; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -33,12 +32,9 @@ 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.ObservableList; -import net.ktnx.mobileledger.utils.SimpleDate; import java.text.NumberFormat; import java.util.ArrayList; -import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -47,20 +43,12 @@ import java.util.concurrent.atomic.AtomicInteger; import static net.ktnx.mobileledger.utils.Logger.debug; public final class Data { - public static final ObservableList transactions = - new ObservableList<>(new ArrayList<>()); - public static final MutableLiveData earliestTransactionDate = - new MutableLiveData<>(null); - public static final MutableLiveData latestTransactionDate = - new MutableLiveData<>(null); public static final MutableLiveData backgroundTasksRunning = new MutableLiveData<>(false); public static final MutableLiveData backgroundTaskProgress = new MutableLiveData<>(); - public static final MutableLiveData lastUpdateDate = new MutableLiveData<>(); public static final MutableLiveData> profiles = new MutableLiveData<>(null); - public static final MutableLiveData accountFilter = new MutableLiveData<>(); public static final MutableLiveData currencySymbolPosition = new MutableLiveData<>(); public static final MutableLiveData currencyGap = new MutableLiveData<>(true); @@ -70,8 +58,6 @@ public final class Data { new InertMutableLiveData<>(); private static final AtomicInteger backgroundTaskCount = new AtomicInteger(0); private static final Locker profilesLocker = new Locker(); - public static MutableLiveData foundTransactionItemIndex = new MutableLiveData<>(null); - private static RetrieveTransactionsTask retrieveTransactionsTask; static { locale.setValue(Locale.getDefault()); @@ -97,7 +83,6 @@ public final class Data { } public static void setCurrentProfile(@NonNull MobileLedgerProfile newProfile) { MLDB.setOption(MLDB.OPT_PROFILE_UUID, newProfile.getUuid()); - stopTransactionsRetrieval(); profile.setValue(newProfile); } public static int getProfileIndex(MobileLedgerProfile profile) { @@ -165,29 +150,6 @@ public final class Data { } return profile; } - public synchronized static void scheduleTransactionListRetrieval() { - if (retrieveTransactionsTask != null) { - Logger.debug("db", "Ignoring request for transaction retrieval - already active"); - return; - } - MobileLedgerProfile pr = profile.getValue(); - if (pr == null) { - Logger.debug("ui", "Ignoring refresh -- no current profile"); - return; - } - - retrieveTransactionsTask = new RetrieveTransactionsTask(profile.getValue()); - Logger.debug("db", "Created a background transaction retrieval task"); - - retrieveTransactionsTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - public static synchronized void stopTransactionsRetrieval() { - if (retrieveTransactionsTask != null) - retrieveTransactionsTask.cancel(false); - } - public static void transactionRetrievalDone() { - retrieveTransactionsTask = null; - } public static void refreshCurrencyData(Locale locale) { NumberFormat formatter = NumberFormat.getCurrencyInstance(locale); java.util.Currency currency = formatter.getCurrency(); diff --git a/app/src/main/java/net/ktnx/mobileledger/model/LedgerTransaction.java b/app/src/main/java/net/ktnx/mobileledger/model/LedgerTransaction.java index 6a8234f2..d42e5630 100644 --- a/app/src/main/java/net/ktnx/mobileledger/model/LedgerTransaction.java +++ b/app/src/main/java/net/ktnx/mobileledger/model/LedgerTransaction.java @@ -33,6 +33,7 @@ import java.security.NoSuchAlgorithmException; import java.text.ParseException; import java.util.ArrayList; import java.util.Comparator; +import java.util.List; public class LedgerTransaction { private static final String DIGEST_TYPE = "SHA-256"; @@ -56,7 +57,7 @@ public class LedgerTransaction { private SimpleDate date; private String description; private String comment; - private ArrayList accounts; + private List accounts; private String dataHash; private boolean dataLoaded; public LedgerTransaction(Integer id, String dateString, String description) @@ -91,7 +92,7 @@ public class LedgerTransaction { this.dataHash = null; this.dataLoaded = false; } - public ArrayList getAccounts() { + public List getAccounts() { return accounts; } public void addAccount(LedgerTransactionAccount item) { @@ -104,7 +105,6 @@ public class LedgerTransaction { } @NonNull public SimpleDate getDate() { - loadData(App.getDatabase()); if (date == null) throw new IllegalStateException("Transaction has no date"); return date; @@ -130,6 +130,7 @@ public class LedgerTransaction { return id; } protected void fillDataHash() { + loadData(App.getDatabase()); if (dataHash != null) return; try { @@ -163,18 +164,7 @@ public class LedgerTransaction { String.format("Unable to get instance of %s digest", DIGEST_TYPE), e); } } - public boolean existsInDb(SQLiteDatabase db) { - fillDataHash(); - try (Cursor c = db.rawQuery("SELECT 1 from transactions where data_hash = ?", - new String[]{dataHash})) - { - boolean result = c.moveToFirst(); -// debug("db", String.format("Transaction %d (%s) %s", id, dataHash, -// result ? "already present" : "not present")); - return result; - } - } - public void loadData(SQLiteDatabase db) { + public synchronized void loadData(SQLiteDatabase db) { if (dataLoaded) return; @@ -238,4 +228,7 @@ public class LedgerTransaction { return false; } + public void markDataAsLoaded() { + dataLoaded = true; + } } diff --git a/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java b/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java index d9fd260a..e255176b 100644 --- a/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java +++ b/app/src/main/java/net/ktnx/mobileledger/model/MobileLedgerProfile.java @@ -20,32 +20,22 @@ package net.ktnx.mobileledger.model; import android.content.res.Resources; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; -import android.os.Build; -import android.text.TextUtils; import android.util.SparseArray; import androidx.annotation.Nullable; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; import net.ktnx.mobileledger.App; import net.ktnx.mobileledger.R; import net.ktnx.mobileledger.async.DbOpQueue; import net.ktnx.mobileledger.async.SendTransactionTask; -import net.ktnx.mobileledger.utils.LockHolder; -import net.ktnx.mobileledger.utils.Locker; import net.ktnx.mobileledger.utils.Logger; -import net.ktnx.mobileledger.utils.MLDB; import net.ktnx.mobileledger.utils.Misc; import net.ktnx.mobileledger.utils.SimpleDate; 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.Map; @@ -54,13 +44,8 @@ import java.util.Objects; import static net.ktnx.mobileledger.utils.Logger.debug; public final class MobileLedgerProfile { - private final MutableLiveData> displayedAccounts; - private final MutableLiveData> allTransactions; - private final MutableLiveData> displayedTransactions; // N.B. when adding new fields, update the copy-constructor below private final String uuid; - private final Locker accountsLocker = new Locker(); - private List allAccounts; private String name; private boolean permitPosting; private boolean showCommentsByDefault; @@ -74,24 +59,13 @@ public final class MobileLedgerProfile { private int themeHue; private int orderNo = -1; private SendTransactionTask.API apiVersion = SendTransactionTask.API.auto; - private Calendar firstTransactionDate; - private Calendar lastTransactionDate; private FutureDates futureDates = FutureDates.None; private boolean accountsLoaded; private boolean transactionsLoaded; // N.B. when adding new fields, update the copy-constructor below - transient private AccountListLoader loader = null; - transient private Thread displayedAccountsUpdater; - transient private AccountListSaver accountListSaver; - transient private TransactionListSaver transactionListSaver; transient private AccountAndTransactionListSaver accountAndTransactionListSaver; - private Map accountMap = new HashMap<>(); public MobileLedgerProfile(String uuid) { this.uuid = uuid; - allAccounts = new ArrayList<>(); - displayedAccounts = new MutableLiveData<>(); - allTransactions = new MutableLiveData<>(new ArrayList<>()); - displayedTransactions = new MutableLiveData<>(new ArrayList<>()); } public MobileLedgerProfile(MobileLedgerProfile origin) { uuid = origin.uuid; @@ -109,13 +83,6 @@ public final class MobileLedgerProfile { futureDates = origin.futureDates; apiVersion = origin.apiVersion; 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; } @@ -174,67 +141,6 @@ public final class MobileLedgerProfile { db.endTransaction(); } } - public static ArrayList mergeAccountListsFromWeb(List oldList, - List newList) { - LedgerAccount oldAcc, newAcc; - ArrayList merged = new ArrayList<>(); - - Iterator oldIterator = oldList.iterator(); - Iterator newIterator = newList.iterator(); - - while (true) { - if (!oldIterator.hasNext()) { - // the rest of the incoming are new - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - newIterator.forEachRemaining(merged::add); - } - else { - while (newIterator.hasNext()) - merged.add(newIterator.next()); - } - break; - } - oldAcc = oldIterator.next(); - - if (!newIterator.hasNext()) { - // no more incoming accounts. ignore the rest of the old - break; - } - newAcc = newIterator.next(); - - // ignore now missing old items - if (oldAcc.getName() - .compareTo(newAcc.getName()) < 0) - continue; - - // add newly found items - if (oldAcc.getName() - .compareTo(newAcc.getName()) > 0) - { - merged.add(newAcc); - continue; - } - - // two items with same account names; forward-merge UI-controlled fields - // it is important that the result list contains a new LedgerAccount instance - // so that the change is propagated to the UI - newAcc.setExpanded(oldAcc.isExpanded()); - newAcc.setAmountsExpanded(oldAcc.amountsExpanded()); - merged.add(newAcc); - } - - return merged; - } - public void mergeAccountListFromWeb(List newList) { - - try (LockHolder l = accountsLocker.lockForWriting()) { - allAccounts = mergeAccountListsFromWeb(allAccounts, newList); - updateAccountsMap(allAccounts); - } - } - public LiveData> getDisplayedAccounts() { - return displayedAccounts; - } @Contract(value = "null -> false", pure = true) @Override public boolean equals(@Nullable Object obj) { @@ -272,29 +178,8 @@ public final class MobileLedgerProfile { 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"); - if ((loader != null) && loader.isAlive()) { - Logger.debug("async-acc", "returning early - loader already active"); - return; - } - - Logger.debug("async-acc", "Starting AccountListLoader"); - loader = new AccountListLoader(this); - loader.start(); - } - synchronized public void abortAccountListReload() { - if (loader == null) - return; - loader.interrupt(); - loader = null; - } public boolean getShowCommentsByDefault() { return showCommentsByDefault; } @@ -453,6 +338,7 @@ public final class MobileLedgerProfile { } public void storeTransaction(SQLiteDatabase db, int generation, LedgerTransaction tr) { tr.fillDataHash(); +// Logger.debug("storeTransaction", String.format(Locale.US, "ID %d", tr.getId())); SimpleDate d = tr.getDate(); db.execSQL("UPDATE transactions SET year=?, month=?, day=?, description=?, comment=?, " + "data_hash=?, generation=? WHERE profile=? AND id=?", @@ -614,12 +500,6 @@ public final class MobileLedgerProfile { new Object[]{uuid, generation}); Logger.debug("db/benchmark", "Done deleting obsolete transactions"); } - 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 void wipeAllData() { SQLiteDatabase db = App.getDatabase(); db.beginTransaction(); @@ -673,116 +553,15 @@ public final class MobileLedgerProfile { return null; } } - public Calendar getFirstTransactionDate() { - return firstTransactionDate; - } - public Calendar getLastTransactionDate() { - return lastTransactionDate; - } - private void applyTransactionFilter(List list) { - final String accFilter = Data.accountFilter.getValue(); - if (TextUtils.isEmpty(accFilter)) { - displayedTransactions.postValue(list); - } - else { - ArrayList newList = new ArrayList<>(); - for (LedgerTransaction tr : list) { - if (tr.hasAccountNamedLike(accFilter)) - newList.add(tr); - } - displayedTransactions.postValue(newList); - } - } - synchronized public void storeAccountListAsync(List list, - boolean storeUiFields) { - if (accountListSaver != null) - accountListSaver.interrupt(); - accountListSaver = new AccountListSaver(this, list, storeUiFields); - accountListSaver.start(); - } - public void setAndStoreAccountListFromWeb(ArrayList list) { - SQLiteDatabase db = App.getDatabase(); - db.beginTransactionNonExclusive(); - try { - Logger.debug("db/benchmark", - String.format(Locale.US, "Storing %d accounts", list.size())); - int gen = getNextAccountsGeneration(db); - Logger.debug("db/benckmark", - String.format(Locale.US, "Got next generation of %d", gen)); - for (LedgerAccount acc : list) { - storeAccount(db, gen, acc, false); - for (LedgerAmount amt : acc.getAmounts()) { - storeAccountValue(db, gen, acc.getName(), amt.getCurrency(), amt.getAmount()); - } - } - Logger.debug("db/benchmark", "Done storing accounts"); - deleteNotPresentAccounts(db, gen); - setLastUpdateStamp(); - db.setTransactionSuccessful(); - } - finally { - db.endTransaction(); - } - - mergeAccountListFromWeb(list); - updateDisplayedAccounts(); - } - public synchronized Locker lockAccountsForWriting() { - accountsLocker.lockForWriting(); - return accountsLocker; - } - public void setAndStoreTransactionList(ArrayList list) { - storeTransactionListAsync(this, list); - - allTransactions.postValue(list); - } - private void storeTransactionListAsync(MobileLedgerProfile mobileLedgerProfile, - List list) { - if (transactionListSaver != null) - transactionListSaver.interrupt(); - - transactionListSaver = new TransactionListSaver(this, list); - transactionListSaver.start(); - } - public void setAndStoreAccountAndTransactionListFromWeb(List accounts, - List transactions) { - storeAccountAndTransactionListAsync(accounts, transactions, false); - - mergeAccountListFromWeb(accounts); - updateDisplayedAccounts(); - - allTransactions.postValue(transactions); - } - private void storeAccountAndTransactionListAsync(List accounts, - List transactions, - boolean storeAccUiFields) { + public void storeAccountAndTransactionListAsync(List accounts, + List transactions) { if (accountAndTransactionListSaver != null) accountAndTransactionListSaver.interrupt(); accountAndTransactionListSaver = - new AccountAndTransactionListSaver(this, accounts, transactions, storeAccUiFields); + new AccountAndTransactionListSaver(this, accounts, transactions); accountAndTransactionListSaver.start(); } - synchronized public void updateDisplayedAccounts() { - if (displayedAccountsUpdater != null) { - displayedAccountsUpdater.interrupt(); - } - displayedAccountsUpdater = new AccountListDisplayedFilter(this, allAccounts); - displayedAccountsUpdater.start(); - } - public List getAllAccounts() { - return allAccounts; - } - private void updateAccountsMap(List newAccounts) { - accountMap.clear(); - for (LedgerAccount acc : newAccounts) { - accountMap.put(acc.getName(), acc); - } - } - @Nullable - public LedgerAccount locateAccount(String name) { - return accountMap.get(name); - } public enum FutureDates { None(0), OneWeek(7), TwoWeeks(14), OneMonth(30), TwoMonths(60), ThreeMonths(90), @@ -829,190 +608,34 @@ public final class MobileLedgerProfile { } } - static class AccountListLoader extends Thread { - MobileLedgerProfile profile; - AccountListLoader(MobileLedgerProfile profile) { - this.profile = profile; - } - @Override - public void run() { - Logger.debug("async-acc", "AccountListLoader::run() entered"); - String profileUUID = profile.getUuid(); - ArrayList list = new ArrayList<>(); - HashMap map = new HashMap<>(); - - 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; - - final String accName = cursor.getString(0); -// debug("accounts", -// String.format("Read account '%s' from DB [%s]", accName, -// profileUUID)); - 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.allAccounts = list; - profile.updateAccountsMap(list); - profile.updateDisplayedAccounts(); - } - } - - static class AccountListDisplayedFilter extends Thread { - private final MobileLedgerProfile profile; - private final List list; - AccountListDisplayedFilter(MobileLedgerProfile profile, List list) { - this.profile = profile; - this.list = list; - } - @Override - public void run() { - List newDisplayed = new ArrayList<>(); - Logger.debug("dFilter", "waiting for synchronized block"); - Logger.debug("dFilter", String.format(Locale.US, - "entered synchronized block (about to examine %d accounts)", list.size())); - for (LedgerAccount a : list) { - if (isInterrupted()) { - return; - } - - if (a.isVisible()) { - newDisplayed.add(a); - } - } - if (!isInterrupted()) { - profile.displayedAccounts.postValue(newDisplayed); - } - Logger.debug("dFilter", "left synchronized block"); - } - } - - private static class AccountListSaver extends Thread { - private final MobileLedgerProfile profile; - private final List list; - private final boolean storeUiFields; - AccountListSaver(MobileLedgerProfile profile, List list, - boolean storeUiFields) { - this.list = list; - this.profile = profile; - this.storeUiFields = storeUiFields; - } - @Override - public void run() { - SQLiteDatabase db = App.getDatabase(); - db.beginTransactionNonExclusive(); - try { - int generation = profile.getNextAccountsGeneration(db); - if (isInterrupted()) - return; - for (LedgerAccount acc : list) { - profile.storeAccount(db, generation, acc, storeUiFields); - if (isInterrupted()) - return; - } - profile.deleteNotPresentAccounts(db, generation); - if (isInterrupted()) - return; - profile.setLastUpdateStamp(); - db.setTransactionSuccessful(); - } - finally { - db.endTransaction(); - } - } - } - - private static class TransactionListSaver extends Thread { - private final MobileLedgerProfile profile; - private final List list; - TransactionListSaver(MobileLedgerProfile profile, List list) { - this.list = list; - this.profile = profile; - } - @Override - public void run() { - SQLiteDatabase db = App.getDatabase(); - db.beginTransactionNonExclusive(); - try { - Logger.debug("db/benchmark", - String.format(Locale.US, "Storing %d transactions", list.size())); - int generation = profile.getNextTransactionsGeneration(db); - Logger.debug("db/benchmark", - String.format(Locale.US, "Got next generation of %d", generation)); - if (isInterrupted()) - return; - for (LedgerTransaction tr : list) { - profile.storeTransaction(db, generation, tr); - if (isInterrupted()) - return; - } - Logger.debug("db/benchmark", "Done storing transactions"); - profile.deleteNotPresentTransactions(db, generation); - if (isInterrupted()) - return; - profile.setLastUpdateStamp(); - db.setTransactionSuccessful(); - } - finally { - db.endTransaction(); - } - } - } - private static class AccountAndTransactionListSaver extends Thread { private final MobileLedgerProfile profile; private final List accounts; private final List transactions; - private final boolean storeAccUiFields; AccountAndTransactionListSaver(MobileLedgerProfile profile, List accounts, - List transactions, - boolean storeAccUiFields) { + List transactions) { this.accounts = accounts; this.transactions = transactions; this.profile = profile; - this.storeAccUiFields = storeAccUiFields; + } + public int getNextDescriptionsGeneration(SQLiteDatabase db) { + int generation = 1; + try (Cursor c = db.rawQuery("SELECT generation FROM description_history LIMIT 1", + null)) + { + if (c.moveToFirst()) { + generation = c.getInt(0) + 1; + } + } + return generation; + } + void deleteNotPresentDescriptions(SQLiteDatabase db, int generation) { + Logger.debug("db/benchmark", "Deleting obsolete descriptions"); + db.execSQL("DELETE FROM description_history WHERE generation <> ?", + new Object[]{generation}); + db.execSQL("DELETE FROM description_history WHERE generation <> ?", + new Object[]{generation}); + Logger.debug("db/benchmark", "Done deleting obsolete descriptions"); } @Override public void run() { @@ -1024,32 +647,54 @@ public final class MobileLedgerProfile { return; int transactionsGeneration = profile.getNextTransactionsGeneration(db); - if (isInterrupted()) { + if (isInterrupted()) return; - } for (LedgerAccount acc : accounts) { - profile.storeAccount(db, accountsGeneration, acc, storeAccUiFields); + profile.storeAccount(db, accountsGeneration, acc, false); if (isInterrupted()) return; + for (LedgerAmount amt : acc.getAmounts()) { + profile.storeAccountValue(db, accountsGeneration, acc.getName(), + amt.getCurrency(), amt.getAmount()); + if (isInterrupted()) + return; + } } for (LedgerTransaction tr : transactions) { profile.storeTransaction(db, transactionsGeneration, tr); - if (isInterrupted()) { + if (isInterrupted()) return; - } } - profile.deleteNotPresentAccounts(db, accountsGeneration); + profile.deleteNotPresentTransactions(db, transactionsGeneration); if (isInterrupted()) { return; } - profile.deleteNotPresentTransactions(db, transactionsGeneration); + profile.deleteNotPresentAccounts(db, accountsGeneration); if (isInterrupted()) return; - profile.setLastUpdateStamp(); + Map unique = new HashMap<>(); + + debug("descriptions", "Starting refresh"); + int descriptionsGeneration = getNextDescriptionsGeneration(db); + try (Cursor c = db.rawQuery("SELECT distinct description from transactions", + null)) + { + while (c.moveToNext()) { + String description = c.getString(0); + String descriptionUpper = description.toUpperCase(); + if (unique.containsKey(descriptionUpper)) + continue; + + storeDescription(db, descriptionsGeneration, description, descriptionUpper); + + unique.put(descriptionUpper, true); + } + } + deleteNotPresentDescriptions(db, descriptionsGeneration); db.setTransactionSuccessful(); } @@ -1057,5 +702,16 @@ public final class MobileLedgerProfile { db.endTransaction(); } } + private void storeDescription(SQLiteDatabase db, int generation, String description, + String descriptionUpper) { + db.execSQL("UPDATE description_history SET description=?, generation=? WHERE " + + "description_upper=?", new Object[]{description, generation, descriptionUpper + }); + db.execSQL( + "INSERT INTO description_history(description, description_upper, generation) " + + "select ?,?,? WHERE (select changes() = 0)", + new Object[]{description, descriptionUpper, generation + }); + } } } diff --git a/app/src/main/java/net/ktnx/mobileledger/model/TransactionListItem.java b/app/src/main/java/net/ktnx/mobileledger/model/TransactionListItem.java index 084eaad8..0aaf763a 100644 --- a/app/src/main/java/net/ktnx/mobileledger/model/TransactionListItem.java +++ b/app/src/main/java/net/ktnx/mobileledger/model/TransactionListItem.java @@ -19,6 +19,7 @@ package net.ktnx.mobileledger.model; import androidx.annotation.NonNull; +import net.ktnx.mobileledger.App; import net.ktnx.mobileledger.utils.SimpleDate; public class TransactionListItem { @@ -26,16 +27,14 @@ public class TransactionListItem { private SimpleDate date; private boolean monthShown; private LedgerTransaction transaction; - private boolean odd; public TransactionListItem(SimpleDate date, boolean monthShown) { this.type = Type.DELIMITER; this.date = date; this.monthShown = monthShown; } - public TransactionListItem(LedgerTransaction transaction, boolean isOdd) { + public TransactionListItem(LedgerTransaction transaction) { this.type = Type.TRANSACTION; this.transaction = transaction; - this.odd = isOdd; } @NonNull public Type getType() { @@ -43,7 +42,10 @@ public class TransactionListItem { } @NonNull public SimpleDate getDate() { - return (date != null) ? date : transaction.getDate(); + if (date != null) + return date; + transaction.loadData(App.getDatabase()); + return transaction.getDate(); } public boolean isMonthShown() { return monthShown; @@ -51,8 +53,5 @@ public class TransactionListItem { public LedgerTransaction getTransaction() { return transaction; } - public boolean isOdd() { - return odd; - } public enum Type {TRANSACTION, DELIMITER} } diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/MainModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/MainModel.java new file mode 100644 index 00000000..9572ea0d --- /dev/null +++ b/app/src/main/java/net/ktnx/mobileledger/ui/MainModel.java @@ -0,0 +1,410 @@ +/* + * Copyright © 2020 Damyan Ivanov. + * This file is part of MoLe. + * MoLe is free software: you can distribute it and/or modify it + * under the term of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your opinion), any later version. + * + * MoLe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License terms for details. + * + * You should have received a copy of the GNU General Public License + * along with MoLe. If not, see . + */ + +package net.ktnx.mobileledger.ui; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.AsyncTask; +import android.os.Build; +import android.text.TextUtils; + +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import net.ktnx.mobileledger.App; +import net.ktnx.mobileledger.async.RetrieveTransactionsTask; +import net.ktnx.mobileledger.async.TransactionAccumulator; +import net.ktnx.mobileledger.async.UpdateTransactionsTask; +import net.ktnx.mobileledger.model.Data; +import net.ktnx.mobileledger.model.LedgerAccount; +import net.ktnx.mobileledger.model.LedgerTransaction; +import net.ktnx.mobileledger.model.MobileLedgerProfile; +import net.ktnx.mobileledger.model.TransactionListItem; +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.SimpleDate; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static net.ktnx.mobileledger.utils.Logger.debug; + +public class MainModel extends ViewModel { + public final MutableLiveData foundTransactionItemIndex = new MutableLiveData<>(null); + public final MutableLiveData lastUpdateDate = new MutableLiveData<>(null); + private final MutableLiveData updatingFlag = new MutableLiveData<>(false); + private final MutableLiveData accountFilter = new MutableLiveData<>(); + private final MutableLiveData> displayedTransactions = + new MutableLiveData<>(new ArrayList<>()); + private final MutableLiveData> displayedAccounts = new MutableLiveData<>(); + private final Locker accountsLocker = new Locker(); + private final MutableLiveData updateError = new MutableLiveData<>(); + private MobileLedgerProfile profile; + private List allAccounts = new ArrayList<>(); + private Map accountMap = new HashMap<>(); + private SimpleDate firstTransactionDate; + private SimpleDate lastTransactionDate; + transient private RetrieveTransactionsTask retrieveTransactionsTask; + transient private Thread displayedAccountsUpdater; + transient private AccountListLoader loader = null; + private TransactionsDisplayedFilter displayedTransactionsUpdater; + public static ArrayList mergeAccountListsFromWeb(List oldList, + List newList) { + LedgerAccount oldAcc, newAcc; + ArrayList merged = new ArrayList<>(); + + Iterator oldIterator = oldList.iterator(); + Iterator newIterator = newList.iterator(); + + while (true) { + if (!oldIterator.hasNext()) { + // the rest of the incoming are new + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + newIterator.forEachRemaining(merged::add); + } + else { + while (newIterator.hasNext()) + merged.add(newIterator.next()); + } + break; + } + oldAcc = oldIterator.next(); + + if (!newIterator.hasNext()) { + // no more incoming accounts. ignore the rest of the old + break; + } + newAcc = newIterator.next(); + + // ignore now missing old items + if (oldAcc.getName() + .compareTo(newAcc.getName()) < 0) + continue; + + // add newly found items + if (oldAcc.getName() + .compareTo(newAcc.getName()) > 0) + { + merged.add(newAcc); + continue; + } + + // two items with same account names; forward-merge UI-controlled fields + // it is important that the result list contains a new LedgerAccount instance + // so that the change is propagated to the UI + newAcc.setExpanded(oldAcc.isExpanded()); + newAcc.setAmountsExpanded(oldAcc.amountsExpanded()); + merged.add(newAcc); + } + + return merged; + } + private void setLastUpdateStamp() { + debug("db", "Updating transaction value stamp"); + Date now = new Date(); + profile.setLongOption(MLDB.OPT_LAST_SCRAPE, now.getTime()); + lastUpdateDate.postValue(now); + } + public void scheduleTransactionListReload() { + UpdateTransactionsTask task = new UpdateTransactionsTask(); + task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, this); + } + public LiveData getUpdatingFlag() { + return updatingFlag; + } + public LiveData getUpdateError() { + return updateError; + } + public void setProfile(MobileLedgerProfile profile) { + stopTransactionsRetrieval(); + this.profile = profile; + } + public LiveData> getDisplayedTransactions() { + return displayedTransactions; + } + public void setDisplayedTransactions(List list) { + displayedTransactions.postValue(list); + } + public SimpleDate getFirstTransactionDate() { + return firstTransactionDate; + } + public void setFirstTransactionDate(SimpleDate earliestDate) { + this.firstTransactionDate = earliestDate; + } + public MutableLiveData getAccountFilter() { + return accountFilter; + } + public SimpleDate getLastTransactionDate() { + return lastTransactionDate; + } + public void setLastTransactionDate(SimpleDate latestDate) { + this.lastTransactionDate = latestDate; + } + private void applyTransactionFilter(List list) { + final String accFilter = accountFilter.getValue(); + ArrayList newList = new ArrayList<>(); + + TransactionAccumulator accumulator = new TransactionAccumulator(this); + if (TextUtils.isEmpty(accFilter)) + for (LedgerTransaction tr : list) + newList.add(new TransactionListItem(tr)); + else + for (LedgerTransaction tr : list) + if (tr.hasAccountNamedLike(accFilter)) + newList.add(new TransactionListItem(tr)); + + displayedTransactions.postValue(newList); + } + public synchronized void scheduleTransactionListRetrieval() { + if (retrieveTransactionsTask != null) { + Logger.debug("db", "Ignoring request for transaction retrieval - already active"); + return; + } + MobileLedgerProfile profile = Data.getProfile(); + + retrieveTransactionsTask = new RetrieveTransactionsTask(this, profile, allAccounts); + Logger.debug("db", "Created a background transaction retrieval task"); + + retrieveTransactionsTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + public synchronized void stopTransactionsRetrieval() { + if (retrieveTransactionsTask != null) + retrieveTransactionsTask.cancel(true); + } + public void transactionRetrievalDone() { + retrieveTransactionsTask = null; + } + public synchronized Locker lockAccountsForWriting() { + accountsLocker.lockForWriting(); + return accountsLocker; + } + public void mergeAccountListFromWeb(List newList) { + + try (LockHolder l = accountsLocker.lockForWriting()) { + allAccounts = mergeAccountListsFromWeb(allAccounts, newList); + updateAccountsMap(allAccounts); + } + } + public LiveData> getDisplayedAccounts() { + return displayedAccounts; + } + synchronized public void scheduleAccountListReload() { + Logger.debug("async-acc", "scheduleAccountListReload() enter"); + if ((loader != null) && loader.isAlive()) { + Logger.debug("async-acc", "returning early - loader already active"); + return; + } + + loader = new AccountListLoader(profile, this); + loader.start(); + } + public synchronized void setAndStoreAccountAndTransactionListFromWeb( + List accounts, List transactions) { + profile.storeAccountAndTransactionListAsync(accounts, transactions); + + setLastUpdateStamp(); + + mergeAccountListFromWeb(accounts); + updateDisplayedAccounts(); + + updateDisplayedTransactionsFromWeb(transactions); + } + synchronized public void abortAccountListReload() { + if (loader == null) + return; + loader.interrupt(); + loader = null; + } + synchronized public void updateDisplayedAccounts() { + if (displayedAccountsUpdater != null) { + displayedAccountsUpdater.interrupt(); + } + displayedAccountsUpdater = new AccountListDisplayedFilter(this, allAccounts); + displayedAccountsUpdater.start(); + } + synchronized public void updateDisplayedTransactionsFromWeb(List list) { + if (displayedTransactionsUpdater != null) { + displayedTransactionsUpdater.interrupt(); + } + displayedTransactionsUpdater = new TransactionsDisplayedFilter(this, list); + displayedTransactionsUpdater.start(); + } + public List getAllAccounts() { + return allAccounts; + } + private void updateAccountsMap(List newAccounts) { + accountMap.clear(); + for (LedgerAccount acc : newAccounts) { + accountMap.put(acc.getName(), acc); + } + } + @Nullable + public LedgerAccount locateAccount(String name) { + return accountMap.get(name); + } + public void clearUpdateError() { + updateError.postValue(null); + } + public void clearTransactions() { + displayedTransactions.setValue(new ArrayList<>()); + } + + static class AccountListLoader extends Thread { + private MobileLedgerProfile profile; + private MainModel model; + AccountListLoader(MobileLedgerProfile profile, MainModel model) { + this.profile = profile; + this.model = model; + } + @Override + public void run() { + Logger.debug("async-acc", "AccountListLoader::run() entered"); + String profileUUID = profile.getUuid(); + ArrayList list = new ArrayList<>(); + HashMap map = new HashMap<>(); + + 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; + + final String accName = cursor.getString(0); +// debug("accounts", +// String.format("Read account '%s' from DB [%s]", accName, +// profileUUID)); + 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"); + model.allAccounts = list; + model.updateAccountsMap(list); + model.updateDisplayedAccounts(); + } + } + + static class AccountListDisplayedFilter extends Thread { + private final MainModel model; + private final List list; + AccountListDisplayedFilter(MainModel model, List list) { + this.model = model; + this.list = list; + } + @Override + public void run() { + List newDisplayed = new ArrayList<>(); + Logger.debug("dFilter", "waiting for synchronized block"); + Logger.debug("dFilter", String.format(Locale.US, + "entered synchronized block (about to examine %d accounts)", list.size())); + for (LedgerAccount a : list) { + if (isInterrupted()) { + return; + } + + if (a.isVisible()) { + newDisplayed.add(a); + } + } + if (!isInterrupted()) { + model.displayedAccounts.postValue(newDisplayed); + } + Logger.debug("dFilter", "left synchronized block"); + } + } + + static class TransactionsDisplayedFilter extends Thread { + private final MainModel model; + private final List list; + TransactionsDisplayedFilter(MainModel model, List list) { + this.model = model; + this.list = list; + } + @Override + public void run() { + List newDisplayed = new ArrayList<>(); + Logger.debug("dFilter", "waiting for synchronized block"); + Logger.debug("dFilter", String.format(Locale.US, + "entered synchronized block (about to examine %d transactions)", list.size())); + String accNameFilter = model.getAccountFilter() + .getValue(); + + TransactionAccumulator acc = new TransactionAccumulator(model); + for (LedgerTransaction tr : list) { + if (isInterrupted()) { + return; + } + + if (accNameFilter == null || tr.hasAccountNamedLike(accNameFilter)) { + acc.put(tr, tr.getDate()); + } + } + if (!isInterrupted()) { + acc.done(); + } + Logger.debug("dFilter", "left synchronized block"); + } + } +} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryAdapter.java index 2d7ea6ed..d50601ef 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryAdapter.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryAdapter.java @@ -37,6 +37,7 @@ import net.ktnx.mobileledger.R; import net.ktnx.mobileledger.async.DbOpQueue; import net.ktnx.mobileledger.model.LedgerAccount; import net.ktnx.mobileledger.model.MobileLedgerProfile; +import net.ktnx.mobileledger.ui.MainModel; import net.ktnx.mobileledger.ui.activity.MainActivity; import net.ktnx.mobileledger.utils.Locker; import net.ktnx.mobileledger.utils.Logger; @@ -52,7 +53,10 @@ public class AccountSummaryAdapter extends RecyclerView.Adapter { public static final int AMOUNT_LIMIT = 3; private AsyncListDiffer listDiffer; - AccountSummaryAdapter() { + private MainModel model; + AccountSummaryAdapter(MainModel model) { + this.model = model; + listDiffer = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NotNull LedgerAccount oldItem, @@ -88,7 +92,7 @@ public class AccountSummaryAdapter public void setAccounts(List newList) { listDiffer.submitList(newList); } - static class LedgerRowHolder extends RecyclerView.ViewHolder { + class LedgerRowHolder extends RecyclerView.ViewHolder { TextView tvAccountName, tvAccountAmounts; ConstraintLayout row; View expanderContainer; @@ -128,8 +132,8 @@ public class AccountSummaryAdapter if (profile == null) { return; } - try (Locker ignored = profile.lockAccountsForWriting()) { - LedgerAccount realAccount = profile.locateAccount(mAccount.getName()); + try (Locker ignored = model.lockAccountsForWriting()) { + LedgerAccount realAccount = model.locateAccount(mAccount.getName()); if (realAccount == null) return; @@ -138,7 +142,7 @@ public class AccountSummaryAdapter } expanderContainer.animate() .rotation(mAccount.isExpanded() ? 0 : 180); - profile.updateDisplayedAccounts(); + model.updateDisplayedAccounts(); DbOpQueue.add("update accounts set expanded=? where name=? and profile=?", new Object[]{mAccount.isExpanded(), mAccount.getName(), profile.getUuid() diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryFragment.java b/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryFragment.java index 63c7455d..29003b82 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryFragment.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/account_summary/AccountSummaryFragment.java @@ -25,6 +25,7 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -32,7 +33,7 @@ import androidx.recyclerview.widget.RecyclerView; import net.ktnx.mobileledger.R; import net.ktnx.mobileledger.model.Data; import net.ktnx.mobileledger.model.LedgerAccount; -import net.ktnx.mobileledger.model.MobileLedgerProfile; +import net.ktnx.mobileledger.ui.MainModel; import net.ktnx.mobileledger.ui.MobileLedgerListFragment; import net.ktnx.mobileledger.ui.activity.MainActivity; import net.ktnx.mobileledger.utils.Colors; @@ -70,10 +71,12 @@ public class AccountSummaryFragment extends MobileLedgerListFragment { debug("flow", "AccountSummaryFragment.onActivityCreated()"); super.onActivityCreated(savedInstanceState); + MainModel model = new ViewModelProvider(requireActivity()).get(MainModel.class); + Data.backgroundTasksRunning.observe(this.getViewLifecycleOwner(), this::onBackgroundTaskRunningChanged); - modelAdapter = new AccountSummaryAdapter(); + modelAdapter = new AccountSummaryAdapter(model); MainActivity mainActivity = getMainActivity(); root = mainActivity.findViewById(R.id.account_root); @@ -93,12 +96,11 @@ public class AccountSummaryFragment extends MobileLedgerListFragment { Colors.themeWatch.observe(getViewLifecycleOwner(), this::themeChanged); refreshLayout.setOnRefreshListener(() -> { debug("ui", "refreshing accounts via swipe"); - Data.scheduleTransactionListRetrieval(); + model.scheduleTransactionListRetrieval(); }); - MobileLedgerProfile profile = Data.getProfile(); - profile.getDisplayedAccounts() - .observe(getViewLifecycleOwner(), this::onAccountsChanged); + model.getDisplayedAccounts() + .observe(getViewLifecycleOwner(), this::onAccountsChanged); } private void onAccountsChanged(List accounts) { Logger.debug("async-acc", diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java index 88ab8090..5efc2f84 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/activity/MainActivity.java @@ -25,7 +25,6 @@ import android.content.pm.ShortcutManager; import android.content.res.ColorStateList; import android.graphics.Color; import android.graphics.drawable.Icon; -import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.util.Log; @@ -43,6 +42,7 @@ import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentPagerAdapter; +import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; @@ -51,15 +51,14 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.Snackbar; import net.ktnx.mobileledger.R; -import net.ktnx.mobileledger.async.RefreshDescriptionsTask; import net.ktnx.mobileledger.async.RetrieveTransactionsTask; import net.ktnx.mobileledger.model.Data; import net.ktnx.mobileledger.model.MobileLedgerProfile; +import net.ktnx.mobileledger.ui.MainModel; import net.ktnx.mobileledger.ui.account_summary.AccountSummaryFragment; import net.ktnx.mobileledger.ui.profiles.ProfileDetailFragment; import net.ktnx.mobileledger.ui.profiles.ProfilesRecyclerViewAdapter; import net.ktnx.mobileledger.ui.transaction_list.TransactionListFragment; -import net.ktnx.mobileledger.ui.transaction_list.TransactionListViewModel; import net.ktnx.mobileledger.utils.Colors; import net.ktnx.mobileledger.utils.Logger; import net.ktnx.mobileledger.utils.MLDB; @@ -95,6 +94,7 @@ public class MainActivity extends ProfileThemedActivity { private ActionBarDrawerToggle barDrawerToggle; private ViewPager.SimpleOnPageChangeListener pageChangeListener; private MobileLedgerProfile profile; + private MainModel mainModel; @Override protected void onStart() { super.onStart(); @@ -107,8 +107,10 @@ public class MainActivity extends ProfileThemedActivity { protected void onSaveInstanceState(@NotNull Bundle outState) { super.onSaveInstanceState(outState); outState.putInt(STATE_CURRENT_PAGE, mViewPager.getCurrentItem()); - if (Data.accountFilter.getValue() != null) - outState.putString(STATE_ACC_FILTER, Data.accountFilter.getValue()); + if (mainModel.getAccountFilter() + .getValue() != null) + outState.putString(STATE_ACC_FILTER, mainModel.getAccountFilter() + .getValue()); } @Override protected void onDestroy() { @@ -149,6 +151,8 @@ public class MainActivity extends ProfileThemedActivity { Logger.debug("MainActivity", "onCreate()/after super"); setContentView(R.layout.activity_main); + mainModel = new ViewModelProvider(this).get(MainModel.class); + fab = findViewById(R.id.btn_add_transaction); profileListHeadMore = findViewById(R.id.nav_profiles_start_edit); profileListHeadCancel = findViewById(R.id.nav_profiles_cancel_edit); @@ -226,10 +230,11 @@ public class MainActivity extends ProfileThemedActivity { if (currentPage != -1) { mCurrentPage = currentPage; } - Data.accountFilter.setValue(savedInstanceState.getString(STATE_ACC_FILTER, null)); + mainModel.getAccountFilter() + .setValue(savedInstanceState.getString(STATE_ACC_FILTER, null)); } - Data.lastUpdateDate.observe(this, this::updateLastUpdateDisplay); + mainModel.lastUpdateDate.observe(this, this::updateLastUpdateDisplay); findViewById(R.id.btn_no_profiles_add).setOnClickListener( v -> startEditProfileActivity(null)); @@ -319,6 +324,16 @@ public class MainActivity extends ProfileThemedActivity { else drawer.close(); }); + + mainModel.getUpdateError() + .observe(this, (error) -> { + if (error == null) + return; + + Snackbar.make(mViewPager, error, Snackbar.LENGTH_LONG) + .show(); + mainModel.clearUpdateError(); + }); } private void scheduleDataRetrievalIfStale(Date lastUpdate) { long now = new Date().getTime(); @@ -330,12 +345,9 @@ public class MainActivity extends ProfileThemedActivity { "WEB data last fetched at %1.3f and now is %1.3f. re-fetching", lastUpdate.getTime() / 1000f, now / 1000f)); - scheduleDataRetrieval(); + mainModel.scheduleTransactionListRetrieval(); } } - public void scheduleDataRetrieval() { - Data.scheduleTransactionListRetrieval(); - } private void createShortcuts(List list) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return; @@ -403,9 +415,7 @@ public class MainActivity extends ProfileThemedActivity { else setTitle(R.string.app_name); - if (this.profile != null) - this.profile.getDisplayedAccounts() - .removeObservers(this); + mainModel.setProfile(profile); this.profile = profile; @@ -426,12 +436,12 @@ public class MainActivity extends ProfileThemedActivity { mProfileListAdapter.notifyDataSetChanged(); - Data.transactions.clear(); - Logger.debug("transactions", "requesting list reload"); - TransactionListViewModel.scheduleTransactionListReload(); + mainModel.clearTransactions(); if (haveProfile) { - profile.scheduleAccountListReload(); + mainModel.scheduleAccountListReload(); + Logger.debug("transactions", "requesting list reload"); + mainModel.scheduleTransactionListReload(); if (profile.isPostingPermitted()) { mToolbar.setSubtitle(null); @@ -472,7 +482,7 @@ public class MainActivity extends ProfileThemedActivity { // un-hook all observed LiveData Data.removeProfileObservers(this); Data.profiles.removeObservers(this); - Data.lastUpdateDate.removeObservers(this); + mainModel.lastUpdateDate.removeObservers(this); recreate(); } @@ -518,7 +528,8 @@ public class MainActivity extends ProfileThemedActivity { } private void showAccountSummaryFragment() { mViewPager.setCurrentItem(0, true); - Data.accountFilter.setValue(null); + mainModel.getAccountFilter() + .setValue(null); } public void onLatestTransactionsClicked(View view) { drawer.closeDrawers(); @@ -526,7 +537,8 @@ public class MainActivity extends ProfileThemedActivity { showTransactionsFragment(null); } public void showTransactionsFragment(String accName) { - Data.accountFilter.setValue(accName); + mainModel.getAccountFilter() + .setValue(accName); mViewPager.setCurrentItem(1, true); } public void showAccountTransactions(String accountName) { @@ -541,7 +553,8 @@ public class MainActivity extends ProfileThemedActivity { } else { if (mBackMeansToAccountList && (mViewPager.getCurrentItem() == 1)) { - Data.accountFilter.setValue(null); + mainModel.getAccountFilter() + .setValue(null); showAccountSummaryFragment(); mBackMeansToAccountList = false; } @@ -559,17 +572,18 @@ public class MainActivity extends ProfileThemedActivity { long last_update = profile.getLongOption(MLDB.OPT_LAST_SCRAPE, 0L); - Logger.debug("transactions", String.format(Locale.ENGLISH, "Last update = %d", last_update)); + Logger.debug("transactions", + String.format(Locale.ENGLISH, "Last update = %d", last_update)); if (last_update == 0) { - Data.lastUpdateDate.postValue(null); + mainModel.lastUpdateDate.postValue(null); } else { - Data.lastUpdateDate.postValue(new Date(last_update)); + mainModel.lastUpdateDate.postValue(new Date(last_update)); } } public void onStopTransactionRefreshClick(View view) { Logger.debug("interactive", "Cancelling transactions refresh"); - Data.stopTransactionsRetrieval(); + mainModel.stopTransactionsRetrieval(); bTransactionListCancelDownload.setEnabled(false); } public void onRetrieveRunningChanged(Boolean running) { @@ -600,7 +614,7 @@ public class MainActivity extends ProfileThemedActivity { Logger.debug("progress", "Done"); findViewById(R.id.transaction_progress_layout).setVisibility(View.GONE); - Data.transactionRetrievalDone(); + mainModel.transactionRetrievalDone(); if (progress.getError() != null) { Snackbar.make(mViewPager, progress.getError(), Snackbar.LENGTH_LONG) @@ -608,11 +622,6 @@ public class MainActivity extends ProfileThemedActivity { return; } - updateLastUpdateTextFromDB(); - - new RefreshDescriptionsTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - TransactionListViewModel.scheduleTransactionListReload(); - return; } @@ -635,8 +644,9 @@ public class MainActivity extends ProfileThemedActivity { if (progressBar.isIndeterminate()) { progressBar.setIndeterminate(false); } - Logger.debug("progress", - String.format(Locale.US, "%d/%d", progress.getProgress(), progress.getTotal())); +// Logger.debug("progress", +// String.format(Locale.US, "%d/%d", progress.getProgress(), progress.getTotal +// ())); progressBar.setMax(progress.getTotal()); // for some reason animation doesn't work - no progress is shown (stick at 0) // on lineageOS 14.1 (Nougat, 7.1.2) @@ -665,7 +675,8 @@ public class MainActivity extends ProfileThemedActivity { @NotNull @Override public Fragment getItem(int position) { - Logger.debug("main", String.format(Locale.ENGLISH, "Switching to fragment %d", position)); + Logger.debug("main", + String.format(Locale.ENGLISH, "Switching to fragment %d", position)); switch (position) { case 0: // debug("flow", "Creating account summary fragment"); diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java index 4b3fa8e6..5118d2c6 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/activity/NewTransactionItemsAdapter.java @@ -283,7 +283,7 @@ class NewTransactionItemsAdapter extends RecyclerView.Adapter accounts = tr.getAccounts(); + List accounts = tr.getAccounts(); NewTransactionModel.Item firstNegative = null; NewTransactionModel.Item firstPositive = null; int singleNegativeIndex = -1; diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfilesRecyclerViewAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfilesRecyclerViewAdapter.java index 2716623d..34b9064a 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfilesRecyclerViewAdapter.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/profiles/ProfilesRecyclerViewAdapter.java @@ -45,7 +45,6 @@ import net.ktnx.mobileledger.utils.Colors; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collections; -import java.util.Locale; import static net.ktnx.mobileledger.utils.Logger.debug; @@ -194,8 +193,8 @@ public class ProfilesRecyclerViewAdapter throw new AssertionError(); final MobileLedgerProfile profile = profiles.get(position); final MobileLedgerProfile currentProfile = Data.getProfile(); - debug("profiles", String.format(Locale.ENGLISH, "pos %d: %s, current: %s", position, - profile.getUuid(), currentProfile.getUuid())); +// debug("profiles", String.format(Locale.ENGLISH, "pos %d: %s, current: %s", position, +// profile.getUuid(), currentProfile.getUuid())); holder.itemView.setTag(profile); int hue = profile.getThemeHue(); diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListAdapter.java b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListAdapter.java index 58468e88..6f13930a 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListAdapter.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListAdapter.java @@ -39,26 +39,29 @@ import androidx.recyclerview.widget.RecyclerView; import net.ktnx.mobileledger.App; import net.ktnx.mobileledger.R; -import net.ktnx.mobileledger.model.Data; import net.ktnx.mobileledger.model.LedgerTransaction; import net.ktnx.mobileledger.model.LedgerTransactionAccount; -import net.ktnx.mobileledger.model.MobileLedgerProfile; import net.ktnx.mobileledger.model.TransactionListItem; +import net.ktnx.mobileledger.ui.MainModel; import net.ktnx.mobileledger.utils.Colors; import net.ktnx.mobileledger.utils.Globals; +import net.ktnx.mobileledger.utils.Logger; import net.ktnx.mobileledger.utils.Misc; import net.ktnx.mobileledger.utils.SimpleDate; import java.text.DateFormat; import java.util.GregorianCalendar; +import java.util.List; import java.util.Locale; import java.util.TimeZone; public class TransactionListAdapter extends RecyclerView.Adapter { - private MobileLedgerProfile profile; + private MainModel model; private AsyncListDiffer listDiffer; - public TransactionListAdapter() { + public TransactionListAdapter(MainModel model) { super(); + this.model = model; + listDiffer = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull TransactionListItem oldItem, @@ -99,7 +102,8 @@ public class TransactionListAdapter extends RecyclerView.Adapter newList) { + Logger.debug("transactions", + String.format(Locale.US, "Got new transaction list (%d items)", newList.size())); + listDiffer.submitList(newList); } enum LoaderStep {HEAD, ACCOUNTS, DONE} @@ -178,12 +188,11 @@ public class TransactionListAdapter extends RecyclerView.Adapter { debug("ui", "refreshing transactions via swipe"); - mainActivity.scheduleDataRetrieval(); + model.scheduleTransactionListRetrieval(); }); Colors.themeWatch.observe(getViewLifecycleOwner(), this::themeChanged); @@ -130,40 +134,35 @@ public class TransactionListFragment extends MobileLedgerListFragment accNameFilter.setOnItemClickListener((parent, view, position, id) -> { // debug("tmp", "direct onItemClick"); Cursor c = (Cursor) parent.getItemAtPosition(position); - Data.accountFilter.setValue(c.getString(1)); + model.getAccountFilter() + .setValue(c.getString(1)); Globals.hideSoftKeyboard(mainActivity); }); - Data.accountFilter.observe(getViewLifecycleOwner(), this::onAccountNameFilterChanged); + model.getAccountFilter() + .observe(getViewLifecycleOwner(), this::onAccountNameFilterChanged); - TransactionListViewModel.updating.addObserver( - (o, arg) -> refreshLayout.setRefreshing(TransactionListViewModel.updating.get())); - TransactionListViewModel.updateError.addObserver((o, arg) -> { - String err = TransactionListViewModel.updateError.get(); - if (err == null) - return; - - Snackbar.make(this.root, err, Snackbar.LENGTH_LONG) - .show(); - TransactionListViewModel.updateError.set(null); - }); - Data.transactions.addObserver( - (o, arg) -> mainActivity.runOnUiThread(() -> modelAdapter.notifyDataSetChanged())); + model.getUpdatingFlag() + .observe(getViewLifecycleOwner(), (flag) -> refreshLayout.setRefreshing(flag)); + MobileLedgerProfile profile = Data.getProfile(); + model.getDisplayedTransactions() + .observe(getViewLifecycleOwner(), list -> modelAdapter.setTransactions(list)); mainActivity.findViewById(R.id.clearAccountNameFilter) .setOnClickListener(v -> { - Data.accountFilter.setValue(null); + model.getAccountFilter() + .setValue(null); vAccountFilter.setVisibility(View.GONE); menuTransactionListFilter.setVisible(true); Globals.hideSoftKeyboard(mainActivity); }); - Data.foundTransactionItemIndex.observe(getViewLifecycleOwner(), pos -> { + model.foundTransactionItemIndex.observe(getViewLifecycleOwner(), pos -> { Logger.debug("go-to-date", String.format(Locale.US, "Found pos %d", pos)); if (pos != null) { root.scrollToPosition(pos); // reset the value to avoid re-notification upon reconfiguration or app restart - Data.foundTransactionItemIndex.setValue(null); + model.foundTransactionItemIndex.setValue(null); } }); } @@ -183,7 +182,7 @@ public class TransactionListFragment extends MobileLedgerListFragment if (menuTransactionListFilter != null) menuTransactionListFilter.setVisible(!filterActive); - TransactionListViewModel.scheduleTransactionListReload(); + model.scheduleTransactionListReload(); } @Override @@ -194,8 +193,8 @@ public class TransactionListFragment extends MobileLedgerListFragment if ((menuTransactionListFilter == null)) throw new AssertionError(); - if ((Data.accountFilter.getValue() != null) || - (vAccountFilter.getVisibility() == View.VISIBLE)) + if ((model.getAccountFilter() + .getValue() != null) || (vAccountFilter.getVisibility() == View.VISIBLE)) { menuTransactionListFilter.setVisible(false); } @@ -218,8 +217,8 @@ public class TransactionListFragment extends MobileLedgerListFragment .setOnMenuItemClickListener(item -> { DatePickerFragment picker = new DatePickerFragment(); picker.setOnDatePickedListener(this); - picker.setDateRange(Data.earliestTransactionDate.getValue(), - Data.latestTransactionDate.getValue()); + picker.setDateRange(model.getFirstTransactionDate(), + model.getLastTransactionDate()); picker.show(requireActivity().getSupportFragmentManager(), null); return true; }); @@ -227,8 +226,9 @@ public class TransactionListFragment extends MobileLedgerListFragment @Override public void onDatePicked(int year, int month, int day) { RecyclerView list = requireActivity().findViewById(R.id.transaction_root); - AsyncTask finder = new TransactionDateFinder(); + AsyncTask finder = new TransactionDateFinder(); - finder.execute(new SimpleDate(year, month + 1, day)); + finder.execute( + new TransactionDateFinder.Params(model, new SimpleDate(year, month + 1, day))); } } diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListViewModel.java b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListViewModel.java deleted file mode 100644 index f994ec7a..00000000 --- a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionListViewModel.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright © 2019 Damyan Ivanov. - * This file is part of MoLe. - * MoLe is free software: you can distribute it and/or modify it - * under the term of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your opinion), any later version. - * - * MoLe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License terms for details. - * - * You should have received a copy of the GNU General Public License - * along with MoLe. If not, see . - */ - -package net.ktnx.mobileledger.ui.transaction_list; - -import android.os.AsyncTask; - -import androidx.lifecycle.ViewModel; - -import net.ktnx.mobileledger.async.UpdateTransactionsTask; -import net.ktnx.mobileledger.model.Data; -import net.ktnx.mobileledger.model.TransactionListItem; -import net.ktnx.mobileledger.utils.LockHolder; -import net.ktnx.mobileledger.utils.ObservableValue; - -public class TransactionListViewModel extends ViewModel { - public static ObservableValue updating = new ObservableValue<>(); - public static ObservableValue updateError = new ObservableValue<>(); - - public static void scheduleTransactionListReload() { - String filter = Data.accountFilter.getValue(); - AsyncTask task = new UTT(); - task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, filter); - } - public static TransactionListItem getTransactionListItem(int position) { - try(LockHolder lh = Data.transactions.lockForReading()) { - if (position >= Data.transactions.size()) return null; - return Data.transactions.get(position); - } - } - private static class UTT extends UpdateTransactionsTask { - @Override - protected void onPostExecute(String error) { - super.onPostExecute(error); - if (error != null) updateError.set(error); - } - } -} diff --git a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionLoaderStep.java b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionLoaderStep.java index 1dd86047..c6c07784 100644 --- a/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionLoaderStep.java +++ b/app/src/main/java/net/ktnx/mobileledger/ui/transaction_list/TransactionLoaderStep.java @@ -1,5 +1,5 @@ /* - * Copyright © 2019 Damyan Ivanov. + * Copyright © 2020 Damyan Ivanov. * This file is part of MoLe. * MoLe is free software: you can distribute it and/or modify it * under the term of the GNU General Public License as published by @@ -29,14 +29,12 @@ class TransactionLoaderStep { private LedgerTransactionAccount account; private int accountPosition; private String boldAccountName; - private boolean odd; public TransactionLoaderStep(TransactionRowHolder holder, int position, - LedgerTransaction transaction, boolean isOdd) { + LedgerTransaction transaction) { this.step = TransactionListAdapter.LoaderStep.HEAD; this.holder = holder; this.transaction = transaction; this.position = position; - this.odd = isOdd; } public TransactionLoaderStep(TransactionRowHolder holder, LedgerTransactionAccount account, int accountPosition, String boldAccountName) { @@ -76,7 +74,4 @@ class TransactionLoaderStep { public LedgerTransactionAccount getAccount() { return account; } - public boolean isOdd() { - return odd; - } } diff --git a/app/src/main/java/net/ktnx/mobileledger/utils/MobileLedgerDatabase.java b/app/src/main/java/net/ktnx/mobileledger/utils/MobileLedgerDatabase.java index fc03a9f8..3e1919e1 100644 --- a/app/src/main/java/net/ktnx/mobileledger/utils/MobileLedgerDatabase.java +++ b/app/src/main/java/net/ktnx/mobileledger/utils/MobileLedgerDatabase.java @@ -34,7 +34,7 @@ import static net.ktnx.mobileledger.utils.Logger.debug; public class MobileLedgerDatabase extends SQLiteOpenHelper { private static final String DB_NAME = "MoLe.db"; - private static final int LATEST_REVISION = 37; + private static final int LATEST_REVISION = 39; private static final String CREATE_DB_SQL = "create_db"; private final Application mContext; @@ -62,7 +62,7 @@ public class MobileLedgerDatabase extends SQLiteOpenHelper { public void onOpen(SQLiteDatabase db) { super.onOpen(db); db.execSQL("pragma case_sensitive_like=ON;"); -// db.execSQL("PRAGMA foreign_keys=ON"); + db.execSQL("PRAGMA foreign_keys=ON"); } private void applyRevision(SQLiteDatabase db, int rev_no) { diff --git a/app/src/main/java/net/ktnx/mobileledger/utils/SimpleDate.java b/app/src/main/java/net/ktnx/mobileledger/utils/SimpleDate.java index 28ad751d..c8c3e054 100644 --- a/app/src/main/java/net/ktnx/mobileledger/utils/SimpleDate.java +++ b/app/src/main/java/net/ktnx/mobileledger/utils/SimpleDate.java @@ -91,4 +91,9 @@ public class SimpleDate implements Comparable { return Integer.compare(day, date.day); } + public Calendar asCalendar() { + final Calendar calendar = Calendar.getInstance(); + calendar.set(year, month, day); + return calendar; + } } diff --git a/app/src/main/res/raw/create_db.sql b/app/src/main/res/raw/create_db.sql index 69be40d3..dc96f4bb 100644 --- a/app/src/main/res/raw/create_db.sql +++ b/app/src/main/res/raw/create_db.sql @@ -18,13 +18,14 @@ create table options(profile varchar not null, name varchar not null, value varc create unique index un_options on options(profile,name); create table account_values(profile varchar not null, account varchar not null, currency varchar not null default '', value decimal not null, generation integer default 0 ); create unique index un_account_values on account_values(profile,account,currency); -create table description_history(description varchar not null primary key, description_upper varchar); +create table description_history(description varchar not null primary key, description_upper varchar, generation integer default 0); +create unique index un_description_history on description_history(description_upper); create table profiles(uuid varchar not null primary key, name not null, url not null, use_authentication boolean not null, auth_user varchar, auth_password varchar, order_no integer, permit_posting boolean default 0, theme integer default -1, preferred_accounts_filter varchar, future_dates integer, api_version integer, show_commodity_by_default boolean default 0, default_commodity varchar, show_comments_by_default boolean default 1); create table transactions(profile varchar not null, id integer not null, data_hash varchar not null, year integer not null, month integer not null, day integer not null, description varchar not null, comment varchar, generation integer default 0); create unique index un_transactions_id on transactions(profile,id); create unique index un_transactions_data_hash on transactions(profile,data_hash); create index idx_transaction_description on transactions(description); -create table transaction_accounts(profile varchar not null, transaction_id integer not null, order_no integer not null, account_name varchar not null, currency varchar not null default '', amount decimal not null, comment varchar, constraint fk_transaction_accounts_acc foreign key(profile,account_name) references accounts(profile,account_name), constraint fk_transaction_accounts_trn foreign key(profile, transaction_id) references transactions(profile,id), generation integer default 0); +create table transaction_accounts(profile varchar not null, transaction_id integer not null, order_no integer not null, account_name varchar not null, currency varchar not null default '', amount decimal not null, comment varchar, constraint fk_transaction_accounts_acc foreign key(profile,account_name) references accounts(profile,name), constraint fk_transaction_accounts_trn foreign key(profile, transaction_id) references transactions(profile,id), generation integer default 0); create unique index un_transaction_accounts_order on transaction_accounts(profile, transaction_id, order_no); create table currencies(id integer not null primary key, name varchar not null, position varchar not null, has_gap boolean not null); --- updated to revision 37 \ No newline at end of file +-- updated to revision 39 \ No newline at end of file diff --git a/app/src/main/res/raw/sql_38.sql b/app/src/main/res/raw/sql_38.sql new file mode 100644 index 00000000..60c8611e --- /dev/null +++ b/app/src/main/res/raw/sql_38.sql @@ -0,0 +1,19 @@ +-- 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 . +CREATE TABLE transaction_accounts_new(profile varchar not null, transaction_id integer not null, account_name varchar not null, currency varchar not null default '', amount decimal not null, comment varchar, generation integer default 0, order_no integer not null default 0, constraint fk_transaction_accounts_acc foreign key(profile,account_name) references accounts(profile,name), constraint fk_transaction_accounts_trn foreign key(profile, transaction_id) references transactions(profile,id)); +insert into transaction_accounts_new(profile, transaction_id, account_name, currency, amount, comment, generation, order_no) select profile, transaction_id, account_name, currency, amount, comment, generation, order_no from transaction_accounts; +drop table transaction_accounts; +alter table transaction_accounts_new rename to transaction_accounts; +create unique index un_transaction_accounts_order on transaction_accounts(profile, transaction_id, order_no); \ No newline at end of file diff --git a/app/src/main/res/raw/sql_39.sql b/app/src/main/res/raw/sql_39.sql new file mode 100644 index 00000000..0312574c --- /dev/null +++ b/app/src/main/res/raw/sql_39.sql @@ -0,0 +1,19 @@ +-- 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 . +create table description_history_new(description varchar not null primary key, description_upper varchar, generation integer default 0); +insert into description_history_new(description, description_upper) select description, description_upper from description_history; +drop table description_history; +alter table description_history_new rename to description_history; +create unique index un_description_history on description_history(description_upper); \ No newline at end of file -- 2.39.5