• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.server.healthconnect.storage.utils;
18 
19 import static android.health.connect.HealthDataCategory.ACTIVITY;
20 import static android.health.connect.HealthDataCategory.SLEEP;
21 import static android.health.connect.HealthDataCategory.WELLNESS;
22 import static android.health.connect.datatypes.AggregationType.SUM;
23 import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_BASAL_METABOLIC_RATE;
24 import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_HYDRATION;
25 import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_NUTRITION;
26 import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_TOTAL_CALORIES_BURNED;
27 import static android.text.TextUtils.isEmpty;
28 
29 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
30 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.CLIENT_RECORD_ID_COLUMN_NAME;
31 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME;
32 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.UUID_COLUMN_NAME;
33 
34 import static java.util.Objects.requireNonNull;
35 
36 import android.annotation.Nullable;
37 import android.content.ContentValues;
38 import android.database.Cursor;
39 import android.database.DatabaseUtils;
40 import android.database.sqlite.SQLiteDatabase;
41 import android.health.connect.HealthDataCategory;
42 import android.health.connect.RecordIdFilter;
43 import android.health.connect.internal.datatypes.InstantRecordInternal;
44 import android.health.connect.internal.datatypes.IntervalRecordInternal;
45 import android.health.connect.internal.datatypes.RecordInternal;
46 import android.health.connect.internal.datatypes.utils.HealthConnectMappings;
47 import android.health.connect.internal.datatypes.utils.RecordTypeRecordCategoryMapper;
48 import android.util.Slog;
49 
50 import com.android.internal.annotations.VisibleForTesting;
51 
52 import java.nio.ByteBuffer;
53 import java.time.ZoneOffset;
54 import java.util.ArrayList;
55 import java.util.Arrays;
56 import java.util.Collection;
57 import java.util.Collections;
58 import java.util.List;
59 import java.util.UUID;
60 import java.util.stream.Collectors;
61 import java.util.stream.Stream;
62 
63 /**
64  * An util class for HC storage
65  *
66  * @hide
67  */
68 public final class StorageUtils {
69     public static final String TEXT_NOT_NULL = "TEXT NOT NULL";
70     public static final String TEXT_NOT_NULL_UNIQUE = "TEXT NOT NULL UNIQUE";
71     public static final String TEXT_NULL = "TEXT";
72     public static final String INTEGER = "INTEGER";
73     public static final String INTEGER_UNIQUE = "INTEGER UNIQUE";
74     public static final String INTEGER_NOT_NULL = "INTEGER NOT NULL";
75     public static final String REAL = "REAL";
76     public static final String REAL_NOT_NULL = "REAL NOT NULL";
77     public static final String PRIMARY_AUTOINCREMENT = "INTEGER PRIMARY KEY AUTOINCREMENT";
78     public static final String PRIMARY = "INTEGER PRIMARY KEY";
79     public static final String DELIMITER = ",";
80     public static final String BLOB = "BLOB";
81     public static final String BLOB_UNIQUE_NULL = "BLOB UNIQUE";
82     public static final String BLOB_NULL = "BLOB NULL";
83     public static final String BLOB_UNIQUE_NON_NULL = "BLOB NOT NULL UNIQUE";
84     public static final String BLOB_NON_NULL = "BLOB NOT NULL";
85     public static final String SELECT_ALL = "SELECT * FROM ";
86     public static final String SELECT = "SELECT ";
87     public static final String FROM = " FROM ";
88     public static final String DISTINCT = "DISTINCT ";
89     public static final String LIMIT_SIZE = " LIMIT ";
90     public static final int BOOLEAN_FALSE_VALUE = 0;
91     public static final int BOOLEAN_TRUE_VALUE = 1;
92     public static final int UUID_BYTE_SIZE = 16;
93     private static final String TAG = "HealthConnectUtils";
94 
95     // Returns null if fetching any of the fields resulted in an error
96     @Nullable
getConflictErrorMessageForRecord( Cursor cursor, ContentValues contentValues)97     public static String getConflictErrorMessageForRecord(
98             Cursor cursor, ContentValues contentValues) {
99         try {
100             return "Updating record with uuid: "
101                     + convertBytesToUUID(contentValues.getAsByteArray(UUID_COLUMN_NAME))
102                     + " and client record id: "
103                     + contentValues.getAsString(CLIENT_RECORD_ID_COLUMN_NAME)
104                     + " conflicts with an existing record with uuid: "
105                     + getCursorUUID(cursor, UUID_COLUMN_NAME)
106                     + "  and client record id: "
107                     + getCursorString(cursor, CLIENT_RECORD_ID_COLUMN_NAME);
108         } catch (Exception exception) {
109             Slog.e(TAG, "", exception);
110             return null;
111         }
112     }
113 
114     /**
115      * Returns a UUID for the given triple {@code resourceId}, {@code resourceType} and {@code
116      * dataSourceId}.
117      */
generateMedicalResourceUUID( String resourceId, int resourceType, String dataSourceId)118     public static UUID generateMedicalResourceUUID(
119             String resourceId, int resourceType, String dataSourceId) {
120         final byte[] resourceIdBytes = resourceId.getBytes();
121         final byte[] dataSourceIdBytes = dataSourceId.getBytes();
122 
123         byte[] bytes =
124                 ByteBuffer.allocate(
125                                 resourceIdBytes.length + Integer.BYTES + dataSourceIdBytes.length)
126                         .put(resourceIdBytes)
127                         .putInt(resourceType)
128                         .put(dataSourceIdBytes)
129                         .array();
130         return UUID.nameUUIDFromBytes(bytes);
131     }
132 
133     /**
134      * Sets UUID for the given record. If {@link RecordInternal#getClientRecordId()} is null or
135      * empty, then the UUID is randomly generated. Otherwise, the UUID is generated as a combination
136      * of {@link RecordInternal#getPackageName()}, {@link RecordInternal#getClientRecordId()} and
137      * {@link RecordInternal#getRecordType()}.
138      */
addNameBasedUUIDTo(RecordInternal<?> recordInternal)139     public static void addNameBasedUUIDTo(RecordInternal<?> recordInternal) {
140         final String clientRecordId = recordInternal.getClientRecordId();
141         if (isEmpty(clientRecordId)) {
142             recordInternal.setUuid(UUID.randomUUID());
143             return;
144         }
145 
146         final UUID uuid =
147                 getUUID(
148                         requireNonNull(recordInternal.getPackageName()),
149                         clientRecordId,
150                         recordInternal.getRecordType());
151         recordInternal.setUuid(uuid);
152     }
153 
154     /** Updates the uuid using the clientRecordID if the clientRecordId is present. */
updateNameBasedUUIDIfRequired(RecordInternal<?> recordInternal)155     public static void updateNameBasedUUIDIfRequired(RecordInternal<?> recordInternal) {
156         final String clientRecordId = recordInternal.getClientRecordId();
157         if (isEmpty(clientRecordId)) {
158             // If clientRecordID is absent, use the uuid already set in the input record and
159             // hence no need to modify it.
160             return;
161         }
162 
163         final UUID uuid =
164                 getUUID(
165                         requireNonNull(recordInternal.getPackageName()),
166                         clientRecordId,
167                         recordInternal.getRecordType());
168         recordInternal.setUuid(uuid);
169     }
170 
171     /**
172      * Returns a UUID for the given {@link RecordIdFilter} and package name. If {@link
173      * RecordIdFilter#getClientRecordId()} is null or empty, then the UUID corresponds to {@link
174      * RecordIdFilter#getId()}. Otherwise, the UUID is generated as a combination of the package
175      * name, {@link RecordIdFilter#getClientRecordId()} and {@link RecordIdFilter#getRecordType()}.
176      */
getUUIDFor(RecordIdFilter recordIdFilter, String packageName)177     public static UUID getUUIDFor(RecordIdFilter recordIdFilter, String packageName) {
178         final String clientRecordId = recordIdFilter.getClientRecordId();
179         if (isEmpty(clientRecordId)) {
180             return UUID.fromString(recordIdFilter.getId());
181         }
182 
183         return getUUID(
184                 packageName,
185                 clientRecordId,
186                 HealthConnectMappings.getInstance().getRecordType(recordIdFilter.getRecordType()));
187     }
188 
addPackageNameTo(RecordInternal<?> recordInternal, String packageName)189     public static void addPackageNameTo(RecordInternal<?> recordInternal, String packageName) {
190         recordInternal.setPackageName(packageName);
191     }
192 
193     /** Checks if the value of given column is null */
isNullValue(Cursor cursor, String columnName)194     public static boolean isNullValue(Cursor cursor, String columnName) {
195         return cursor.isNull(cursor.getColumnIndex(columnName));
196     }
197 
getCursorString(Cursor cursor, String columnName)198     public static String getCursorString(Cursor cursor, String columnName) {
199         return cursor.getString(cursor.getColumnIndex(columnName));
200     }
201 
getCursorUUID(Cursor cursor, String columnName)202     public static UUID getCursorUUID(Cursor cursor, String columnName) {
203         return convertBytesToUUID(cursor.getBlob(cursor.getColumnIndex(columnName)));
204     }
205 
getCursorInt(Cursor cursor, String columnName)206     public static int getCursorInt(Cursor cursor, String columnName) {
207         return cursor.getInt(cursor.getColumnIndex(columnName));
208     }
209 
210     /** Reads integer and converts to false anything apart from 1. */
getIntegerAndConvertToBoolean(Cursor cursor, String columnName)211     public static boolean getIntegerAndConvertToBoolean(Cursor cursor, String columnName) {
212         String value = cursor.getString(cursor.getColumnIndex(columnName));
213         if (value == null || value.isEmpty()) {
214             return false;
215         }
216         return Integer.parseInt(value) == BOOLEAN_TRUE_VALUE;
217     }
218 
getCursorLong(Cursor cursor, String columnName)219     public static long getCursorLong(Cursor cursor, String columnName) {
220         return cursor.getLong(cursor.getColumnIndex(columnName));
221     }
222 
getCursorDouble(Cursor cursor, String columnName)223     public static double getCursorDouble(Cursor cursor, String columnName) {
224         return cursor.getDouble(cursor.getColumnIndex(columnName));
225     }
226 
getCursorBlob(Cursor cursor, String columnName)227     public static byte[] getCursorBlob(Cursor cursor, String columnName) {
228         return cursor.getBlob(cursor.getColumnIndex(columnName));
229     }
230 
getCursorStringList( Cursor cursor, String columnName, String delimiter)231     public static List<String> getCursorStringList(
232             Cursor cursor, String columnName, String delimiter) {
233         final String values = cursor.getString(cursor.getColumnIndex(columnName));
234         if (values == null || values.isEmpty()) {
235             return Collections.emptyList();
236         }
237 
238         return Arrays.asList(values.split(delimiter));
239     }
240 
getCursorIntegerList( Cursor cursor, String columnName, String delimiter)241     public static List<Integer> getCursorIntegerList(
242             Cursor cursor, String columnName, String delimiter) {
243         final String stringList = cursor.getString(cursor.getColumnIndex(columnName));
244         if (stringList == null || stringList.isEmpty()) {
245             return Collections.emptyList();
246         }
247 
248         return Arrays.stream(stringList.split(delimiter))
249                 .mapToInt(Integer::valueOf)
250                 .boxed()
251                 .toList();
252     }
253 
getCursorLongList(Cursor cursor, String columnName, String delimiter)254     public static List<Long> getCursorLongList(Cursor cursor, String columnName, String delimiter) {
255         final String stringList = cursor.getString(cursor.getColumnIndex(columnName));
256         if (stringList == null || stringList.isEmpty()) {
257             return Collections.emptyList();
258         }
259 
260         return Arrays.stream(stringList.split(delimiter)).mapToLong(Long::valueOf).boxed().toList();
261     }
262 
flattenIntList(List<Integer> values)263     public static String flattenIntList(List<Integer> values) {
264         return values.stream().map(String::valueOf).collect(Collectors.joining(DELIMITER));
265     }
266 
flattenLongList(List<Long> values)267     public static String flattenLongList(List<Long> values) {
268         return values.stream().map(String::valueOf).collect(Collectors.joining(DELIMITER));
269     }
270 
flattenIntArray(int[] values)271     public static String flattenIntArray(int[] values) {
272         return Arrays.stream(values)
273                 .mapToObj(String::valueOf)
274                 .collect(Collectors.joining(DELIMITER));
275     }
276 
getMaxPrimaryKeyQuery(String tableName)277     private static String getMaxPrimaryKeyQuery(String tableName) {
278         return "SELECT MAX("
279                 + PRIMARY_COLUMN_NAME
280                 + ") as "
281                 + PRIMARY_COLUMN_NAME
282                 + " FROM "
283                 + tableName;
284     }
285 
286     /**
287      * Reads ZoneOffset using given cursor. Returns null of column name is not present in the table.
288      */
289     @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
getZoneOffset(Cursor cursor, String startZoneOffsetColumnName)290     public static ZoneOffset getZoneOffset(Cursor cursor, String startZoneOffsetColumnName) {
291         ZoneOffset zoneOffset = null;
292         if (cursor.getColumnIndex(startZoneOffsetColumnName) != -1) {
293             zoneOffset =
294                     ZoneOffset.ofTotalSeconds(
295                             StorageUtils.getCursorInt(cursor, startZoneOffsetColumnName));
296         }
297 
298         return zoneOffset;
299     }
300 
301     /** Encodes record properties participating in deduplication into a byte array. */
302     @Nullable
getDedupeByteBuffer(RecordInternal<?> record)303     public static byte[] getDedupeByteBuffer(RecordInternal<?> record) {
304         if (!isEmpty(record.getClientRecordId())) {
305             return null; // If dedupe by clientRecordId then don't dedupe by hash
306         }
307 
308         if (record instanceof InstantRecordInternal<?>) {
309             return getDedupeByteBuffer((InstantRecordInternal<?>) record);
310         }
311 
312         if (record instanceof IntervalRecordInternal<?>) {
313             return getDedupeByteBuffer((IntervalRecordInternal<?>) record);
314         }
315 
316         throw new IllegalArgumentException("Unexpected record type: " + record);
317     }
318 
getDedupeByteBuffer(InstantRecordInternal<?> record)319     private static byte[] getDedupeByteBuffer(InstantRecordInternal<?> record) {
320         return ByteBuffer.allocate(Long.BYTES * 3)
321                 .putLong(record.getAppInfoId())
322                 .putLong(record.getDeviceInfoId())
323                 .putLong(record.getTimeInMillis())
324                 .array();
325     }
326 
327     @Nullable
getDedupeByteBuffer(IntervalRecordInternal<?> record)328     private static byte[] getDedupeByteBuffer(IntervalRecordInternal<?> record) {
329         final int type = record.getRecordType();
330         if ((type == RECORD_TYPE_HYDRATION) || (type == RECORD_TYPE_NUTRITION)) {
331             return null; // Some records are exempt from deduplication
332         }
333 
334         return ByteBuffer.allocate(Long.BYTES * 4)
335                 .putLong(record.getAppInfoId())
336                 .putLong(record.getDeviceInfoId())
337                 .putLong(record.getStartTimeInMillis())
338                 .putLong(record.getEndTimeInMillis())
339                 .array();
340     }
341 
342     /** Returns a UUID for the given package name, client record id and record type id. */
getUUID(String packageName, String clientRecordId, int recordTypeId)343     private static UUID getUUID(String packageName, String clientRecordId, int recordTypeId) {
344         final byte[] packageNameBytes = packageName.getBytes();
345         final byte[] clientRecordIdBytes = clientRecordId.getBytes();
346 
347         byte[] bytes =
348                 ByteBuffer.allocate(
349                                 packageNameBytes.length
350                                         + Integer.BYTES
351                                         + clientRecordIdBytes.length)
352                         .put(packageNameBytes)
353                         .putInt(
354                                 InternalHealthConnectMappings.getInstance()
355                                         .getRecordTypeIdForUuid(recordTypeId))
356                         .put(clientRecordIdBytes)
357                         .array();
358         return UUID.nameUUIDFromBytes(bytes);
359     }
360 
361     /**
362      * Returns if priority of apps needs to be considered to compute the aggregate request for the
363      * record type.
364      *
365      * @deprecated use {@link InternalHealthConnectMappings#supportsPriority(int, int)}
366      */
367     @Deprecated
supportsPriority(int recordType, int operationType)368     public static boolean supportsPriority(int recordType, int operationType) {
369         if (operationType != SUM) {
370             return false;
371         }
372 
373         @HealthDataCategory.Type
374         int recordCategory =
375                 RecordTypeRecordCategoryMapper.getRecordCategoryForRecordType(recordType);
376         return recordCategory == ACTIVITY || recordCategory == SLEEP || recordCategory == WELLNESS;
377     }
378 
379     /**
380      * Returns if derivation needs to be done to calculate aggregate.
381      *
382      * @deprecated use {@link InternalHealthConnectMappings#isDerivedType(int)}
383      */
384     @Deprecated
isDerivedType(int recordType)385     public static boolean isDerivedType(int recordType) {
386         return recordType == RECORD_TYPE_BASAL_METABOLIC_RATE
387                 || recordType == RECORD_TYPE_TOTAL_CALORIES_BURNED;
388     }
389 
convertBytesToUUID(byte[] bytes)390     public static UUID convertBytesToUUID(byte[] bytes) {
391         ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
392         long high = byteBuffer.getLong();
393         long low = byteBuffer.getLong();
394         return new UUID(high, low);
395     }
396 
convertUUIDToBytes(UUID uuid)397     public static byte[] convertUUIDToBytes(UUID uuid) {
398         ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]);
399         byteBuffer.putLong(uuid.getMostSignificantBits());
400         byteBuffer.putLong(uuid.getLeastSignificantBits());
401         return byteBuffer.array();
402     }
403 
404     /** Convert a double value to bytes. */
convertDoubleToBytes(double value)405     public static byte[] convertDoubleToBytes(double value) {
406         ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[8]);
407         byteBuffer.putDouble(value);
408         return byteBuffer.array();
409     }
410 
411     /** Convert bytes to a double. */
convertBytesToDouble(byte[] bytes)412     public static double convertBytesToDouble(byte[] bytes) {
413         return ByteBuffer.wrap(bytes).getDouble();
414     }
415 
416     /** Convert an integer value to bytes. */
convertIntToBytes(int value)417     public static byte[] convertIntToBytes(int value) {
418         ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[4]);
419         byteBuffer.putInt(value);
420         return byteBuffer.array();
421     }
422 
423     /** Convert bytes to an integer. */
convertBytesToInt(byte[] bytes)424     public static int convertBytesToInt(byte[] bytes) {
425         return ByteBuffer.wrap(bytes).getInt();
426     }
427 
428     /** Convert bytes to a long. */
convertLongToBytes(long value)429     public static byte[] convertLongToBytes(long value) {
430         ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[8]);
431         byteBuffer.putLong(value);
432         return byteBuffer.array();
433     }
434 
435     /** Convert a long value to bytes. */
convertBytesToLong(byte[] bytes)436     public static long convertBytesToLong(byte[] bytes) {
437         return ByteBuffer.wrap(bytes).getLong();
438     }
439 
440     /**
441      * Creates a list of UUIDs from a collection of the string representation of the UUIDs. Any ids
442      * which cannot be parsed as UUIDs are ignores. It is the responsibility of the caller to handle
443      * the case where a non-empty list becomes empty.
444      *
445      * @param ids the ids to parse
446      * @return a possibly empty list of UUIDs
447      */
toUuids(Collection<String> ids)448     public static List<UUID> toUuids(Collection<String> ids) {
449         return ids.stream()
450                 .flatMap(
451                         id -> {
452                             try {
453                                 return Stream.of(UUID.fromString(id));
454                             } catch (IllegalArgumentException ex) {
455                                 return Stream.of();
456                             }
457                         })
458                 .toList();
459     }
460 
461     /** Converts a list of {@link UUID} strings to a list of hex strings. */
462     public static List<String> convertUuidStringsToHexStrings(List<String> ids) {
463         return StorageUtils.getListOfHexStrings(toUuids(ids));
464     }
465 
466     public static String getHexString(byte[] value) {
467         if (value == null) {
468             return "";
469         }
470 
471         final StringBuilder builder = new StringBuilder("x'");
472         for (byte b : value) {
473             builder.append(String.format("%02x", b));
474         }
475         builder.append("'");
476 
477         return builder.toString();
478     }
479 
480     public static String getHexString(UUID uuid) {
481         return getHexString(convertUUIDToBytes(uuid));
482     }
483 
484     /** Creates a list of Hex strings for a given list of {@code UUID}s. */
485     public static List<String> getListOfHexStrings(List<UUID> uuids) {
486         List<String> hexStrings = new ArrayList<>();
487         for (UUID uuid : uuids) {
488             hexStrings.add(getHexString(convertUUIDToBytes(uuid)));
489         }
490 
491         return hexStrings;
492     }
493 
494     /**
495      * Returns a byte array containing sublist of the given uuids list, from position {@code
496      * start}(inclusive) to {@code end}(exclusive).
497      */
498     public static byte[] getSingleByteArray(List<UUID> uuids) {
499         byte[] allByteArray = new byte[UUID_BYTE_SIZE * uuids.size()];
500 
501         ByteBuffer byteBuffer = ByteBuffer.wrap(allByteArray);
502         for (UUID uuid : uuids) {
503             byteBuffer.put(convertUUIDToBytes(uuid));
504         }
505 
506         return byteBuffer.array();
507     }
508 
509     public static List<UUID> getCursorUUIDList(Cursor cursor, String columnName) {
510         byte[] bytes = cursor.getBlob(cursor.getColumnIndex(columnName));
511         return bytesToUuids(bytes);
512     }
513 
514     /** Turns a byte array to a UUID list. */
515     @VisibleForTesting(visibility = PRIVATE)
516     public static List<UUID> bytesToUuids(byte[] bytes) {
517         ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
518 
519         List<UUID> uuidList = new ArrayList<>();
520         while (byteBuffer.hasRemaining()) {
521             long high = byteBuffer.getLong();
522             long low = byteBuffer.getLong();
523             uuidList.add(new UUID(high, low));
524         }
525         return uuidList;
526     }
527 
528     /**
529      * Returns a quoted, escaped id if {@code id} is not quoted. Following examples show the
530      * expected return values,
531      *
532      * <p>getNormalisedId("id") -> "'id'"
533      *
534      * <p>getNormalisedId("'id'") -> "'id'"
535      *
536      * <p>getNormalisedId("x'id'") -> "x'id'"
537      */
538     public static String getNormalisedString(String id) {
539         StringBuilder result = new StringBuilder();
540 
541         String innerId;
542         if (id.startsWith("'") && id.endsWith("'")) {
543             innerId = id.substring(1, id.length() - 1);
544         } else if ((id.startsWith("x'") || id.startsWith("X'")) && id.endsWith("'")) {
545             innerId = id.substring(2, id.length() - 1);
546             result.append(id.charAt(0));
547         } else {
548             innerId = id;
549         }
550 
551         DatabaseUtils.appendEscapedSQLString(result, innerId);
552 
553         return result.toString();
554     }
555 
556     /** Checks whether {@code tableName} exists in the {@code database}. */
557     public static boolean checkTableExists(SQLiteDatabase database, String tableName) {
558         try (Cursor cursor =
559                 database.rawQuery(
560                         "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
561                         new String[] {tableName})) {
562             if (cursor.getCount() == 0) {
563                 Slog.d(TAG, "Table does not exist: " + tableName);
564             }
565             return cursor.getCount() > 0;
566         }
567     }
568 
569     /** Gets the last id for {@code tableName} exists in the {@code database}. */
570     public static long getLastRowIdFor(SQLiteDatabase database, String tableName) {
571         try (Cursor cursor =
572                 database.rawQuery(StorageUtils.getMaxPrimaryKeyQuery(tableName), null)) {
573             cursor.moveToFirst();
574             return cursor.getLong(cursor.getColumnIndex(PRIMARY_COLUMN_NAME));
575         }
576     }
577 
578     /** Extracts and holds data from {@link ContentValues}. */
579     public static class RecordIdentifierData {
580         private final String mClientRecordId;
581         private final UUID mUuid;
582 
583         public RecordIdentifierData(ContentValues contentValues) {
584             mClientRecordId = contentValues.getAsString(CLIENT_RECORD_ID_COLUMN_NAME);
585             mUuid = StorageUtils.convertBytesToUUID(contentValues.getAsByteArray(UUID_COLUMN_NAME));
586         }
587 
588         @Nullable
589         public String getClientRecordId() {
590             return mClientRecordId;
591         }
592 
593         @Nullable
594         public UUID getUuid() {
595             return mUuid;
596         }
597 
598         @Override
599         public String toString() {
600             final StringBuilder builder = new StringBuilder();
601             if (mClientRecordId != null && !mClientRecordId.isEmpty()) {
602                 builder.append("clientRecordID : ").append(mClientRecordId).append(" , ");
603             }
604 
605             if (mUuid != null) {
606                 builder.append("uuid : ").append(mUuid).append(" , ");
607             }
608             return builder.toString();
609         }
610     }
611 }
612