package nodomain.freeyourgadget.gadgetbridge.database; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.widget.Toast; import java.io.File; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Objects; import de.greenrobot.dao.Property; import de.greenrobot.dao.query.Query; import de.greenrobot.dao.query.QueryBuilder; import de.greenrobot.dao.query.WhereCondition; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleHealthSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleMisfitSampleProvider; import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.ActivityDescription; import nodomain.freeyourgadget.gadgetbridge.entities.ActivityDescriptionDao; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.DeviceAttributes; import nodomain.freeyourgadget.gadgetbridge.entities.DeviceAttributesDao; import nodomain.freeyourgadget.gadgetbridge.entities.DeviceDao; import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivityOverlay; import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivityOverlayDao; import nodomain.freeyourgadget.gadgetbridge.entities.Tag; import nodomain.freeyourgadget.gadgetbridge.entities.TagDao; import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.entities.UserAttributes; import nodomain.freeyourgadget.gadgetbridge.entities.UserDao; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.ValidByDate; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; import static nodomain.freeyourgadget.gadgetbridge.database.DBConstants.KEY_CUSTOM_SHORT; import static nodomain.freeyourgadget.gadgetbridge.database.DBConstants.KEY_INTENSITY; import static nodomain.freeyourgadget.gadgetbridge.database.DBConstants.KEY_STEPS; import static nodomain.freeyourgadget.gadgetbridge.database.DBConstants.KEY_TIMESTAMP; import static nodomain.freeyourgadget.gadgetbridge.database.DBConstants.KEY_TYPE; import static nodomain.freeyourgadget.gadgetbridge.database.DBConstants.TABLE_GBACTIVITYSAMPLES; /** * Provides utiliy access to some common entities, so you won't need to use * their DAO classes. *

* Maybe this code should actually be in the DAO classes themselves, but then * these should be under revision control instead of 100% generated at build time. */ public class DBHelper { private final Context context; public DBHelper(Context context) { this.context = context; } /** * Closes the database and returns its name. * Important: after calling this, you have to DBHandler#openDb() it again * to get it back to work. * * @param dbHandler * @return * @throws IllegalStateException */ private String getClosedDBPath(DBHandler dbHandler) throws IllegalStateException { SQLiteDatabase db = dbHandler.getDatabase(); String path = db.getPath(); dbHandler.closeDb(); if (db.isOpen()) { // reference counted, so may still be open throw new IllegalStateException("Database must be closed"); } return path; } public File exportDB(DBHandler dbHandler, File toDir) throws IllegalStateException, IOException { String dbPath = getClosedDBPath(dbHandler); try { File sourceFile = new File(dbPath); File destFile = new File(toDir, sourceFile.getName()); if (destFile.exists()) { File backup = new File(toDir, destFile.getName() + "_" + getDate()); destFile.renameTo(backup); } else if (!toDir.exists()) { if (!toDir.mkdirs()) { throw new IOException("Unable to create directory: " + toDir.getAbsolutePath()); } } FileUtils.copyFile(sourceFile, destFile); return destFile; } finally { dbHandler.openDb(); } } private String getDate() { return new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(new Date()); } public void importDB(DBHandler dbHandler, File fromFile) throws IllegalStateException, IOException { String dbPath = getClosedDBPath(dbHandler); try { File toFile = new File(dbPath); FileUtils.copyFile(fromFile, toFile); } finally { dbHandler.openDb(); } } public void validateDB(SQLiteOpenHelper dbHandler) throws IOException { try (SQLiteDatabase db = dbHandler.getReadableDatabase()) { if (!db.isDatabaseIntegrityOk()) { throw new IOException("Database integrity is not OK"); } } } public static void dropTable(String tableName, SQLiteDatabase db) { String statement = "DROP TABLE IF EXISTS '" + tableName + "'"; db.execSQL(statement); } public boolean existsDB(String dbName) { File path = context.getDatabasePath(dbName); return path != null && path.exists(); } public static boolean existsColumn(String tableName, String columnName, SQLiteDatabase db) { try (Cursor res = db.rawQuery("PRAGMA table_info('" + tableName + "')", null)) { int index = res.getColumnIndex("name"); if (index < 1) { return false; // something's really wrong } while (res.moveToNext()) { String cn = res.getString(index); if (columnName.equals(cn)) { return true; } } } return false; } /** * WITHOUT ROWID is only available with sqlite 3.8.2, which is available * with Lollipop and later. * * @return the "WITHOUT ROWID" string or an empty string for pre-Lollipop devices */ public static String getWithoutRowId() { if (GBApplication.isRunningLollipopOrLater()) { return " WITHOUT ROWID;"; } return ""; } /** * Looks up the user entity in the database. If a user exists already, it will * be updated with the current preferences values. If no user exists yet, it will * be created in the database. * * Note: so far there is only ever a single user; there is no multi-user support yet * @param session * @return the User entity */ public static User getUser(DaoSession session) { ActivityUser prefsUser = new ActivityUser(); UserDao userDao = session.getUserDao(); User user; List users = userDao.loadAll(); if (users.isEmpty()) { user = createUser(prefsUser, session); } else { user = users.get(0); // TODO: multiple users support? ensureUserUpToDate(user, prefsUser, session); } ensureUserAttributes(user, prefsUser, session); return user; } private static User createUser(ActivityUser prefsUser, DaoSession session) { User user = new User(); ensureUserUpToDate(user, prefsUser, session); return user; } private static void ensureUserUpToDate(User user, ActivityUser prefsUser, DaoSession session) { if (!isUserUpToDate(user, prefsUser)) { user.setName(prefsUser.getName()); user.setBirthday(prefsUser.getUserBirthday()); user.setGender(prefsUser.getGender()); if (user.getId() == null) { session.getUserDao().insert(user); } else { session.getUserDao().update(user); } } } public static boolean isUserUpToDate(User user, ActivityUser prefsUser) { if (!Objects.equals(user.getName(), prefsUser.getName())) { return false; } if (!Objects.equals(user.getBirthday(), prefsUser.getUserBirthday())) { return false; } if (user.getGender() != prefsUser.getGender()) { return false; } return true; } private static void ensureUserAttributes(User user, ActivityUser prefsUser, DaoSession session) { List userAttributes = user.getUserAttributesList(); UserAttributes[] previousUserAttributes = new UserAttributes[1]; if (hasUpToDateUserAttributes(userAttributes, prefsUser, previousUserAttributes)) { return; } Calendar now = DateTimeUtils.getCalendarUTC(); invalidateUserAttributes(previousUserAttributes[0], now, session); UserAttributes attributes = new UserAttributes(); attributes.setValidFromUTC(now.getTime()); attributes.setHeightCM(prefsUser.getHeightCm()); attributes.setWeightKG(prefsUser.getWeightKg()); attributes.setUserId(user.getId()); session.getUserAttributesDao().insert(attributes); userAttributes.add(attributes); } private static void invalidateUserAttributes(UserAttributes userAttributes, Calendar now, DaoSession session) { if (userAttributes != null) { Calendar invalid = (Calendar) now.clone(); invalid.add(Calendar.MINUTE, -1); userAttributes.setValidToUTC(invalid.getTime()); session.getUserAttributesDao().update(userAttributes); } } private static boolean hasUpToDateUserAttributes(List userAttributes, ActivityUser prefsUser, UserAttributes[] outPreviousUserAttributes) { for (UserAttributes attr : userAttributes) { if (!isValidNow(attr)) { continue; } if (isEqual(attr, prefsUser)) { return true; } else { outPreviousUserAttributes[0] = attr; } } return false; } // TODO: move this into db queries? private static boolean isValidNow(ValidByDate element) { Calendar cal = DateTimeUtils.getCalendarUTC(); Date nowUTC = cal.getTime(); return isValid(element, nowUTC); } private static boolean isValid(ValidByDate element, Date nowUTC) { Date validFromUTC = element.getValidFromUTC(); Date validToUTC = element.getValidToUTC(); if (nowUTC.before(validFromUTC)) { return false; } if (validToUTC != null && nowUTC.after(validToUTC)) { return false; } return true; } private static boolean isEqual(UserAttributes attr, ActivityUser prefsUser) { if (prefsUser.getHeightCm() != attr.getHeightCM()) { return false; } if (prefsUser.getWeightKg() != attr.getWeightKG()) { return false; } if (!Integer.valueOf(prefsUser.getSleepDuration()).equals(attr.getSleepGoalHPD())) { return false; } if (!Integer.valueOf(prefsUser.getStepsGoal()).equals(attr.getStepsGoalSPD())) { return false; } return true; } private static boolean isEqual(DeviceAttributes attr, GBDevice gbDevice) { if (!Objects.equals(attr.getFirmwareVersion1(), gbDevice.getFirmwareVersion())) { return false; } if (!Objects.equals(attr.getFirmwareVersion2(), gbDevice.getFirmwareVersion2())) { return false; } return true; } public static Device findDevice(GBDevice gbDevice, DaoSession session) { DeviceDao deviceDao = session.getDeviceDao(); Query query = deviceDao.queryBuilder().where(DeviceDao.Properties.Identifier.eq(gbDevice.getAddress())).build(); List devices = query.list(); if (devices.size() > 0) { return devices.get(0); } return null; } /** * Looks up in the database the Device entity corresponding to the GBDevice. If a device * exists already, it will be updated with the current preferences values. If no device exists * yet, it will be created in the database. * * @param session * @return the device entity corresponding to the given GBDevice */ public static Device getDevice(GBDevice gbDevice, DaoSession session) { Device device = findDevice(gbDevice, session); if (device == null) { device = createDevice(gbDevice, session); } else { ensureDeviceUpToDate(device, gbDevice, session); } ensureDeviceAttributes(device, gbDevice, session); return device; } private static void ensureDeviceUpToDate(Device device, GBDevice gbDevice, DaoSession session) { if (!isDeviceUpToDate(device, gbDevice)) { device.setIdentifier(gbDevice.getAddress()); device.setName(gbDevice.getName()); DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); device.setManufacturer(coordinator.getManufacturer()); device.setType(gbDevice.getType().getKey()); device.setModel(gbDevice.getModel()); if (device.getId() == null) { session.getDeviceDao().insert(device); } else { session.getDeviceDao().update(device); } } } private static boolean isDeviceUpToDate(Device device, GBDevice gbDevice) { if (!Objects.equals(device.getIdentifier(), gbDevice.getAddress())) { return false; } if (!Objects.equals(device.getName(), gbDevice.getName())) { return false; } DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); if (!Objects.equals(device.getManufacturer(), coordinator.getManufacturer())) { return false; } if (device.getType() != gbDevice.getType().getKey()) { return false; } if (!Objects.equals(device.getModel(), gbDevice.getModel())) { return false; } return true; } private static Device createDevice(GBDevice gbDevice, DaoSession session) { Device device = new Device(); ensureDeviceUpToDate(device, gbDevice, session); return device; } private static void ensureDeviceAttributes(Device device, GBDevice gbDevice, DaoSession session) { List deviceAttributes = device.getDeviceAttributesList(); DeviceAttributes[] previousDeviceAttributes = new DeviceAttributes[1]; if (hasUpToDateDeviceAttributes(deviceAttributes, gbDevice, previousDeviceAttributes)) { return; } Calendar now = DateTimeUtils.getCalendarUTC(); invalidateDeviceAttributes(previousDeviceAttributes[0], now, session); DeviceAttributes attributes = new DeviceAttributes(); attributes.setDeviceId(device.getId()); attributes.setValidFromUTC(now.getTime()); attributes.setFirmwareVersion1(gbDevice.getFirmwareVersion()); attributes.setFirmwareVersion2(gbDevice.getFirmwareVersion2()); DeviceAttributesDao attributesDao = session.getDeviceAttributesDao(); attributesDao.insert(attributes); deviceAttributes.add(attributes); } private static void invalidateDeviceAttributes(DeviceAttributes deviceAttributes, Calendar now, DaoSession session) { if (deviceAttributes != null) { Calendar invalid = (Calendar) now.clone(); invalid.add(Calendar.MINUTE, -1); deviceAttributes.setValidToUTC(invalid.getTime()); session.getDeviceAttributesDao().update(deviceAttributes); } } private static boolean hasUpToDateDeviceAttributes(List deviceAttributes, GBDevice gbDevice, DeviceAttributes[] outPreviousAttributes) { for (DeviceAttributes attr : deviceAttributes) { if (!isValidNow(attr)) { continue; } if (isEqual(attr, gbDevice)) { return true; } else { outPreviousAttributes[0] = attr; } } return false; } @NonNull public static List findActivityDecriptions(@NonNull User user, int tsFrom, int tsTo, @NonNull DaoSession session) { Property tsFromProperty = ActivityDescriptionDao.Properties.TimestampFrom; Property tsToProperty = ActivityDescriptionDao.Properties.TimestampTo; Property userIdProperty = ActivityDescriptionDao.Properties.UserId; QueryBuilder qb = session.getActivityDescriptionDao().queryBuilder(); qb.where(userIdProperty.eq(user.getId()), isAtLeastPartiallyInRange(qb, tsFromProperty, tsToProperty, tsFrom, tsTo)); List descriptions = qb.build().list(); return descriptions; } /** * Returns a condition that matches when the range of the entity (tsFromProperty..tsToProperty) * is completely or partially inside the range tsFrom..tsTo. * @param qb the query builder to use * @param tsFromProperty the property indicating the start of the entity's range * @param tsToProperty the property indicating the end of the entity's range * @param tsFrom the timestamp indicating the start of the range to match * @param tsTo the timestamp indicating the end of the range to match * @param the query builder's type parameter * @return the range WhereCondition */ private static WhereCondition isAtLeastPartiallyInRange(QueryBuilder qb, Property tsFromProperty, Property tsToProperty, int tsFrom, int tsTo) { return qb.and(tsFromProperty.lt(tsTo), tsToProperty.gt(tsFrom)); } @NonNull public static ActivityDescription createActivityDescription(@NonNull User user, int tsFrom, int tsTo, @NonNull DaoSession session) { ActivityDescription desc = new ActivityDescription(); desc.setUser(user); desc.setTimestampFrom(tsFrom); desc.setTimestampTo(tsTo); session.getActivityDescriptionDao().insertOrReplace(desc); return desc; } @NonNull public static Tag getTag(@NonNull User user, @NonNull String name, @NonNull DaoSession session) { TagDao tagDao = session.getTagDao(); QueryBuilder qb = tagDao.queryBuilder(); Query query = qb.where(TagDao.Properties.UserId.eq(user.getId()), TagDao.Properties.Name.eq(name)).build(); List tags = query.list(); if (tags.size() > 0) { return tags.get(0); } return createTag(user, name, null, session); } static Tag createTag(@NonNull User user, @NonNull String name, @Nullable String description, @NonNull DaoSession session) { Tag tag = new Tag(); tag.setUserId(user.getId()); tag.setName(name); tag.setDescription(description); session.getTagDao().insertOrReplace(tag); return tag; } /** * Returns the old activity database handler if there is any content in that * db, or null otherwise. * * @return the old activity db handler or null */ @Nullable public ActivityDatabaseHandler getOldActivityDatabaseHandler() { ActivityDatabaseHandler handler = new ActivityDatabaseHandler(context); if (handler.hasContent()) { return handler; } return null; } public void importOldDb(ActivityDatabaseHandler oldDb, GBDevice targetDevice, DBHandler targetDBHandler) { DaoSession tempSession = targetDBHandler.getDaoMaster().newSession(); try { importActivityDatabase(oldDb, targetDevice, tempSession); } finally { tempSession.clear(); } } private boolean isEmpty(DaoSession session) { long totalSamplesCount = session.getMiBandActivitySampleDao().count(); totalSamplesCount += session.getPebbleHealthActivitySampleDao().count(); return totalSamplesCount == 0; } private void importActivityDatabase(ActivityDatabaseHandler oldDbHandler, GBDevice targetDevice, DaoSession session) { try (SQLiteDatabase oldDB = oldDbHandler.getReadableDatabase()) { User user = DBHelper.getUser(session); for (DeviceCoordinator coordinator : DeviceHelper.getInstance().getAllCoordinators()) { if (coordinator.supports(targetDevice)) { AbstractSampleProvider sampleProvider = (AbstractSampleProvider) coordinator.getSampleProvider(targetDevice, session); importActivitySamples(oldDB, targetDevice, session, sampleProvider, user); break; } } } } private void importActivitySamples(SQLiteDatabase fromDb, GBDevice targetDevice, DaoSession targetSession, AbstractSampleProvider sampleProvider, User user) { if (sampleProvider instanceof PebbleMisfitSampleProvider) { GB.toast(context, "Migration of old Misfit data is not supported!", Toast.LENGTH_LONG, GB.WARN); return; } String order = "timestamp"; final String where = "provider=" + sampleProvider.getID(); boolean convertActivityTypeToRange = false; int currentTypeRun, previousTypeRun, currentTimeStamp, currentTypeStartTimeStamp, currentTypeEndTimeStamp; List overlayList = new ArrayList<>(); final int BATCH_SIZE = 100000; // 100.000 samples = rougly 20 MB per batch List newSamples; if (sampleProvider instanceof PebbleHealthSampleProvider) { convertActivityTypeToRange = true; previousTypeRun = ActivitySample.NOT_MEASURED; currentTypeStartTimeStamp = -1; currentTypeEndTimeStamp = -1; } else { previousTypeRun = currentTypeStartTimeStamp = currentTypeEndTimeStamp = 0; } try (Cursor cursor = fromDb.query(TABLE_GBACTIVITYSAMPLES, null, where, null, null, null, order)) { int colTimeStamp = cursor.getColumnIndex(KEY_TIMESTAMP); int colIntensity = cursor.getColumnIndex(KEY_INTENSITY); int colSteps = cursor.getColumnIndex(KEY_STEPS); int colType = cursor.getColumnIndex(KEY_TYPE); int colCustomShort = cursor.getColumnIndex(KEY_CUSTOM_SHORT); Long deviceId = DBHelper.getDevice(targetDevice, targetSession).getId(); Long userId = user.getId(); newSamples = new ArrayList<>(Math.min(BATCH_SIZE, cursor.getCount())); while (cursor.moveToNext()) { T newSample = sampleProvider.createActivitySample(); newSample.setProvider(sampleProvider); newSample.setUserId(userId); newSample.setDeviceId(deviceId); currentTimeStamp = cursor.getInt(colTimeStamp); newSample.setTimestamp(currentTimeStamp); newSample.setRawIntensity(getNullableInt(cursor, colIntensity, ActivitySample.NOT_MEASURED)); currentTypeRun = getNullableInt(cursor, colType, ActivitySample.NOT_MEASURED); newSample.setRawKind(currentTypeRun); if (convertActivityTypeToRange) { //at the beginning there is no start timestamp if (currentTypeStartTimeStamp == -1) { currentTypeStartTimeStamp = currentTypeEndTimeStamp = currentTimeStamp; previousTypeRun = currentTypeRun; } if (currentTypeRun != previousTypeRun) { //we used not to store the last sample, now we do the opposite and we need to round up currentTypeEndTimeStamp = currentTimeStamp; //if the Type has changed, the run has ended. Only store light and deep sleep data if (previousTypeRun == 4) { overlayList.add(new PebbleHealthActivityOverlay(currentTypeStartTimeStamp, currentTypeEndTimeStamp, sampleProvider.toRawActivityKind(ActivityKind.TYPE_LIGHT_SLEEP), deviceId, userId, null)); } else if (previousTypeRun == 5) { overlayList.add(new PebbleHealthActivityOverlay(currentTypeStartTimeStamp, currentTypeEndTimeStamp, sampleProvider.toRawActivityKind(ActivityKind.TYPE_DEEP_SLEEP), deviceId, userId, null)); } currentTypeStartTimeStamp = currentTimeStamp; previousTypeRun = currentTypeRun; } else { //just expand the run currentTypeEndTimeStamp = currentTimeStamp; } } newSample.setSteps(getNullableInt(cursor, colSteps, ActivitySample.NOT_MEASURED)); if (colCustomShort > -1) { newSample.setHeartRate(getNullableInt(cursor, colCustomShort, ActivitySample.NOT_MEASURED)); } else { newSample.setHeartRate(ActivitySample.NOT_MEASURED); } newSamples.add(newSample); if ((newSamples.size() % BATCH_SIZE) == 0) { sampleProvider.getSampleDao().insertOrReplaceInTx(newSamples, true); targetSession.clear(); newSamples.clear(); } } // and insert the remaining samples if (!newSamples.isEmpty()) { sampleProvider.getSampleDao().insertOrReplaceInTx(newSamples, true); } // store the overlay records if (!overlayList.isEmpty()) { PebbleHealthActivityOverlayDao overlayDao = targetSession.getPebbleHealthActivityOverlayDao(); overlayDao.insertOrReplaceInTx(overlayList); } } } private int getNullableInt(Cursor cursor, int columnIndex, int defaultValue) { if (cursor.isNull(columnIndex)) { return defaultValue; } return cursor.getInt(columnIndex); } }