/* * Copyright (c) 2015 Ngewi Fet <ngewif@gmail.com> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.gnucash.android.service; import android.app.IntentService; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; import android.os.PowerManager; import android.support.annotation.VisibleForTesting; import android.util.Log; import com.crashlytics.android.Crashlytics; import org.gnucash.android.app.GnuCashApplication; import org.gnucash.android.db.DatabaseHelper; import org.gnucash.android.db.DatabaseSchema; import org.gnucash.android.db.adapter.BooksDbAdapter; import org.gnucash.android.db.adapter.DatabaseAdapter; import org.gnucash.android.db.adapter.RecurrenceDbAdapter; import org.gnucash.android.db.adapter.ScheduledActionDbAdapter; import org.gnucash.android.db.adapter.SplitsDbAdapter; import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.export.ExportAsyncTask; import org.gnucash.android.export.ExportFormat; import org.gnucash.android.export.ExportParams; import org.gnucash.android.export.xml.GncXmlExporter; import org.gnucash.android.model.Book; import org.gnucash.android.model.ScheduledAction; import org.gnucash.android.model.Transaction; import org.gnucash.android.util.BookUtils; import java.io.BufferedOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.zip.GZIPOutputStream; /** * Service for running scheduled events. * <p>The service is started and goes through all scheduled event entries in the the database and executes them. * Then it is stopped until the next time it is run. <br> * Scheduled runs of the service should be achieved using an {@link android.app.AlarmManager}</p> * @author Ngewi Fet <ngewif@gmail.com> */ public class ScheduledActionService extends IntentService { public static final String LOG_TAG = "ScheduledActionService"; public ScheduledActionService() { super(LOG_TAG); } @Override protected void onHandleIntent(Intent intent) { Log.i(LOG_TAG, "Starting scheduled action service"); PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); PowerManager.WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOG_TAG); wakeLock.acquire(); autoBackup(); //First run automatic backup of all books before doing anything else try { BooksDbAdapter booksDbAdapter = BooksDbAdapter.getInstance(); List<Book> books = booksDbAdapter.getAllRecords(); for (Book book : books) { //// TODO: 20.04.2017 Retrieve only the book UIDs with new method DatabaseHelper dbHelper = new DatabaseHelper(GnuCashApplication.getAppContext(), book.getUID()); SQLiteDatabase db = dbHelper.getWritableDatabase(); RecurrenceDbAdapter recurrenceDbAdapter = new RecurrenceDbAdapter(db); ScheduledActionDbAdapter scheduledActionDbAdapter = new ScheduledActionDbAdapter(db, recurrenceDbAdapter); List<ScheduledAction> scheduledActions = scheduledActionDbAdapter.getAllEnabledScheduledActions(); Log.i(LOG_TAG, String.format("Processing %d total scheduled actions for Book: %s", scheduledActions.size(), book.getDisplayName())); processScheduledActions(scheduledActions, db); //close all databases except the currently active database if (!db.getPath().equals(GnuCashApplication.getActiveDb().getPath())) db.close(); } Log.i(LOG_TAG, "Completed service @ " + java.text.DateFormat.getDateTimeInstance().format(new Date())); } finally { //release the lock either way wakeLock.release(); } } /** * Process scheduled actions and execute any pending actions * @param scheduledActions List of scheduled actions */ //made public static for testing. Do not call these methods directly @VisibleForTesting public static void processScheduledActions(List<ScheduledAction> scheduledActions, SQLiteDatabase db) { for (ScheduledAction scheduledAction : scheduledActions) { long now = System.currentTimeMillis(); int totalPlannedExecutions = scheduledAction.getTotalPlannedExecutionCount(); int executionCount = scheduledAction.getExecutionCount(); //the end time of the ScheduledAction is not handled here because //it is handled differently for transactions and backups. See the individual methods. if (scheduledAction.getStartTime() > now //if schedule begins in the future || !scheduledAction.isEnabled() // of if schedule is disabled || (totalPlannedExecutions > 0 && executionCount >= totalPlannedExecutions)) { //limit was set and we reached or exceeded it Log.i(LOG_TAG, "Skipping scheduled action: " + scheduledAction.toString()); continue; } executeScheduledEvent(scheduledAction, db); } } /** * Executes a scheduled event according to the specified parameters * @param scheduledAction ScheduledEvent to be executed */ private static void executeScheduledEvent(ScheduledAction scheduledAction, SQLiteDatabase db){ Log.i(LOG_TAG, "Executing scheduled action: " + scheduledAction.toString()); int executionCount = 0; switch (scheduledAction.getActionType()){ case TRANSACTION: executionCount += executeTransactions(scheduledAction, db); break; case BACKUP: executionCount += executeBackup(scheduledAction, db); break; } if (executionCount > 0) { scheduledAction.setLastRun(System.currentTimeMillis()); // Set the execution count in the object because it will be checked // for the next iteration in the calling loop. // This call is important, do not remove!! scheduledAction.setExecutionCount(scheduledAction.getExecutionCount() + executionCount); // Update the last run time and execution count ContentValues contentValues = new ContentValues(); contentValues.put(DatabaseSchema.ScheduledActionEntry.COLUMN_LAST_RUN, scheduledAction.getLastRunTime()); contentValues.put(DatabaseSchema.ScheduledActionEntry.COLUMN_EXECUTION_COUNT, scheduledAction.getExecutionCount()); db.update(DatabaseSchema.ScheduledActionEntry.TABLE_NAME, contentValues, DatabaseSchema.ScheduledActionEntry.COLUMN_UID + "=?", new String[]{scheduledAction.getUID()}); } } /** * Executes scheduled backups for a given scheduled action. * The backup will be executed only once, even if multiple schedules were missed * @param scheduledAction Scheduled action referencing the backup * @param db SQLiteDatabase to backup * @return Number of times backup is executed. This should either be 1 or 0 */ private static int executeBackup(ScheduledAction scheduledAction, SQLiteDatabase db) { if (!shouldExecuteScheduledBackup(scheduledAction)) return 0; ExportParams params = ExportParams.parseCsv(scheduledAction.getTag()); // HACK: the tag isn't updated with the new date, so set the correct by hand params.setExportStartTime(new Timestamp(scheduledAction.getLastRunTime())); Boolean result = false; try { //wait for async task to finish before we proceed (we are holding a wake lock) result = new ExportAsyncTask(GnuCashApplication.getAppContext(), db).execute(params).get(); } catch (InterruptedException | ExecutionException e) { Crashlytics.logException(e); Log.e(LOG_TAG, e.getMessage()); } Log.i(LOG_TAG, "Backup/export did not occur. There might have beeen no new transactions to export or it might have crashed"); return 1; } /** * Check if a scheduled action is due for execution * @param scheduledAction Scheduled action * @return {@code true} if execution is due, {@code false} otherwise */ private static boolean shouldExecuteScheduledBackup(ScheduledAction scheduledAction) { long now = System.currentTimeMillis(); long endTime = scheduledAction.getEndTime(); if (endTime > 0 && endTime < now) return false; if (scheduledAction.computeNextTimeBasedScheduledExecutionTime() > now) return false; return true; } /** * Executes scheduled transactions which are to be added to the database. * <p>If a schedule was missed, all the intervening transactions will be generated, even if * the end time of the transaction was already reached</p> * @param scheduledAction Scheduled action which references the transaction * @param db SQLiteDatabase where the transactions are to be executed * @return Number of transactions created as a result of this action */ private static int executeTransactions(ScheduledAction scheduledAction, SQLiteDatabase db) { int executionCount = 0; String actionUID = scheduledAction.getActionUID(); TransactionsDbAdapter transactionsDbAdapter = new TransactionsDbAdapter(db, new SplitsDbAdapter(db)); Transaction trxnTemplate = null; try { trxnTemplate = transactionsDbAdapter.getRecord(actionUID); } catch (IllegalArgumentException ex){ //if the record could not be found, abort Log.e(LOG_TAG, "Scheduled transaction with UID " + actionUID + " could not be found in the db with path " + db.getPath()); return executionCount; } long now = System.currentTimeMillis(); //if there is an end time in the past, we execute all schedules up to the end time. //if the end time is in the future, we execute all schedules until now (current time) //if there is no end time, we execute all schedules until now long endTime = scheduledAction.getEndTime() > 0 ? Math.min(scheduledAction.getEndTime(), now) : now; int totalPlannedExecutions = scheduledAction.getTotalPlannedExecutionCount(); List<Transaction> transactions = new ArrayList<>(); int previousExecutionCount = scheduledAction.getExecutionCount(); // We'll modify it //we may be executing scheduled action significantly after scheduled time (depending on when Android fires the alarm) //so compute the actual transaction time from pre-known values long transactionTime = scheduledAction.computeNextCountBasedScheduledExecutionTime(); while (transactionTime <= endTime) { Transaction recurringTrxn = new Transaction(trxnTemplate, true); recurringTrxn.setTime(transactionTime); transactions.add(recurringTrxn); recurringTrxn.setScheduledActionUID(scheduledAction.getUID()); scheduledAction.setExecutionCount(++executionCount); //required for computingNextScheduledExecutionTime if (totalPlannedExecutions > 0 && executionCount >= totalPlannedExecutions) break; //if we hit the total planned executions set, then abort transactionTime = scheduledAction.computeNextCountBasedScheduledExecutionTime(); } transactionsDbAdapter.bulkAddRecords(transactions, DatabaseAdapter.UpdateMethod.insert); // Be nice and restore the parameter's original state to avoid confusing the callers scheduledAction.setExecutionCount(previousExecutionCount); return executionCount; } /** * Perform an automatic backup of all books in the database. * This method is run everytime the service is executed */ private static void autoBackup(){ BooksDbAdapter booksDbAdapter = BooksDbAdapter.getInstance(); List<String> bookUIDs = booksDbAdapter.getAllBookUIDs(); Context context = GnuCashApplication.getAppContext(); for (String bookUID : bookUIDs) { String backupFile = BookUtils.getBookBackupFileUri(bookUID); if (backupFile == null){ GncXmlExporter.createBackup(); continue; } try (BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(context.getContentResolver().openOutputStream(Uri.parse(backupFile)))){ GZIPOutputStream gzipOutputStream = new GZIPOutputStream(bufferedOutputStream); OutputStreamWriter writer = new OutputStreamWriter(gzipOutputStream); ExportParams params = new ExportParams(ExportFormat.XML); new GncXmlExporter(params).generateExport(writer); writer.close(); } catch (IOException ex) { Log.e(LOG_TAG, "Auto backup failed for book " + bookUID); ex.printStackTrace(); Crashlytics.logException(ex); } } } }