package com.uriio.beacons; import android.annotation.SuppressLint; import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteStatement; import android.os.Build; import android.support.annotation.NonNull; import android.util.Base64; import android.util.SparseArray; import com.uriio.beacons.ble.Advertiser; import com.uriio.beacons.model.Beacon; import com.uriio.beacons.model.EddystoneBase; import com.uriio.beacons.model.EddystoneEID; import com.uriio.beacons.model.EddystoneTLM; import com.uriio.beacons.model.EddystoneUID; import com.uriio.beacons.model.EddystoneURL; import com.uriio.beacons.model.iBeacon; import java.nio.ByteBuffer; import java.util.Arrays; import java.util.Locale; /** * Database manager. */ public class Storage extends SQLiteOpenHelper { /** * Serializer/storage interface for custom beacon kinds. */ public interface Persistable { /** * @return The beacon kind. Values below 0x10000 (65536) are reserved. Since the beacon kind * is stored with every beacon, it must never be changed. */ int getKind(); /** * Prepares an insert. You can bind custom data to indexes 1 to 7 of the statement. * @param beacon Beacon that will be inserted. * @param statement Compiled statement. First 7 positions can be used for custom data. */ void prepareInsert(Beacon beacon, SQLiteStatement statement); /** * @param beacon Beacon to be saved. * @param db SQLite database * @param flags Custom flags sent by your beacon editor, or 0 to save default data. @return The statement to use. The statement is not closed after execution. */ SQLiteStatement prepareUpdate(Beacon beacon, SQLiteDatabase db, int flags); /** * Called after a beacon was deleted from storage. * @param beacon The beacon that was removed. */ void onDeleted(Beacon beacon); /** * @param cursor Database cursor. * @return Deserialized beacon instance. */ Beacon fromCursor(Cursor cursor); void close(); } public static final int KIND_EDDYSTONE_URL = 1; public static final int KIND_EDDYSTONE_UID = 2; public static final int KIND_IBEACON = 3; public static final int KIND_EDDYSTONE_EID = 4; public static final int KIND_EDDYSTONE_TLM = 5; private static final String ITEMS_TABLE = "b"; @Deprecated private static final String EDDYSTONE_TABLE = "url"; @Deprecated private static final String IBEACONS_TABLE = "ib"; @Deprecated private static final String URIIO_TABLE = "uriio"; private static final int DATABASE_SCHEMA_VERSION = 7; private static Storage _instance; /** lazy SQLite statements **/ private SQLiteStatement mInsertItemStmt = null; private SQLiteStatement mUpdateItemStmt = null; private SQLiteStatement mDeleteItemStmt = null; private SQLiteStatement mUpdateStateStmt = null; private SQLiteStatement mUpdateEddystoneStmt = null; private SQLiteStatement mUpdateIBeaconStmt = null; private SparseArray<Persistable> mBeaconPersisters = null; private Storage(Context context, String dbName) { super(context, dbName, null, DATABASE_SCHEMA_VERSION); } static void init(Context context, String dbName) { _instance = new Storage(context, dbName); ApplicationInfo appInfo; try { appInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); } catch (PackageManager.NameNotFoundException e) { throw new IllegalArgumentException("App package not found"); } // metadata is null when no entries exist if (null != appInfo && null != appInfo.metaData) { for (String key : appInfo.metaData.keySet()) { if (key.startsWith("com.uriio.ext.")) { String className = appInfo.metaData.getString(key); if (null != className) { Persistable persistable; try { persistable = (Persistable) Class.forName(className).newInstance(); } catch (Exception ignored) { continue; } int kind = persistable.getKind(); if (kind > 0xffff) { if (null == _instance.mBeaconPersisters) { _instance.mBeaconPersisters = new SparseArray<>(); } _instance.mBeaconPersisters.put(kind, persistable); } } } } } } public static Storage getInstance() { return _instance; } @Override public void onCreate(SQLiteDatabase db) { // db.beginTransaction(); // create main table db.execSQL("CREATE TABLE " + ITEMS_TABLE + " (kind INTEGER, created INTEGER, " + "advMode INTEGER, txLevel INTEGER, state INTEGER DEFAULT 1, flags INTEGER, " + "name TEXT, d0 TEXT, d1 TEXT, d2 TEXT, d3 TEXT, d4 TEXT, d5 TEXT, d6 TEXT)"); // indexes // db.execSQL("CREATE INDEX ia ON " + ITEMS_TABLE + "(kind)"); // db.setTransactionSuccessful(); // db.endTransaction(); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { // added the 'name' column to items db.execSQL("ALTER TABLE " + ITEMS_TABLE + " ADD COLUMN name TEXT"); } if (oldVersion < 3) { // added the 'domain' column to Eddystone items db.execSQL("ALTER TABLE " + EDDYSTONE_TABLE + " ADD COLUMN domain TEXT"); } if (oldVersion < 6) { db.execSQL("ALTER TABLE " + EDDYSTONE_TABLE + " ADD COLUMN lockKey BLOB"); } if (oldVersion < 7) { for (int idx = 0; idx < 7; idx++) { db.execSQL(String.format(Locale.US, "ALTER TABLE " + ITEMS_TABLE + " ADD COLUMN d%d TEXT", idx)); } migrateUriioItems(db); migrateEddystoneItems(db); migrateIBeaconItems(db); } } @Override public synchronized void close() { if (null != mInsertItemStmt) { mInsertItemStmt.close(); mInsertItemStmt = null; } if (null != mUpdateItemStmt) { mUpdateItemStmt.close(); mUpdateItemStmt = null; } if (null != mDeleteItemStmt) { mDeleteItemStmt.close(); mDeleteItemStmt = null; } if (null != mUpdateStateStmt) { mUpdateStateStmt.close(); mUpdateStateStmt = null; } if (null != mUpdateEddystoneStmt) { mUpdateEddystoneStmt.close(); mUpdateEddystoneStmt = null; } if (null != mUpdateIBeaconStmt) { mUpdateIBeaconStmt.close(); mUpdateIBeaconStmt = null; } if (null != mBeaconPersisters) { for (int idx = mBeaconPersisters.size() - 1; idx >= 0; --idx) { mBeaconPersisters.valueAt(idx).close(); } } super.close(); } public long insert(Beacon item) { SQLiteDatabase db = getWritableDatabase(); if (null == mInsertItemStmt) { mInsertItemStmt = db.compileStatement("INSERT INTO " + ITEMS_TABLE + " (d0, d1, d2, d3, d4, d5, d6, created, advMode, txLevel, kind, flags, name)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); } mInsertItemStmt.clearBindings(); switch (item.getKind()) { case KIND_EDDYSTONE_URL: case KIND_EDDYSTONE_UID: case KIND_EDDYSTONE_EID: case KIND_EDDYSTONE_TLM: bindInsertEddystoneItem((EddystoneBase) item); break; case KIND_IBEACON: bindInsertIBeaconStatement((iBeacon) item); break; default: if (null != mBeaconPersisters) { mBeaconPersisters.get(item.getKind()).prepareInsert(item, mInsertItemStmt); } break; } mInsertItemStmt.bindLong(8, System.currentTimeMillis()); mInsertItemStmt.bindLong(9, item.getAdvertiseMode()); mInsertItemStmt.bindLong(10, item.getTxPowerLevel()); mInsertItemStmt.bindLong(11, item.getKind()); mInsertItemStmt.bindLong(12, item.getFlags()); bindStringOrNull(mInsertItemStmt, 13, item.getName()); long rowid = mInsertItemStmt.executeInsert(); if (rowid > 0) { item.setStorageId(rowid); } return rowid; } private void bindInsertEddystoneItem(EddystoneBase beacon) { mInsertItemStmt.bindBlob(1, beacon.getLockKey()); switch (beacon.getKind()) { case KIND_EDDYSTONE_URL: bindStringOrNull(mInsertItemStmt, 2, ((EddystoneURL) beacon).getURL()); break; case KIND_EDDYSTONE_UID: mInsertItemStmt.bindBlob(2, ((EddystoneUID) beacon).getNamespaceInstance()); bindStringOrNull(mInsertItemStmt, 3, ((EddystoneUID) beacon).getDomainHint()); break; case KIND_EDDYSTONE_EID: mInsertItemStmt.bindBlob(2, ((EddystoneEID) beacon).getIdentityKey()); mInsertItemStmt.bindLong(3, ((EddystoneEID) beacon).getRotationExponent()); mInsertItemStmt.bindLong(4, ((EddystoneEID) beacon).getClockOffset()); break; case KIND_EDDYSTONE_TLM: mInsertItemStmt.bindLong(2, ((EddystoneTLM) beacon).getRefreshInterval()); break; } } private void bindInsertIBeaconStatement(iBeacon beacon) { mInsertItemStmt.bindBlob(1, beacon.getUuidRaw()); mInsertItemStmt.bindLong(2, beacon.getMajor()); mInsertItemStmt.bindLong(3, beacon.getMinor()); } public void delete(Beacon beacon) { long id = beacon.getSavedId(); if (id > 0) { SQLiteDatabase db = getWritableDatabase(); if (null == mDeleteItemStmt) { mDeleteItemStmt = db.compileStatement("DELETE FROM " + ITEMS_TABLE + " WHERE rowid=?"); } mDeleteItemStmt.bindLong(1, id); executeSafeUpdateOrDelete(mDeleteItemStmt); Persistable persistable = null == mBeaconPersisters ? null : mBeaconPersisters.get(beacon.getKind()); if (null != persistable) { persistable.onDeleted(beacon); } } } Cursor queryAll(boolean stopped) { // if we ever use this in a CursorAdapter, the rowid column should be aliased to '_id' return getReadableDatabase().rawQuery(String.format("SELECT d0, d1, d2, d3, d4, d5, d6," + " rowid, state, advMode, txLevel, flags, kind, name, created" + " FROM " + ITEMS_TABLE + " WHERE state%s2 ORDER BY rowid DESC", stopped ? "=" : "<"), null); } Cursor query(long itemId) { return getReadableDatabase().rawQuery("SELECT d0, d1, d2, d3, d4, d5, d6," + " rowid, state, advMode, txLevel, flags, kind, name, created" + " FROM " + ITEMS_TABLE + " WHERE rowid=?", new String[] { String.valueOf(itemId)}); } public static long getId(Cursor cursor) { return cursor.getLong(7); } public static int getKind(Cursor cursor) { return cursor.getInt(12); } public static Beacon fromCursor(Cursor cursor) { Beacon beacon; int kind = cursor.getInt(12); switch (kind) { case KIND_EDDYSTONE_URL: beacon = new EddystoneURL(cursor.getString(1), cursor.isNull(0) ? null : cursor.getBlob(0)); break; case KIND_EDDYSTONE_UID: beacon = new EddystoneUID(cursor.getBlob(1), cursor.getString(2), cursor.isNull(0) ? null : cursor.getBlob(0), null); break; case KIND_EDDYSTONE_EID: beacon = new EddystoneEID(cursor.getBlob(1), (byte) cursor.getInt(2), cursor.getInt(3), cursor.isNull(0) ? null : cursor.getBlob(0)); break; case KIND_EDDYSTONE_TLM: beacon = new EddystoneTLM(cursor.getInt(1), cursor.isNull(0) ? null : cursor.getBlob(0)); break; case KIND_IBEACON: beacon = new iBeacon(cursor.getBlob(0), cursor.getInt(1), cursor.getInt(2)); break; default: Persistable persistable = null == getInstance().mBeaconPersisters ? null : getInstance().mBeaconPersisters.get(kind); beacon = null == persistable ? null : persistable.fromCursor(cursor); break; } if (null != beacon) { long itemId = cursor.getLong(7); @Advertiser.Mode int advertiseMode = cursor.getInt(9); @Advertiser.Power int txPowerLevel = cursor.getInt(10); int flags = cursor.getInt(11); String name = cursor.getString(13); beacon.init(itemId, advertiseMode, txPowerLevel, flags, name); beacon.setActiveState(cursor.getInt(8)); } return beacon; } private SQLiteStatement prepareUpdateStatement(iBeacon item, SQLiteDatabase db) { if (null == mUpdateIBeaconStmt) { mUpdateIBeaconStmt = createUpdater(db, "d0", "d1", "d2"); } mUpdateIBeaconStmt.bindBlob(2, item.getUuidRaw()); mUpdateIBeaconStmt.bindLong(3, item.getMajor()); mUpdateIBeaconStmt.bindLong(4, item.getMinor()); return mUpdateIBeaconStmt; } private SQLiteStatement prepareUpdateStatement(EddystoneBase beacon, SQLiteDatabase db) { if (null == mUpdateEddystoneStmt) { mUpdateEddystoneStmt = createUpdater(db, "d0", "d1", "d2", "d3"); } mUpdateEddystoneStmt.bindBlob(2, beacon.getLockKey()); switch (beacon.getKind()) { case KIND_EDDYSTONE_URL: bindStringOrNull(mUpdateEddystoneStmt, 3, ((EddystoneURL) beacon).getURL()); break; case KIND_EDDYSTONE_UID: mUpdateEddystoneStmt.bindBlob(3, ((EddystoneUID) beacon).getNamespaceInstance()); bindStringOrNull(mUpdateEddystoneStmt, 4, ((EddystoneUID) beacon).getDomainHint()); break; case KIND_EDDYSTONE_EID: mUpdateEddystoneStmt.bindBlob(3, ((EddystoneEID) beacon).getIdentityKey()); mUpdateEddystoneStmt.bindLong(4, ((EddystoneEID) beacon).getRotationExponent()); mUpdateEddystoneStmt.bindLong(5, ((EddystoneEID) beacon).getClockOffset()); break; case KIND_EDDYSTONE_TLM: mUpdateEddystoneStmt.bindLong(3, ((EddystoneTLM) beacon).getRefreshInterval()); break; } return mUpdateEddystoneStmt; } /** * Saves an existing beacon's main details, and/or custom details. * <b>This method is for internal (and beacon extensions) use only.</b> * @param beacon An existing beacon. * @param flags If 0, the beacon's <b>advertiseMode</b>, <b>txPower</b>, <b>name</b> and <b>flags</b> will be saved. * Other basic details will also be saved depending on the beacon type. * If non-zero, then only custom details will be saved, on a per-beacon defined basis. */ public void update(Beacon beacon, int flags) { SQLiteDatabase db = getWritableDatabase(); if (0 == flags) { // we'll do two updates - use a transaction db.beginTransaction(); if (null == mUpdateItemStmt) { mUpdateItemStmt = db.compileStatement("UPDATE " + ITEMS_TABLE + " SET advMode=?, txLevel=?, flags=?, name=? WHERE rowid=?"); } mUpdateItemStmt.bindLong(1, beacon.getAdvertiseMode()); mUpdateItemStmt.bindLong(2, beacon.getTxPowerLevel()); mUpdateItemStmt.bindLong(3, beacon.getFlags()); bindStringOrNull(mUpdateItemStmt, 4, beacon.getName()); mUpdateItemStmt.bindLong(5, beacon.getSavedId()); executeSafeUpdateOrDelete(mUpdateItemStmt); } SQLiteStatement updateStatement; switch (beacon.getKind()) { case KIND_EDDYSTONE_URL: case KIND_EDDYSTONE_UID: case KIND_EDDYSTONE_EID: case KIND_EDDYSTONE_TLM: updateStatement = prepareUpdateStatement((EddystoneBase) beacon, db); break; case KIND_IBEACON: updateStatement = prepareUpdateStatement((iBeacon) beacon, db); break; default: Persistable persister = null == mBeaconPersisters ? null : mBeaconPersisters.get(beacon.getKind()); updateStatement = null == persister ? null : persister.prepareUpdate(beacon, db, flags); break; } if (null != updateStatement) { updateStatement.bindLong(1, beacon.getSavedId()); executeSafeUpdateOrDelete(updateStatement); } if (0 == flags) { db.setTransactionSuccessful(); db.endTransaction(); } } /** * Saves an existing beacon's main details. * @param beacon Target beacon. */ public void update(Beacon beacon) { update(beacon, 0); } public void updateState(Beacon beacon, int state) { SQLiteDatabase db = getWritableDatabase(); if (null == mUpdateStateStmt) { mUpdateStateStmt = db.compileStatement("UPDATE " + ITEMS_TABLE + " SET state=? WHERE rowid=?"); } mUpdateStateStmt.bindLong(1, state); mUpdateStateStmt.bindLong(2, beacon.getSavedId()); executeSafeUpdateOrDelete(mUpdateStateStmt); } /** * Binds either a string or NULL to a SQLite statement. * Reason: trying to bind a null string would normally crash the app. * @param statement SQLite statement * @param value A string, or null */ public static void bindStringOrNull(@NonNull SQLiteStatement statement, int index, String value) { if (null == value) { statement.bindNull(index); } else { statement.bindString(index, value); } } @SuppressLint("ObsoleteSdkInt") private void executeSafeUpdateOrDelete(SQLiteStatement statement) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { statement.executeUpdateDelete(); } else { statement.execute(); } } private static @NonNull SQLiteStatement createUpdater(SQLiteDatabase db, String... columns) { StringBuilder sql = null; for (int i = 0; i < columns.length; ++i) { String column = columns[i]; if (null == sql) { sql = new StringBuilder("UPDATE " + ITEMS_TABLE + " SET "); } else { sql.append(","); } sql.append(column).append("=?").append(2 + i); } if (null == sql) { throw new IllegalArgumentException("Invalid columns"); } // need a db reference to work around IllegalStateException: getDatabase called recursively return db.compileStatement(sql.append(" WHERE rowid=?1").toString()); } /** * Creates a beacon update statement for saving custom data. * In the returned statement, bind position 1 is reserved for the ID of the updated item. * @param db SQLite database * @param columns Custom columns to update, 0 to 6 inclusive. * @return A compiled statement. To bind values, start from index 2. */ public static @NonNull SQLiteStatement createUpdater(SQLiteDatabase db, int... columns) { String[] names = new String[columns.length]; for (int i = 0; i < columns.length; ++i) { if (columns[i] >= 0 && columns[i] < 7) { names[i] = "d" + columns[i]; } } return createUpdater(db, names); } private void migrateUriioItems(SQLiteDatabase db) { SQLiteStatement updateStatement = createUpdater(db, "d1", "d5", "kind"); Cursor cursor = db.rawQuery("SELECT rowid, longUrl, shortUrl FROM " + URIIO_TABLE, null); while (cursor.moveToNext()) { updateStatement.bindLong(1, cursor.getLong(0)); bindStringOrNull(updateStatement, 2, cursor.getString(1)); bindStringOrNull(updateStatement, 3, cursor.getString(2)); updateStatement.bindLong(4, 0x10000); executeSafeUpdateOrDelete(updateStatement); } cursor.close(); updateStatement.close(); db.execSQL("DROP TABLE " + URIIO_TABLE); } private void migrateIBeaconItems(SQLiteDatabase db) { SQLiteStatement updateStatement = createUpdater(db, "d0", "d1", "d2"); Cursor cursor = db.rawQuery("SELECT rowid, uuid, maj, min FROM " + IBEACONS_TABLE, null); while (cursor.moveToNext()) { updateStatement.bindLong(1, cursor.getLong(0)); updateStatement.bindBlob(2, Base64.decode(cursor.getString(1), Base64.DEFAULT)); updateStatement.bindLong(3, cursor.getInt(2)); updateStatement.bindLong(4, cursor.getInt(3)); executeSafeUpdateOrDelete(updateStatement); } cursor.close(); updateStatement.close(); db.execSQL("DROP TABLE " + IBEACONS_TABLE); } private void migrateEddystoneItems(SQLiteDatabase db) { SQLiteStatement updateStatement = createUpdater(db, "d0", "d1", "d2", "d3", "kind", "flags"); Cursor cursor = db.rawQuery("SELECT e.rowid, url, domain, lockKey, flags" + " FROM " + EDDYSTONE_TABLE + " e" + " LEFT OUTER JOIN " + ITEMS_TABLE + " i ON e.rowid=i.rowid", null); while (cursor.moveToNext()) { updateStatement.clearBindings(); updateStatement.bindLong(1, cursor.getLong(0)); int flags = cursor.getInt(4); int type = flags >>> 4; if (!cursor.isNull(3)) { updateStatement.bindBlob(2, cursor.getBlob(3)); // lock key } updateStatement.bindLong(7, 0); String payload = cursor.getString(1); String domain = cursor.getString(2); switch (type) { case 0: // EDDYSTONE_URL bindStringOrNull(updateStatement, 3, payload); updateStatement.bindLong(6, KIND_EDDYSTONE_URL); break; case 1: // EDDYSTONE_UID updateStatement.bindBlob(3, Base64.decode(payload, Base64.DEFAULT)); bindStringOrNull(updateStatement, 4, domain); updateStatement.bindLong(6, KIND_EDDYSTONE_UID); break; case 2: // EDDYSTONE_EID byte[] eidRaw = Base64.decode(payload, Base64.DEFAULT); int eidTimeOffset = ByteBuffer.wrap(eidRaw, 16, 4).getInt(); // sanitize time offset to match range; see EIDUtils.register() eidTimeOffset = Math.min(255, Math.max(-65280, eidTimeOffset)); // sanitize rotation exponent to [0, 15] range byte rotationExponent = (byte) (eidRaw[20] & 0x0f); updateStatement.bindBlob(3, Arrays.copyOfRange(eidRaw, 0, 16)); updateStatement.bindLong(4, rotationExponent); updateStatement.bindLong(5, eidTimeOffset); updateStatement.bindLong(6, KIND_EDDYSTONE_EID); break; } executeSafeUpdateOrDelete(updateStatement); } cursor.close(); updateStatement.close(); db.execSQL("DROP TABLE " + EDDYSTONE_TABLE); } }