/* * Copyright 2014 Bevbot LLC <info@bevbot.com> * * This file is part of the Kegtab package from the Kegbot project. For * more information on Kegtab or Kegbot, see <http://kegbot.org/>. * * Kegtab is free software: you can redistribute it and/or modify it under * the terms of the GNU General Public License as published by the Free * Software Foundation, version 2. * * Kegtab 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 for * more details. * * You should have received a copy of the GNU General Public License along * with Kegtab. If not, see <http://www.gnu.org/licenses/>. */ package org.kegbot.core; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.SystemClock; import android.util.Log; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import com.google.protobuf.AbstractMessage; import com.google.protobuf.InvalidProtocolBufferException; import com.squareup.otto.Bus; import com.squareup.otto.Produce; import com.squareup.otto.Subscribe; import org.codehaus.jackson.JsonNode; import org.kegbot.api.KegbotApiException; import org.kegbot.api.KegbotApiImpl; import org.kegbot.app.BuildConfig; import org.kegbot.app.event.ConnectivityChangedEvent; import org.kegbot.app.event.ControllerListUpdateEvent; import org.kegbot.app.event.CurrentSessionChangedEvent; import org.kegbot.app.event.DrinkPostedEvent; import org.kegbot.app.event.FlowMeterListUpdateEvent; import org.kegbot.app.event.FlowToggleListUpdateEvent; import org.kegbot.app.event.SoundEventListUpdateEvent; import org.kegbot.app.event.SystemEventListUpdateEvent; import org.kegbot.app.storage.LocalDbHelper; import org.kegbot.app.util.TimeSeries; import org.kegbot.backend.Backend; import org.kegbot.backend.BackendException; import org.kegbot.backend.NotFoundException; import org.kegbot.proto.Api.RecordDrinkRequest; import org.kegbot.proto.Api.RecordTemperatureRequest; import org.kegbot.proto.Api.SyncResponse; import org.kegbot.proto.Internal.PendingPour; import org.kegbot.proto.Models; import org.kegbot.proto.Models.Controller; import org.kegbot.proto.Models.Drink; import org.kegbot.proto.Models.FlowMeter; import org.kegbot.proto.Models.KegTap; import org.kegbot.proto.Models.Session; import org.kegbot.proto.Models.SoundEvent; import org.kegbot.proto.Models.SystemEvent; import java.io.File; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; /** * Performs asynchronous work against a {@link Backend} and provides non-blocking cached data from * it. */ public class SyncManager extends BackgroundManager { private static String TAG = SyncManager.class.getSimpleName(); private static final boolean DEBUG = BuildConfig.DEBUG; private final Backend mBackend; private final TapManager mTapManager; private final Context mContext; @Nullable private SyncResponse mLastSync; private List<SystemEvent> mLastSystemEventList = Lists.newArrayList(); private List<SoundEvent> mLastSoundEventList = Lists.newArrayList(); private List<Controller> mLastControllers = Lists.newArrayList(); private List<FlowMeter> mLastFlowMeters = Lists.newArrayList(); private List<Models.FlowToggle> mLastFlowToggles = Lists.newArrayList(); @Nullable private Session mLastSession = null; @Nullable private JsonNode mLastSessionStats = null; private SQLiteOpenHelper mLocalDbHelper; private boolean mRunning = true; private boolean mSyncImmediate = true; private long mNextSyncTime = Long.MIN_VALUE; private static final long SYNC_INTERVAL_MILLIS = TimeUnit.MINUTES.toMillis(1); private static final long SYNC_INTERVAL_AGGRESSIVE_MILLIS = TimeUnit.SECONDS.toMillis(10); private ExecutorService mBackendExecutorService; public static Comparator<SystemEvent> EVENTS_DESCENDING = new Comparator<SystemEvent>() { @Override public int compare(SystemEvent object1, SystemEvent object2) { try { final long time1 = org.kegbot.app.util.DateUtils.dateFromIso8601String(object1.getTime()); final long time2 = org.kegbot.app.util.DateUtils.dateFromIso8601String(object2.getTime()); return Long.valueOf(time2).compareTo(Long.valueOf(time1)); } catch (IllegalArgumentException e) { Log.wtf(TAG, "Error parsing times", e); return 0; } } }; public static Comparator<SoundEvent> SOUND_EVENT_COMPARATOR = new Comparator<SoundEvent>() { @Override public int compare(SoundEvent object1, SoundEvent object2) { int cmp = object1.getEventName().compareTo(object2.getEventName()); if (cmp == 0) { cmp = object1.getEventPredicate().compareTo(object2.getEventPredicate()); } return cmp; } }; public SyncManager(Bus bus, Context context, Backend api, TapManager tapManager) { super(bus); mBackend = api; mTapManager = tapManager; mContext = context; } @Override public synchronized void start() { Log.d(TAG, "Opening local database"); mRunning = true; mSyncImmediate = true; mNextSyncTime = Long.MIN_VALUE; mBackendExecutorService = Executors.newSingleThreadExecutor(); mLocalDbHelper = new LocalDbHelper(mContext); mRunning = true; getBus().register(this); super.start(); } @Override public synchronized void stop() { mRunning = false; getBus().unregister(this); mBackendExecutorService.shutdown(); super.stop(); } /** Schedules a drink to be recorded asynchronously. */ public synchronized void recordDrinkAsync(final Flow flow) { if (!mRunning) { Log.e(TAG, "Record drink request while not running."); return; } final RecordDrinkRequest request = getRequestForFlow(flow); final PendingPour.Builder builder = PendingPour.newBuilder() .setDrinkRequest(request); if (!Strings.isNullOrEmpty(flow.getImagePath())) { builder.addImages(flow.getImagePath()); } final PendingPour pour = builder.build(); postDeferredPoursAsync(); mBackendExecutorService.submit(new Runnable() { @Override public void run() { try { postPour(pour); } catch (KegbotApiException e) { Log.d(TAG, "Caught exception posting pour: " + e); deferPostPour(pour); } catch (Exception e) { Log.w(TAG, "Error posting pour: " + e, e); } } }); } private void postDeferredPoursAsync() { mBackendExecutorService.submit(new Runnable() { @Override public void run() { postDeferredPours(); } }); } /** * Schedules a temperature reading to be recorded asynchronously. * * @param request */ public synchronized void recordTemperatureAsync(final RecordTemperatureRequest request) { if (!mRunning) { Log.e(TAG, "Record thermo request while not running."); return; } mBackendExecutorService.submit(new Runnable() { @Override public void run() { try { postThermoLog(request); } catch (BackendException e) { // Don't both retrying. Log.w(TAG, String.format("Error posting thermo, dropping: %s", e)); } } }); } public synchronized void requestSync() { Log.d(TAG, "Immediate sync requested."); mSyncImmediate = true; } @Produce public SystemEventListUpdateEvent produceSystemEvents() { return new SystemEventListUpdateEvent(Lists.newArrayList(mLastSystemEventList)); } @Produce public CurrentSessionChangedEvent produceCurrentSession() { return new CurrentSessionChangedEvent(mLastSession, mLastSessionStats); } @Produce public SoundEventListUpdateEvent produceSoundEvents() { return new SoundEventListUpdateEvent(mLastSoundEventList); } public List<Controller> getCurrentControllers() { return ImmutableList.copyOf(mLastControllers); } public List<FlowMeter> getCurrentFlowMeters() { return ImmutableList.copyOf(mLastFlowMeters); } public List<Models.FlowToggle> getCurrentFlowToggles() { return ImmutableList.copyOf(mLastFlowToggles); } @Subscribe public void handleConnectivityChangedEvent(ConnectivityChangedEvent event) { if (event.isConnected()) { Log.d(TAG, "Connection is up, requesting sync."); requestSync(); } else { Log.d(TAG, "Connection is down."); } } @Override protected void runInBackground() { Log.i(TAG, "Running in background."); try { while (true) { synchronized (this) { if (!mRunning) { Log.d(TAG, "No longer running, exiting."); break; } } long now = SystemClock.elapsedRealtime(); if (mSyncImmediate == true || now > mNextSyncTime) { Log.d(TAG, "Syncing: syncImmediate=" + mSyncImmediate + " mNextSyncTime=" + mNextSyncTime); mSyncImmediate = false; boolean syncError = true; try { syncError = syncNow(); } finally { mNextSyncTime = SystemClock.elapsedRealtime() + (syncError ? SYNC_INTERVAL_AGGRESSIVE_MILLIS : SYNC_INTERVAL_MILLIS); } } try { Thread.sleep(1000); } catch (InterruptedException e) { Log.d(TAG, "Interrupted."); Thread.currentThread().interrupt(); break; } } } catch (Throwable e) { Log.wtf(TAG, "Uncaught exception in background.", e); } } private void deferPostPour(PendingPour pour) { Log.d(TAG, "Deferring pour: " + pour); addSingleRequestToDb(pour); } /** * Synchronously posts a single pour to the remote backend. This method is guaranteed to have * succeeded on non-exceptional return. */ private void postPour(final PendingPour pour) throws KegbotApiException { final RecordDrinkRequest request = pour.getDrinkRequest(); Log.d(TAG, ">>> Posting pour: tap=" + request.getTapName() + " ticks=" + request.getTicks()); if (!isConnected()) { throw new KegbotApiException("Not connected."); } final Drink drink; File picture = null; if (pour.getImagesCount() > 0) { // TODO(mikey): Single image everywhere. picture = new File(pour.getImagesList().get(0)); if (!picture.exists()) { picture = null; } } try { TimeSeries ts = null; if (request.hasTickTimeSeries()) { ts = TimeSeries.fromString(request.getTickTimeSeries()); } drink = mBackend.recordDrink(request.getTapName(), (long) request.getVolumeMl(), request.getTicks(), request.getShout(), request.getUsername(), request.getRecordDate(), request.getDurationSeconds() * 1000L, ts, picture); } catch (NotFoundException e) { Log.w(TAG, "Tap does not exist, dropping pour."); return; } catch (BackendException e) { // TODO: Handle error. Log.w(TAG, "Other error."); return; } finally { for (final String image : pour.getImagesList()) { if (new File(image).delete()) { Log.d(TAG, "Deleted " + image); } } } Log.d(TAG, "<<< Success, drink posted: " + drink); postOnMainThread(new DrinkPostedEvent(drink)); requestSync(); } /** * Synchronously posts a single thermo log to the remote backend. This method is guaranteed to * have succeeded on non-exceptional return. */ private void postThermoLog(final RecordTemperatureRequest request) throws BackendException { Log.d(TAG, ">>> Posting thermo log: tap=" + request.getSensorName() + " value=" + request.getTempC()); if (!isConnected()) { throw new KegbotApiException("Not connected."); } mBackend.recordTemperature(request); Log.d(TAG, "<<< Success."); } /** Posts any queued requests to the api service. */ private void postDeferredPours() { final SQLiteDatabase db = mLocalDbHelper.getWritableDatabase(); // Fetch most recent entry. final Cursor cursor = db.query(LocalDbHelper.TABLE_NAME, null, null, null, null, null, LocalDbHelper.COLUMN_NAME_ADDED_DATE + " ASC", "1"); try { final int numPending = cursor.getCount(); if (numPending == 0) { return; } Log.d(TAG, String.format("Processing %s deferred pour%s.", Integer.valueOf(numPending), numPending == 1 ? "" : "s")); cursor.moveToFirst(); boolean deleteRow = true; try { final AbstractMessage record = LocalDbHelper.getCurrentRow(db, cursor); if (record instanceof PendingPour) { try { postPour((PendingPour) record); } catch (KegbotApiException e) { // Try later. deleteRow = false; } // Sync taps, etc, on new drink. mSyncImmediate = true; } } catch (InvalidProtocolBufferException e) { Log.w(TAG, "Error processing column: " + e); } if (deleteRow) { final int deleteResult = LocalDbHelper.deleteCurrentRow(db, cursor); Log.d(TAG, "Deleted row, result = " + deleteResult); } } finally { cursor.close(); db.close(); } } private boolean addSingleRequestToDb(AbstractMessage message) { Log.d(TAG, "Adding request to db!"); final String type; if (message instanceof PendingPour) { type = "pour"; } else if (message instanceof RecordTemperatureRequest) { type = "thermo"; } else { Log.w(TAG, "Unknown record type; dropping."); return false; } Log.d(TAG, "Request is a " + type); final ContentValues values = new ContentValues(); values.put(LocalDbHelper.COLUMN_NAME_TYPE, type); values.put(LocalDbHelper.COLUMN_NAME_RECORD, message.toByteArray()); boolean inserted = false; final SQLiteDatabase db = mLocalDbHelper.getWritableDatabase(); try { db.insert(LocalDbHelper.TABLE_NAME, null, values); inserted = true; } finally { db.close(); } return inserted; } private boolean isConnected() { final ConnectivityManager cm = (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); final NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); if (activeNetwork == null || !activeNetwork.isConnected()) { return false; } return true; } private boolean syncNow() { boolean error = false; if (mBackend instanceof KegbotApiImpl) { if (!isConnected()) { error = true; Log.d(TAG, "Network not connected."); return error; } } postDeferredPoursAsync(); // Taps. try { List<KegTap> newTaps = mBackend.getTaps(); mTapManager.updateTaps(newTaps); } catch (BackendException e) { Log.w(TAG, "Error syncing taps: " + e); error = true; } // System events. SystemEvent lastEvent = null; if (!mLastSystemEventList.isEmpty()) { lastEvent = mLastSystemEventList.get(0); } try { List<SystemEvent> newEvents; if (lastEvent != null) { newEvents = mBackend.getEventsSince(lastEvent.getId()); } else { newEvents = mBackend.getEvents(); } Collections.sort(newEvents, EVENTS_DESCENDING); if (!newEvents.isEmpty()) { mLastSystemEventList.clear(); mLastSystemEventList.addAll(newEvents); postOnMainThread(new SystemEventListUpdateEvent(mLastSystemEventList)); } } catch (BackendException e) { Log.w(TAG, "Error syncing events: " + e); error = true; } // Current session try { Session currentSession = mBackend.getCurrentSession(); if ((currentSession == null && mLastSession != null) || (mLastSession == null && currentSession != null) || (currentSession != null && !currentSession.equals(mLastSession))) { JsonNode stats = null; if (currentSession != null) { stats = mBackend.getSessionStats(currentSession.getId()); } mLastSession = currentSession; mLastSessionStats = stats; postOnMainThread(new CurrentSessionChangedEvent(currentSession, stats)); } } catch (BackendException e) { Log.w(TAG, "Error syncing current session: " + e); error = true; } // Sound events try { List<SoundEvent> events = mBackend.getSoundEvents(); Collections.sort(events, SOUND_EVENT_COMPARATOR); if (!events.equals(mLastSoundEventList)) { mLastSoundEventList.clear(); mLastSoundEventList.addAll(events); postOnMainThread(new SoundEventListUpdateEvent(mLastSoundEventList)); } } catch (BackendException e) { Log.w(TAG, "Error syncing sound events: " + e); error = true; } // Controllers try { List<Controller> controllers = mBackend.getControllers(); if (!controllers.equals(mLastControllers)) { mLastControllers.clear(); mLastControllers.addAll(controllers); postOnMainThread(new ControllerListUpdateEvent(mLastControllers)); } } catch (BackendException e) { Log.w(TAG, "Error syncing controllers: " + e); error = true; } // Flow Meters try { List<FlowMeter> meters = mBackend.getFlowMeters(); if (!meters.equals(mLastFlowMeters)) { mLastFlowMeters.clear(); mLastFlowMeters.addAll(meters); postOnMainThread(new FlowMeterListUpdateEvent(mLastFlowMeters)); } } catch (BackendException e) { Log.w(TAG, "Error syncing flow meters: " + e); error = true; } // Flow Toggles try { List<Models.FlowToggle> toggles = mBackend.getFlowToggles(); if (!toggles.equals(mLastFlowToggles)) { mLastFlowToggles.clear(); mLastFlowToggles.addAll(toggles); postOnMainThread(new FlowToggleListUpdateEvent(mLastFlowToggles)); } } catch (BackendException e) { Log.w(TAG, "Error syncing flow toggles: " + e); error = true; } return error; } private static RecordDrinkRequest getRequestForFlow(final Flow ended) { return RecordDrinkRequest.newBuilder() .setTapName(ended.getTap().getMeter().getName()) .setTicks(ended.getTicks()) .setVolumeMl((float) ended.getVolumeMl()) .setUsername(ended.getUsername()) .setSecondsAgo(0) .setDurationSeconds((int) (ended.getDurationMs() / 1000.0)) .setSpilled(false) .setShout(ended.getShout()) .setTickTimeSeries(ended.getTickTimeSeries().asString()) .buildPartial(); } }