2015-07-08 23:03:34 +02:00
|
|
|
package nodomain.freeyourgadget.gadgetbridge.database;
|
|
|
|
|
|
|
|
import android.content.Context;
|
2016-03-04 23:19:44 +01:00
|
|
|
import android.database.Cursor;
|
2015-07-08 23:03:34 +02:00
|
|
|
import android.database.sqlite.SQLiteDatabase;
|
|
|
|
import android.database.sqlite.SQLiteOpenHelper;
|
2016-08-27 00:23:41 +02:00
|
|
|
import android.support.annotation.NonNull;
|
2016-06-17 00:07:50 +02:00
|
|
|
import android.support.annotation.Nullable;
|
2016-08-20 21:38:39 +02:00
|
|
|
import android.widget.Toast;
|
2015-07-08 23:03:34 +02:00
|
|
|
|
2016-09-11 12:23:36 +02:00
|
|
|
import org.slf4j.Logger;
|
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
|
2015-07-08 23:03:34 +02:00
|
|
|
import java.io.File;
|
|
|
|
import java.io.IOException;
|
|
|
|
import java.text.SimpleDateFormat;
|
2016-06-17 00:07:50 +02:00
|
|
|
import java.util.ArrayList;
|
2016-06-06 23:18:46 +02:00
|
|
|
import java.util.Calendar;
|
2015-07-08 23:03:34 +02:00
|
|
|
import java.util.Date;
|
2016-05-13 23:47:47 +02:00
|
|
|
import java.util.List;
|
2015-07-08 23:03:34 +02:00
|
|
|
import java.util.Locale;
|
2016-08-24 23:14:25 +02:00
|
|
|
import java.util.Objects;
|
2015-07-08 23:03:34 +02:00
|
|
|
|
2016-08-27 00:23:41 +02:00
|
|
|
import de.greenrobot.dao.Property;
|
2016-05-13 23:47:47 +02:00
|
|
|
import de.greenrobot.dao.query.Query;
|
2016-08-27 00:23:41 +02:00
|
|
|
import de.greenrobot.dao.query.QueryBuilder;
|
2016-08-28 00:22:34 +02:00
|
|
|
import de.greenrobot.dao.query.WhereCondition;
|
2015-07-10 00:31:45 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
2016-06-17 00:07:50 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
|
2016-05-13 23:47:47 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
|
2016-08-19 21:09:32 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleHealthSampleProvider;
|
2016-08-20 21:38:39 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleMisfitSampleProvider;
|
2016-06-17 00:07:50 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
|
2016-08-27 00:23:41 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.entities.ActivityDescription;
|
|
|
|
import nodomain.freeyourgadget.gadgetbridge.entities.ActivityDescriptionDao;
|
2016-05-13 23:47:47 +02:00
|
|
|
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;
|
2016-08-19 21:09:32 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivityOverlay;
|
|
|
|
import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivityOverlayDao;
|
2016-08-27 00:23:41 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.entities.Tag;
|
|
|
|
import nodomain.freeyourgadget.gadgetbridge.entities.TagDao;
|
2016-05-13 23:47:47 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.entities.User;
|
|
|
|
import nodomain.freeyourgadget.gadgetbridge.entities.UserAttributes;
|
|
|
|
import nodomain.freeyourgadget.gadgetbridge.entities.UserDao;
|
|
|
|
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
2016-08-19 21:09:32 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
|
2016-07-28 22:12:20 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
|
2016-05-13 23:47:47 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
|
2016-06-06 23:18:46 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.model.ValidByDate;
|
2016-05-13 23:47:47 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
|
|
|
import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
|
2015-07-08 23:03:34 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
|
2016-08-20 21:38:39 +02:00
|
|
|
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
2015-07-08 23:03:34 +02:00
|
|
|
|
2016-06-17 00:07:50 +02:00
|
|
|
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;
|
|
|
|
|
2016-06-14 20:13:08 +02:00
|
|
|
/**
|
|
|
|
* Provides utiliy access to some common entities, so you won't need to use
|
|
|
|
* their DAO classes.
|
2016-08-19 21:09:32 +02:00
|
|
|
* <p/>
|
2016-06-14 20:13:08 +02:00
|
|
|
* 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.
|
|
|
|
*/
|
2015-07-08 23:03:34 +02:00
|
|
|
public class DBHelper {
|
2016-09-11 12:23:36 +02:00
|
|
|
private static final Logger LOG = LoggerFactory.getLogger(DBHelper.class);
|
|
|
|
|
2015-07-08 23:03:34 +02:00
|
|
|
private final Context context;
|
|
|
|
|
|
|
|
public DBHelper(Context context) {
|
|
|
|
this.context = context;
|
|
|
|
}
|
|
|
|
|
2016-05-17 00:51:00 +02:00
|
|
|
/**
|
|
|
|
* Closes the database and returns its name.
|
|
|
|
* Important: after calling this, you have to DBHandler#openDb() it again
|
|
|
|
* to get it back to work.
|
2016-08-19 21:09:32 +02:00
|
|
|
*
|
2016-05-17 00:51:00 +02:00
|
|
|
* @param dbHandler
|
|
|
|
* @return
|
|
|
|
* @throws IllegalStateException
|
|
|
|
*/
|
|
|
|
private String getClosedDBPath(DBHandler dbHandler) throws IllegalStateException {
|
|
|
|
SQLiteDatabase db = dbHandler.getDatabase();
|
2015-07-08 23:03:34 +02:00
|
|
|
String path = db.getPath();
|
2016-05-17 00:51:00 +02:00
|
|
|
dbHandler.closeDb();
|
2015-08-03 01:17:02 +02:00
|
|
|
if (db.isOpen()) { // reference counted, so may still be open
|
2015-07-08 23:03:34 +02:00
|
|
|
throw new IllegalStateException("Database must be closed");
|
|
|
|
}
|
|
|
|
return path;
|
|
|
|
}
|
|
|
|
|
2016-05-17 00:51:00 +02:00
|
|
|
public File exportDB(DBHandler dbHandler, File toDir) throws IllegalStateException, IOException {
|
2015-07-08 23:03:34 +02:00
|
|
|
String dbPath = getClosedDBPath(dbHandler);
|
2016-05-17 00:51:00 +02:00
|
|
|
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());
|
|
|
|
}
|
2015-07-08 23:03:34 +02:00
|
|
|
}
|
|
|
|
|
2016-05-17 00:51:00 +02:00
|
|
|
FileUtils.copyFile(sourceFile, destFile);
|
|
|
|
return destFile;
|
|
|
|
} finally {
|
|
|
|
dbHandler.openDb();
|
|
|
|
}
|
2015-07-08 23:03:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private String getDate() {
|
|
|
|
return new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(new Date());
|
|
|
|
}
|
|
|
|
|
2016-05-17 00:51:00 +02:00
|
|
|
public void importDB(DBHandler dbHandler, File fromFile) throws IllegalStateException, IOException {
|
2015-07-08 23:03:34 +02:00
|
|
|
String dbPath = getClosedDBPath(dbHandler);
|
2016-05-17 00:51:00 +02:00
|
|
|
try {
|
|
|
|
File toFile = new File(dbPath);
|
|
|
|
FileUtils.copyFile(fromFile, toFile);
|
|
|
|
} finally {
|
|
|
|
dbHandler.openDb();
|
|
|
|
}
|
2015-07-08 23:03:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
public void validateDB(SQLiteOpenHelper dbHandler) throws IOException {
|
|
|
|
try (SQLiteDatabase db = dbHandler.getReadableDatabase()) {
|
|
|
|
if (!db.isDatabaseIntegrityOk()) {
|
|
|
|
throw new IOException("Database integrity is not OK");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2015-07-10 00:31:45 +02:00
|
|
|
|
|
|
|
public static void dropTable(String tableName, SQLiteDatabase db) {
|
2015-07-11 21:16:07 +02:00
|
|
|
String statement = "DROP TABLE IF EXISTS '" + tableName + "'";
|
|
|
|
db.execSQL(statement);
|
2015-07-10 00:31:45 +02:00
|
|
|
}
|
|
|
|
|
2016-08-27 22:51:00 +02:00
|
|
|
public boolean existsDB(String dbName) {
|
|
|
|
File path = context.getDatabasePath(dbName);
|
|
|
|
return path != null && path.exists();
|
|
|
|
}
|
|
|
|
|
2016-03-04 23:19:44 +01:00
|
|
|
public static boolean existsColumn(String tableName, String columnName, SQLiteDatabase db) {
|
|
|
|
try (Cursor res = db.rawQuery("PRAGMA table_info('" + tableName + "')", null)) {
|
2016-03-07 00:36:39 +01:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
2016-03-04 23:19:44 +01:00
|
|
|
}
|
2016-03-07 00:36:39 +01:00
|
|
|
return false;
|
2016-03-04 23:19:44 +01:00
|
|
|
}
|
|
|
|
|
2015-07-10 00:31:45 +02:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
2016-09-03 21:16:32 +02:00
|
|
|
@NonNull
|
2015-07-10 00:31:45 +02:00
|
|
|
public static String getWithoutRowId() {
|
|
|
|
if (GBApplication.isRunningLollipopOrLater()) {
|
|
|
|
return " WITHOUT ROWID;";
|
|
|
|
}
|
|
|
|
return "";
|
|
|
|
}
|
2016-05-13 23:47:47 +02:00
|
|
|
|
2016-08-27 22:34:30 +02:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
2016-09-03 21:16:32 +02:00
|
|
|
@NonNull
|
2016-05-16 23:00:04 +02:00
|
|
|
public static User getUser(DaoSession session) {
|
2016-05-23 23:31:22 +02:00
|
|
|
ActivityUser prefsUser = new ActivityUser();
|
2016-06-14 20:13:08 +02:00
|
|
|
UserDao userDao = session.getUserDao();
|
2016-05-23 23:31:22 +02:00
|
|
|
User user;
|
2016-08-27 22:34:30 +02:00
|
|
|
List<User> users = userDao.loadAll();
|
2016-05-13 23:47:47 +02:00
|
|
|
if (users.isEmpty()) {
|
2016-05-23 23:31:22 +02:00
|
|
|
user = createUser(prefsUser, session);
|
|
|
|
} else {
|
|
|
|
user = users.get(0); // TODO: multiple users support?
|
2016-08-27 22:34:30 +02:00
|
|
|
ensureUserUpToDate(user, prefsUser, session);
|
2016-05-13 23:47:47 +02:00
|
|
|
}
|
2016-05-23 23:31:22 +02:00
|
|
|
ensureUserAttributes(user, prefsUser, session);
|
|
|
|
|
|
|
|
return user;
|
2016-05-13 23:47:47 +02:00
|
|
|
}
|
|
|
|
|
2016-09-03 21:16:32 +02:00
|
|
|
@NonNull
|
|
|
|
public static UserAttributes getUserAttributes(User user) {
|
|
|
|
List<UserAttributes> list = user.getUserAttributesList();
|
|
|
|
if (list.isEmpty()) {
|
|
|
|
throw new IllegalStateException("user has no attributes");
|
|
|
|
}
|
|
|
|
return list.get(0);
|
|
|
|
}
|
|
|
|
|
|
|
|
@NonNull
|
2016-05-23 23:31:22 +02:00
|
|
|
private static User createUser(ActivityUser prefsUser, DaoSession session) {
|
2016-05-13 23:47:47 +02:00
|
|
|
User user = new User();
|
2016-08-27 22:34:30 +02:00
|
|
|
ensureUserUpToDate(user, prefsUser, session);
|
2016-05-13 23:47:47 +02:00
|
|
|
|
2016-05-23 23:31:22 +02:00
|
|
|
return user;
|
|
|
|
}
|
|
|
|
|
2016-08-27 22:34:30 +02:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2016-05-23 23:31:22 +02:00
|
|
|
private static void ensureUserAttributes(User user, ActivityUser prefsUser, DaoSession session) {
|
|
|
|
List<UserAttributes> userAttributes = user.getUserAttributesList();
|
2016-08-13 00:52:35 +02:00
|
|
|
UserAttributes[] previousUserAttributes = new UserAttributes[1];
|
|
|
|
if (hasUpToDateUserAttributes(userAttributes, prefsUser, previousUserAttributes)) {
|
2016-05-23 23:31:22 +02:00
|
|
|
return;
|
|
|
|
}
|
2016-08-13 00:52:35 +02:00
|
|
|
|
|
|
|
Calendar now = DateTimeUtils.getCalendarUTC();
|
|
|
|
invalidateUserAttributes(previousUserAttributes[0], now, session);
|
|
|
|
|
2016-05-13 23:47:47 +02:00
|
|
|
UserAttributes attributes = new UserAttributes();
|
2016-08-13 00:52:35 +02:00
|
|
|
attributes.setValidFromUTC(now.getTime());
|
2016-05-13 23:47:47 +02:00
|
|
|
attributes.setHeightCM(prefsUser.getHeightCm());
|
|
|
|
attributes.setWeightKG(prefsUser.getWeightKg());
|
2016-09-11 12:35:26 +02:00
|
|
|
attributes.setSleepGoalHPD(prefsUser.getSleepDuration());
|
|
|
|
attributes.setStepsGoalSPD(prefsUser.getStepsGoal());
|
2016-05-13 23:47:47 +02:00
|
|
|
attributes.setUserId(user.getId());
|
|
|
|
session.getUserAttributesDao().insert(attributes);
|
|
|
|
|
2016-09-02 00:26:31 +02:00
|
|
|
// sort order is important, so we re-fetch from the db
|
|
|
|
// userAttributes.add(attributes);
|
|
|
|
user.resetUserAttributesList();
|
2016-05-23 23:31:22 +02:00
|
|
|
}
|
2016-05-13 23:47:47 +02:00
|
|
|
|
2016-08-13 00:52:35 +02:00
|
|
|
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());
|
2016-08-27 23:12:48 +02:00
|
|
|
session.getUserAttributesDao().update(userAttributes);
|
2016-08-13 00:52:35 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private static boolean hasUpToDateUserAttributes(List<UserAttributes> userAttributes, ActivityUser prefsUser, UserAttributes[] outPreviousUserAttributes) {
|
2016-05-23 23:31:22 +02:00
|
|
|
for (UserAttributes attr : userAttributes) {
|
2016-06-06 23:18:46 +02:00
|
|
|
if (!isValidNow(attr)) {
|
2016-08-13 00:27:38 +02:00
|
|
|
continue;
|
2016-05-23 23:31:22 +02:00
|
|
|
}
|
|
|
|
if (isEqual(attr, prefsUser)) {
|
|
|
|
return true;
|
2016-08-13 00:52:35 +02:00
|
|
|
} else {
|
|
|
|
outPreviousUserAttributes[0] = attr;
|
2016-05-23 23:31:22 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2016-06-14 20:13:08 +02:00
|
|
|
// TODO: move this into db queries?
|
2016-06-06 23:18:46 +02:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2016-05-23 23:31:22 +02:00
|
|
|
private static boolean isEqual(UserAttributes attr, ActivityUser prefsUser) {
|
2016-06-06 23:18:46 +02:00
|
|
|
if (prefsUser.getHeightCm() != attr.getHeightCM()) {
|
2016-09-11 12:23:36 +02:00
|
|
|
LOG.info("user height changed to " + prefsUser.getHeightCm() + " from " + attr.getHeightCM());
|
2016-06-06 23:18:46 +02:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if (prefsUser.getWeightKg() != attr.getWeightKG()) {
|
2016-09-11 12:23:36 +02:00
|
|
|
LOG.info("user changed to " + prefsUser.getWeightKg() + " from " + attr.getWeightKG());
|
2016-06-06 23:18:46 +02:00
|
|
|
return false;
|
|
|
|
}
|
2016-06-18 01:26:36 +02:00
|
|
|
if (!Integer.valueOf(prefsUser.getSleepDuration()).equals(attr.getSleepGoalHPD())) {
|
2016-09-11 12:23:36 +02:00
|
|
|
LOG.info("user sleep goal changed to " + prefsUser.getSleepDuration() + " from " + attr.getSleepGoalHPD());
|
2016-06-06 23:18:46 +02:00
|
|
|
return false;
|
|
|
|
}
|
2016-06-18 01:26:36 +02:00
|
|
|
if (!Integer.valueOf(prefsUser.getStepsGoal()).equals(attr.getStepsGoalSPD())) {
|
2016-09-11 12:23:36 +02:00
|
|
|
LOG.info("user steps goal changed to " + prefsUser.getStepsGoal() + " from " + attr.getStepsGoalSPD());
|
2016-06-06 23:18:46 +02:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
2016-05-13 23:47:47 +02:00
|
|
|
}
|
|
|
|
|
2016-06-14 20:13:08 +02:00
|
|
|
private static boolean isEqual(DeviceAttributes attr, GBDevice gbDevice) {
|
2016-08-24 23:14:25 +02:00
|
|
|
if (!Objects.equals(attr.getFirmwareVersion1(), gbDevice.getFirmwareVersion())) {
|
2016-06-14 20:13:08 +02:00
|
|
|
return false;
|
|
|
|
}
|
2016-08-24 23:14:25 +02:00
|
|
|
if (!Objects.equals(attr.getFirmwareVersion2(), gbDevice.getFirmwareVersion2())) {
|
2016-06-14 20:13:08 +02:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2016-06-27 20:41:20 +02:00
|
|
|
public static Device findDevice(GBDevice gbDevice, DaoSession session) {
|
2016-05-13 23:47:47 +02:00
|
|
|
DeviceDao deviceDao = session.getDeviceDao();
|
|
|
|
Query<Device> query = deviceDao.queryBuilder().where(DeviceDao.Properties.Identifier.eq(gbDevice.getAddress())).build();
|
|
|
|
List<Device> devices = query.list();
|
2016-06-27 20:41:20 +02:00
|
|
|
if (devices.size() > 0) {
|
|
|
|
return devices.get(0);
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2016-08-31 00:33:54 +02:00
|
|
|
/**
|
|
|
|
* Returns all active (that is, not old, archived ones) from the database.
|
|
|
|
* (currently the active handling is not available)
|
|
|
|
* @param daoSession
|
|
|
|
*/
|
|
|
|
public static List<Device> getActiveDevices(DaoSession daoSession) {
|
|
|
|
return daoSession.getDeviceDao().loadAll();
|
|
|
|
}
|
|
|
|
|
2016-08-27 23:25:37 +02:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
2016-06-27 20:41:20 +02:00
|
|
|
public static Device getDevice(GBDevice gbDevice, DaoSession session) {
|
2016-08-19 21:09:32 +02:00
|
|
|
Device device = findDevice(gbDevice, session);
|
2016-06-27 20:41:20 +02:00
|
|
|
if (device == null) {
|
2016-08-27 23:25:37 +02:00
|
|
|
device = createDevice(gbDevice, session);
|
|
|
|
} else {
|
|
|
|
ensureDeviceUpToDate(device, gbDevice, session);
|
2016-05-13 23:47:47 +02:00
|
|
|
}
|
2016-06-14 20:13:08 +02:00
|
|
|
ensureDeviceAttributes(device, gbDevice, session);
|
|
|
|
|
|
|
|
return device;
|
2016-05-13 23:47:47 +02:00
|
|
|
}
|
|
|
|
|
2016-09-03 21:16:32 +02:00
|
|
|
@NonNull
|
|
|
|
public static DeviceAttributes getDeviceAttributes(Device device) {
|
|
|
|
List<DeviceAttributes> list = device.getDeviceAttributesList();
|
|
|
|
if (list.isEmpty()) {
|
|
|
|
throw new IllegalStateException("device has no attributes");
|
|
|
|
}
|
|
|
|
return list.get(0);
|
|
|
|
}
|
|
|
|
|
2016-08-27 23:25:37 +02:00
|
|
|
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;
|
|
|
|
}
|
2016-05-13 23:47:47 +02:00
|
|
|
DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice);
|
2016-08-27 23:25:37 +02:00
|
|
|
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);
|
2016-05-13 23:47:47 +02:00
|
|
|
|
2016-06-14 20:13:08 +02:00
|
|
|
return device;
|
|
|
|
}
|
|
|
|
|
|
|
|
private static void ensureDeviceAttributes(Device device, GBDevice gbDevice, DaoSession session) {
|
|
|
|
List<DeviceAttributes> deviceAttributes = device.getDeviceAttributesList();
|
2016-08-13 00:52:35 +02:00
|
|
|
DeviceAttributes[] previousDeviceAttributes = new DeviceAttributes[1];
|
|
|
|
if (hasUpToDateDeviceAttributes(deviceAttributes, gbDevice, previousDeviceAttributes)) {
|
2016-06-14 20:13:08 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2016-08-13 00:52:35 +02:00
|
|
|
Calendar now = DateTimeUtils.getCalendarUTC();
|
|
|
|
invalidateDeviceAttributes(previousDeviceAttributes[0], now, session);
|
|
|
|
|
|
|
|
DeviceAttributes attributes = new DeviceAttributes();
|
2016-05-13 23:47:47 +02:00
|
|
|
attributes.setDeviceId(device.getId());
|
2016-08-13 00:52:35 +02:00
|
|
|
attributes.setValidFromUTC(now.getTime());
|
2016-05-13 23:47:47 +02:00
|
|
|
attributes.setFirmwareVersion1(gbDevice.getFirmwareVersion());
|
2016-06-14 20:13:08 +02:00
|
|
|
attributes.setFirmwareVersion2(gbDevice.getFirmwareVersion2());
|
2016-05-13 23:47:47 +02:00
|
|
|
DeviceAttributesDao attributesDao = session.getDeviceAttributesDao();
|
|
|
|
attributesDao.insert(attributes);
|
|
|
|
|
2016-09-02 00:26:31 +02:00
|
|
|
// sort order is important, so we re-fetch from the db
|
|
|
|
// deviceAttributes.add(attributes);
|
|
|
|
device.resetDeviceAttributesList();
|
2016-06-14 20:13:08 +02:00
|
|
|
}
|
2016-05-13 23:47:47 +02:00
|
|
|
|
2016-08-13 00:52:35 +02:00
|
|
|
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());
|
2016-08-27 23:12:48 +02:00
|
|
|
session.getDeviceAttributesDao().update(deviceAttributes);
|
2016-08-13 00:52:35 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private static boolean hasUpToDateDeviceAttributes(List<DeviceAttributes> deviceAttributes, GBDevice gbDevice, DeviceAttributes[] outPreviousAttributes) {
|
2016-06-14 20:13:08 +02:00
|
|
|
for (DeviceAttributes attr : deviceAttributes) {
|
|
|
|
if (!isValidNow(attr)) {
|
2016-08-13 00:27:38 +02:00
|
|
|
continue;
|
2016-06-14 20:13:08 +02:00
|
|
|
}
|
|
|
|
if (isEqual(attr, gbDevice)) {
|
|
|
|
return true;
|
2016-08-13 00:52:35 +02:00
|
|
|
} else {
|
|
|
|
outPreviousAttributes[0] = attr;
|
2016-06-14 20:13:08 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return false;
|
2016-05-13 23:47:47 +02:00
|
|
|
}
|
2016-06-17 00:07:50 +02:00
|
|
|
|
2016-08-27 00:23:41 +02:00
|
|
|
@NonNull
|
|
|
|
public static List<ActivityDescription> 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<ActivityDescription> qb = session.getActivityDescriptionDao().queryBuilder();
|
2016-08-28 00:22:34 +02:00
|
|
|
qb.where(userIdProperty.eq(user.getId()), isAtLeastPartiallyInRange(qb, tsFromProperty, tsToProperty, tsFrom, tsTo));
|
2016-08-27 00:23:41 +02:00
|
|
|
List<ActivityDescription> descriptions = qb.build().list();
|
|
|
|
return descriptions;
|
|
|
|
}
|
|
|
|
|
2016-08-28 00:22:34 +02:00
|
|
|
/**
|
|
|
|
* 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 <T> the query builder's type parameter
|
|
|
|
* @return the range WhereCondition
|
|
|
|
*/
|
|
|
|
private static <T> WhereCondition isAtLeastPartiallyInRange(QueryBuilder<T> qb, Property tsFromProperty, Property tsToProperty, int tsFrom, int tsTo) {
|
|
|
|
return qb.and(tsFromProperty.lt(tsTo), tsToProperty.gt(tsFrom));
|
|
|
|
}
|
|
|
|
|
2016-08-27 00:23:41 +02:00
|
|
|
@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);
|
2016-08-27 23:12:48 +02:00
|
|
|
session.getActivityDescriptionDao().insertOrReplace(desc);
|
2016-08-27 00:23:41 +02:00
|
|
|
|
|
|
|
return desc;
|
|
|
|
}
|
|
|
|
|
|
|
|
@NonNull
|
|
|
|
public static Tag getTag(@NonNull User user, @NonNull String name, @NonNull DaoSession session) {
|
|
|
|
TagDao tagDao = session.getTagDao();
|
|
|
|
QueryBuilder<Tag> qb = tagDao.queryBuilder();
|
|
|
|
Query<Tag> query = qb.where(TagDao.Properties.UserId.eq(user.getId()), TagDao.Properties.Name.eq(name)).build();
|
|
|
|
List<Tag> tags = query.list();
|
|
|
|
if (tags.size() > 0) {
|
|
|
|
return tags.get(0);
|
|
|
|
}
|
|
|
|
return createTag(user, name, null, session);
|
|
|
|
}
|
|
|
|
|
2016-08-27 23:12:48 +02:00
|
|
|
static Tag createTag(@NonNull User user, @NonNull String name, @Nullable String description, @NonNull DaoSession session) {
|
2016-08-27 00:23:41 +02:00
|
|
|
Tag tag = new Tag();
|
|
|
|
tag.setUserId(user.getId());
|
|
|
|
tag.setName(name);
|
|
|
|
tag.setDescription(description);
|
2016-08-27 23:12:48 +02:00
|
|
|
session.getTagDao().insertOrReplace(tag);
|
2016-08-27 00:23:41 +02:00
|
|
|
return tag;
|
|
|
|
}
|
|
|
|
|
2016-06-17 00:07:50 +02:00
|
|
|
/**
|
|
|
|
* Returns the old activity database handler if there is any content in that
|
|
|
|
* db, or null otherwise.
|
2016-08-19 21:09:32 +02:00
|
|
|
*
|
2016-06-17 00:07:50 +02:00
|
|
|
* @return the old activity db handler or null
|
|
|
|
*/
|
|
|
|
@Nullable
|
|
|
|
public ActivityDatabaseHandler getOldActivityDatabaseHandler() {
|
|
|
|
ActivityDatabaseHandler handler = new ActivityDatabaseHandler(context);
|
|
|
|
if (handler.hasContent()) {
|
|
|
|
return handler;
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2016-06-18 01:26:36 +02:00
|
|
|
public void importOldDb(ActivityDatabaseHandler oldDb, GBDevice targetDevice, DBHandler targetDBHandler) {
|
|
|
|
DaoSession tempSession = targetDBHandler.getDaoMaster().newSession();
|
2016-06-17 00:07:50 +02:00
|
|
|
try {
|
2016-06-18 01:26:36 +02:00
|
|
|
importActivityDatabase(oldDb, targetDevice, tempSession);
|
2016-06-17 00:07:50 +02:00
|
|
|
} finally {
|
|
|
|
tempSession.clear();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private boolean isEmpty(DaoSession session) {
|
|
|
|
long totalSamplesCount = session.getMiBandActivitySampleDao().count();
|
2016-07-30 23:22:27 +02:00
|
|
|
totalSamplesCount += session.getPebbleHealthActivitySampleDao().count();
|
2016-06-17 00:07:50 +02:00
|
|
|
return totalSamplesCount == 0;
|
|
|
|
}
|
|
|
|
|
2016-06-18 01:26:36 +02:00
|
|
|
private void importActivityDatabase(ActivityDatabaseHandler oldDbHandler, GBDevice targetDevice, DaoSession session) {
|
2016-06-17 00:07:50 +02:00
|
|
|
try (SQLiteDatabase oldDB = oldDbHandler.getReadableDatabase()) {
|
|
|
|
User user = DBHelper.getUser(session);
|
|
|
|
for (DeviceCoordinator coordinator : DeviceHelper.getInstance().getAllCoordinators()) {
|
2016-08-18 20:29:20 +02:00
|
|
|
if (coordinator.supports(targetDevice)) {
|
|
|
|
AbstractSampleProvider<? extends AbstractActivitySample> sampleProvider = (AbstractSampleProvider<? extends AbstractActivitySample>) coordinator.getSampleProvider(targetDevice, session);
|
|
|
|
importActivitySamples(oldDB, targetDevice, session, sampleProvider, user);
|
|
|
|
break;
|
|
|
|
}
|
2016-06-17 00:07:50 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private <T extends AbstractActivitySample> void importActivitySamples(SQLiteDatabase fromDb, GBDevice targetDevice, DaoSession targetSession, AbstractSampleProvider<T> sampleProvider, User user) {
|
2016-08-20 21:38:39 +02:00
|
|
|
if (sampleProvider instanceof PebbleMisfitSampleProvider) {
|
|
|
|
GB.toast(context, "Migration of old Misfit data is not supported!", Toast.LENGTH_LONG, GB.WARN);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2016-06-17 00:07:50 +02:00
|
|
|
String order = "timestamp";
|
|
|
|
final String where = "provider=" + sampleProvider.getID();
|
|
|
|
|
2016-08-19 21:09:32 +02:00
|
|
|
boolean convertActivityTypeToRange = false;
|
|
|
|
int currentTypeRun, previousTypeRun, currentTimeStamp, currentTypeStartTimeStamp, currentTypeEndTimeStamp;
|
|
|
|
List<PebbleHealthActivityOverlay> overlayList = new ArrayList<>();
|
|
|
|
|
2016-08-18 20:38:48 +02:00
|
|
|
final int BATCH_SIZE = 100000; // 100.000 samples = rougly 20 MB per batch
|
|
|
|
List<T> newSamples;
|
2016-08-19 21:09:32 +02:00
|
|
|
if (sampleProvider instanceof PebbleHealthSampleProvider) {
|
|
|
|
convertActivityTypeToRange = true;
|
|
|
|
previousTypeRun = ActivitySample.NOT_MEASURED;
|
|
|
|
currentTypeStartTimeStamp = -1;
|
|
|
|
currentTypeEndTimeStamp = -1;
|
|
|
|
|
|
|
|
} else {
|
|
|
|
previousTypeRun = currentTypeStartTimeStamp = currentTypeEndTimeStamp = 0;
|
|
|
|
}
|
2016-06-17 00:07:50 +02:00
|
|
|
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);
|
2016-09-06 00:00:48 +02:00
|
|
|
long deviceId = DBHelper.getDevice(targetDevice, targetSession).getId();
|
|
|
|
long userId = user.getId();
|
2016-08-18 20:38:48 +02:00
|
|
|
newSamples = new ArrayList<>(Math.min(BATCH_SIZE, cursor.getCount()));
|
2016-06-17 00:07:50 +02:00
|
|
|
while (cursor.moveToNext()) {
|
|
|
|
T newSample = sampleProvider.createActivitySample();
|
2016-07-28 22:12:20 +02:00
|
|
|
newSample.setProvider(sampleProvider);
|
2016-06-17 00:07:50 +02:00
|
|
|
newSample.setUserId(userId);
|
|
|
|
newSample.setDeviceId(deviceId);
|
2016-08-19 21:09:32 +02:00
|
|
|
currentTimeStamp = cursor.getInt(colTimeStamp);
|
|
|
|
newSample.setTimestamp(currentTimeStamp);
|
2016-07-28 22:12:20 +02:00
|
|
|
newSample.setRawIntensity(getNullableInt(cursor, colIntensity, ActivitySample.NOT_MEASURED));
|
2016-08-19 21:09:32 +02:00
|
|
|
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) {
|
2016-08-21 17:38:07 +02:00
|
|
|
//we used not to store the last sample, now we do the opposite and we need to round up
|
|
|
|
currentTypeEndTimeStamp = currentTimeStamp;
|
2016-08-19 21:09:32 +02:00
|
|
|
//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));
|
|
|
|
}
|
2016-08-21 17:38:07 +02:00
|
|
|
currentTypeStartTimeStamp = currentTimeStamp;
|
2016-08-19 21:09:32 +02:00
|
|
|
previousTypeRun = currentTypeRun;
|
|
|
|
} else {
|
|
|
|
//just expand the run
|
|
|
|
currentTypeEndTimeStamp = currentTimeStamp;
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
2016-07-28 22:12:20 +02:00
|
|
|
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);
|
|
|
|
}
|
2016-06-17 00:07:50 +02:00
|
|
|
newSamples.add(newSample);
|
2016-08-18 20:38:48 +02:00
|
|
|
|
|
|
|
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);
|
2016-06-17 00:07:50 +02:00
|
|
|
}
|
2016-08-19 21:09:32 +02:00
|
|
|
// store the overlay records
|
|
|
|
if (!overlayList.isEmpty()) {
|
2016-08-21 17:38:07 +02:00
|
|
|
PebbleHealthActivityOverlayDao overlayDao = targetSession.getPebbleHealthActivityOverlayDao();
|
|
|
|
overlayDao.insertOrReplaceInTx(overlayList);
|
2016-08-19 21:09:32 +02:00
|
|
|
}
|
2016-06-17 00:07:50 +02:00
|
|
|
}
|
|
|
|
}
|
2016-07-28 22:12:20 +02:00
|
|
|
|
|
|
|
private int getNullableInt(Cursor cursor, int columnIndex, int defaultValue) {
|
|
|
|
if (cursor.isNull(columnIndex)) {
|
|
|
|
return defaultValue;
|
|
|
|
}
|
|
|
|
return cursor.getInt(columnIndex);
|
|
|
|
}
|
2015-07-08 23:03:34 +02:00
|
|
|
}
|