1 /*
2  * Copyright (C) 2019 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.networkstack.ipmemorystore;
18 
19 import static com.android.net.module.util.Inet4AddressUtils.inet4AddressToIntHTH;
20 import static com.android.net.module.util.Inet4AddressUtils.intToInet4AddressHTH;
21 
22 import android.content.ContentValues;
23 import android.content.Context;
24 import android.database.Cursor;
25 import android.database.sqlite.SQLiteCursor;
26 import android.database.sqlite.SQLiteCursorDriver;
27 import android.database.sqlite.SQLiteDatabase;
28 import android.database.sqlite.SQLiteException;
29 import android.database.sqlite.SQLiteOpenHelper;
30 import android.database.sqlite.SQLiteQuery;
31 import android.net.ipmemorystore.NetworkAttributes;
32 import android.net.ipmemorystore.Status;
33 import android.util.Log;
34 
35 import androidx.annotation.NonNull;
36 import androidx.annotation.Nullable;
37 
38 import java.io.ByteArrayInputStream;
39 import java.io.ByteArrayOutputStream;
40 import java.net.InetAddress;
41 import java.net.UnknownHostException;
42 import java.util.ArrayList;
43 import java.util.List;
44 import java.util.StringJoiner;
45 
46 /**
47  * Encapsulating class for using the SQLite database backing the memory store.
48  *
49  * This class groups together the contracts and the SQLite helper used to
50  * use the database.
51  *
52  * @hide
53  */
54 public class IpMemoryStoreDatabase {
55     private static final String TAG = IpMemoryStoreDatabase.class.getSimpleName();
56     // A pair of NetworkAttributes objects is group-close if the confidence that they are
57     // the same is above this cutoff. See NetworkAttributes and SameL3NetworkResponse.
58     private static final float GROUPCLOSE_CONFIDENCE = 0.5f;
59 
60     /**
61      * Contract class for the Network Attributes table.
62      */
63     public static final class NetworkAttributesContract {
NetworkAttributesContract()64         private NetworkAttributesContract() {}
65 
66         public static final String TABLENAME = "NetworkAttributes";
67 
68         public static final String COLNAME_L2KEY = "l2Key";
69         public static final String COLTYPE_L2KEY = "TEXT NOT NULL";
70 
71         public static final String COLNAME_EXPIRYDATE = "expiryDate";
72         // Milliseconds since the Epoch, in true Java style
73         public static final String COLTYPE_EXPIRYDATE = "BIGINT";
74 
75         public static final String COLNAME_ASSIGNEDV4ADDRESS = "assignedV4Address";
76         public static final String COLTYPE_ASSIGNEDV4ADDRESS = "INTEGER";
77 
78         public static final String COLNAME_ASSIGNEDV4ADDRESSEXPIRY = "assignedV4AddressExpiry";
79         // The lease expiry timestamp in uint of milliseconds since the Epoch. Long.MAX_VALUE
80         // is used to represent "infinite lease".
81         public static final String COLTYPE_ASSIGNEDV4ADDRESSEXPIRY = "BIGINT";
82 
83         // An optional cluster representing a notion of group owned by the client. The memory
84         // store uses this as a hint for grouping, but not as an overriding factor. The client
85         // can then use this to find networks belonging to a cluster. An example of this could
86         // be the SSID for WiFi, where same SSID-networks may not be the same L3 networks but
87         // it's still useful for managing networks.
88         // Note that "groupHint" is the legacy name of the column. The column should be renamed
89         // in the database – ALTER TABLE ${NetworkAttributesContract.TABLENAME RENAME} COLUMN
90         // groupHint TO cluster – but this has been postponed to reduce risk as the Mainline
91         // release winter imposes a lot of changes be pushed at the same time in the next release.
92         public static final String COLNAME_CLUSTER = "groupHint";
93         public static final String COLTYPE_CLUSTER = "TEXT";
94 
95         public static final String COLNAME_DNSADDRESSES = "dnsAddresses";
96         // Stored in marshalled form as is
97         public static final String COLTYPE_DNSADDRESSES = "BLOB";
98 
99         public static final String COLNAME_MTU = "mtu";
100         public static final String COLTYPE_MTU = "INTEGER DEFAULT -1";
101 
102         public static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS "
103                 + TABLENAME                       + " ("
104                 + COLNAME_L2KEY                   + " " + COLTYPE_L2KEY + " PRIMARY KEY NOT NULL, "
105                 + COLNAME_EXPIRYDATE              + " " + COLTYPE_EXPIRYDATE              + ", "
106                 + COLNAME_ASSIGNEDV4ADDRESS       + " " + COLTYPE_ASSIGNEDV4ADDRESS       + ", "
107                 + COLNAME_ASSIGNEDV4ADDRESSEXPIRY + " " + COLTYPE_ASSIGNEDV4ADDRESSEXPIRY + ", "
108                 + COLNAME_CLUSTER                 + " " + COLTYPE_CLUSTER                 + ", "
109                 + COLNAME_DNSADDRESSES            + " " + COLTYPE_DNSADDRESSES            + ", "
110                 + COLNAME_MTU                     + " " + COLTYPE_MTU                     + ")";
111         public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLENAME;
112     }
113 
114     /**
115      * Contract class for the Private Data table.
116      */
117     public static final class PrivateDataContract {
PrivateDataContract()118         private PrivateDataContract() {}
119 
120         public static final String TABLENAME = "PrivateData";
121 
122         public static final String COLNAME_L2KEY = "l2Key";
123         public static final String COLTYPE_L2KEY = "TEXT NOT NULL";
124 
125         public static final String COLNAME_CLIENT = "client";
126         public static final String COLTYPE_CLIENT = "TEXT NOT NULL";
127 
128         public static final String COLNAME_DATANAME = "dataName";
129         public static final String COLTYPE_DATANAME = "TEXT NOT NULL";
130 
131         public static final String COLNAME_DATA = "data";
132         public static final String COLTYPE_DATA = "BLOB NOT NULL";
133 
134         public static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS "
135                 + TABLENAME        + " ("
136                 + COLNAME_L2KEY    + " " + COLTYPE_L2KEY    + ", "
137                 + COLNAME_CLIENT   + " " + COLTYPE_CLIENT   + ", "
138                 + COLNAME_DATANAME + " " + COLTYPE_DATANAME + ", "
139                 + COLNAME_DATA     + " " + COLTYPE_DATA     + ", "
140                 + "PRIMARY KEY ("
141                 + COLNAME_L2KEY    + ", "
142                 + COLNAME_CLIENT   + ", "
143                 + COLNAME_DATANAME + "))";
144         public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLENAME;
145     }
146 
147     // To save memory when the DB is not used, close it after 30s of inactivity. This is
148     // determined manually based on what feels right.
149     private static final long IDLE_CONNECTION_TIMEOUT_MS = 30_000;
150 
151     /** The SQLite DB helper */
152     public static class DbHelper extends SQLiteOpenHelper {
153         // Update this whenever changing the schema.
154         // DO NOT CHANGE without solid testing for downgrades, and checking onDowngrade
155         // below: b/171340630
156         private static final int SCHEMA_VERSION = 4;
157         private static final String DATABASE_FILENAME = "IpMemoryStore.db";
158         private static final String TRIGGER_NAME = "delete_cascade_to_private";
159 
DbHelper(@onNull final Context context)160         public DbHelper(@NonNull final Context context) {
161             super(context, DATABASE_FILENAME, null, SCHEMA_VERSION);
162             setIdleConnectionTimeout(IDLE_CONNECTION_TIMEOUT_MS);
163         }
164 
165         /** Called when the database is created */
166         @Override
onCreate(@onNull final SQLiteDatabase db)167         public void onCreate(@NonNull final SQLiteDatabase db) {
168             db.execSQL(NetworkAttributesContract.CREATE_TABLE);
169             db.execSQL(PrivateDataContract.CREATE_TABLE);
170             createTrigger(db);
171         }
172 
173         /** Called when the database is upgraded */
174         @Override
onUpgrade(@onNull final SQLiteDatabase db, final int oldVersion, final int newVersion)175         public void onUpgrade(@NonNull final SQLiteDatabase db, final int oldVersion,
176                 final int newVersion) {
177             try {
178                 if (oldVersion < 2) {
179                     // upgrade from version 1 to version 2
180                     // since we start from version 2, do nothing here
181                 }
182 
183                 if (oldVersion < 3) {
184                     // upgrade from version 2 to version 3
185                     final String sqlUpgradeAddressExpiry = "alter table"
186                             + " " + NetworkAttributesContract.TABLENAME + " ADD"
187                             + " " + NetworkAttributesContract.COLNAME_ASSIGNEDV4ADDRESSEXPIRY
188                             + " " + NetworkAttributesContract.COLTYPE_ASSIGNEDV4ADDRESSEXPIRY;
189                     db.execSQL(sqlUpgradeAddressExpiry);
190                 }
191 
192                 if (oldVersion < 4) {
193                     createTrigger(db);
194                 }
195             } catch (SQLiteException e) {
196                 Log.e(TAG, "Could not upgrade to the new version", e);
197                 // create database with new version
198                 db.execSQL(NetworkAttributesContract.DROP_TABLE);
199                 db.execSQL(PrivateDataContract.DROP_TABLE);
200                 onCreate(db);
201             }
202         }
203 
204         /** Called when the database is downgraded */
205         @Override
onDowngrade(@onNull final SQLiteDatabase db, final int oldVersion, final int newVersion)206         public void onDowngrade(@NonNull final SQLiteDatabase db, final int oldVersion,
207                 final int newVersion) {
208             // Downgrades always nuke all data and recreate an empty table.
209             db.execSQL(NetworkAttributesContract.DROP_TABLE);
210             db.execSQL(PrivateDataContract.DROP_TABLE);
211             // TODO: add test for downgrades. Triggers should already be dropped
212             // when the table is dropped, so this may be a bug.
213             // Note that fixing this code does not affect how older versions
214             // will handle downgrades.
215             db.execSQL("DROP TRIGGER " + TRIGGER_NAME);
216             onCreate(db);
217         }
218 
createTrigger(@onNull final SQLiteDatabase db)219         private void createTrigger(@NonNull final SQLiteDatabase db) {
220             final String createTrigger = "CREATE TRIGGER " + TRIGGER_NAME
221                     + " DELETE ON " + NetworkAttributesContract.TABLENAME
222                     + " BEGIN"
223                     + " DELETE FROM " + PrivateDataContract.TABLENAME + " WHERE OLD."
224                     + NetworkAttributesContract.COLNAME_L2KEY
225                     + "=" + PrivateDataContract.COLNAME_L2KEY
226                     + "; END;";
227             db.execSQL(createTrigger);
228         }
229     }
230 
231     @NonNull
encodeAddressList(@onNull final List<InetAddress> addresses)232     private static byte[] encodeAddressList(@NonNull final List<InetAddress> addresses) {
233         final ByteArrayOutputStream os = new ByteArrayOutputStream();
234         for (final InetAddress address : addresses) {
235             final byte[] b = address.getAddress();
236             os.write(b.length);
237             os.write(b, 0, b.length);
238         }
239         return os.toByteArray();
240     }
241 
242     @NonNull
decodeAddressList(@onNull final byte[] encoded)243     private static ArrayList<InetAddress> decodeAddressList(@NonNull final byte[] encoded) {
244         final ByteArrayInputStream is = new ByteArrayInputStream(encoded);
245         final ArrayList<InetAddress> addresses = new ArrayList<>();
246         int d = -1;
247         while ((d = is.read()) != -1) {
248             final byte[] bytes = new byte[d];
249             is.read(bytes, 0, d);
250             try {
251                 addresses.add(InetAddress.getByAddress(bytes));
252             } catch (UnknownHostException e) { /* Hopefully impossible */ }
253         }
254         return addresses;
255     }
256 
257     @NonNull
toContentValues(@ullable final NetworkAttributes attributes)258     private static ContentValues toContentValues(@Nullable final NetworkAttributes attributes) {
259         final ContentValues values = new ContentValues();
260         if (null == attributes) return values;
261         if (null != attributes.assignedV4Address) {
262             values.put(NetworkAttributesContract.COLNAME_ASSIGNEDV4ADDRESS,
263                     inet4AddressToIntHTH(attributes.assignedV4Address));
264         }
265         if (null != attributes.assignedV4AddressExpiry) {
266             values.put(NetworkAttributesContract.COLNAME_ASSIGNEDV4ADDRESSEXPIRY,
267                     attributes.assignedV4AddressExpiry);
268         }
269         if (null != attributes.cluster) {
270             values.put(NetworkAttributesContract.COLNAME_CLUSTER, attributes.cluster);
271         }
272         if (null != attributes.dnsAddresses) {
273             values.put(NetworkAttributesContract.COLNAME_DNSADDRESSES,
274                     encodeAddressList(attributes.dnsAddresses));
275         }
276         if (null != attributes.mtu) {
277             values.put(NetworkAttributesContract.COLNAME_MTU, attributes.mtu);
278         }
279         return values;
280     }
281 
282     // Convert a NetworkAttributes object to content values to store them in a table compliant
283     // with the contract defined in NetworkAttributesContract.
284     @NonNull
toContentValues(@onNull final String key, @Nullable final NetworkAttributes attributes, final long expiry)285     private static ContentValues toContentValues(@NonNull final String key,
286             @Nullable final NetworkAttributes attributes, final long expiry) {
287         final ContentValues values = toContentValues(attributes);
288         values.put(NetworkAttributesContract.COLNAME_L2KEY, key);
289         values.put(NetworkAttributesContract.COLNAME_EXPIRYDATE, expiry);
290         return values;
291     }
292 
293     // Convert a byte array into content values to store it in a table compliant with the
294     // contract defined in PrivateDataContract.
295     @NonNull
toContentValues(@onNull final String key, @NonNull final String clientId, @NonNull final String name, @NonNull final byte[] data)296     private static ContentValues toContentValues(@NonNull final String key,
297             @NonNull final String clientId, @NonNull final String name,
298             @NonNull final byte[] data) {
299         final ContentValues values = new ContentValues();
300         values.put(PrivateDataContract.COLNAME_L2KEY, key);
301         values.put(PrivateDataContract.COLNAME_CLIENT, clientId);
302         values.put(PrivateDataContract.COLNAME_DATANAME, name);
303         values.put(PrivateDataContract.COLNAME_DATA, data);
304         return values;
305     }
306 
307     @Nullable
readNetworkAttributesLine(@onNull final Cursor cursor)308     private static NetworkAttributes readNetworkAttributesLine(@NonNull final Cursor cursor) {
309         // Make sure the data hasn't expired
310         final long expiry = getLong(cursor, NetworkAttributesContract.COLNAME_EXPIRYDATE, -1L);
311         if (expiry < System.currentTimeMillis()) return null;
312 
313         final NetworkAttributes.Builder builder = new NetworkAttributes.Builder();
314         final int assignedV4AddressInt = getInt(cursor,
315                 NetworkAttributesContract.COLNAME_ASSIGNEDV4ADDRESS, 0);
316         final long assignedV4AddressExpiry = getLong(cursor,
317                 NetworkAttributesContract.COLNAME_ASSIGNEDV4ADDRESSEXPIRY, 0);
318         final String cluster = getString(cursor, NetworkAttributesContract.COLNAME_CLUSTER);
319         final byte[] dnsAddressesBlob =
320                 getBlob(cursor, NetworkAttributesContract.COLNAME_DNSADDRESSES);
321         final int mtu = getInt(cursor, NetworkAttributesContract.COLNAME_MTU, -1);
322         if (0 != assignedV4AddressInt) {
323             builder.setAssignedV4Address(intToInet4AddressHTH(assignedV4AddressInt));
324         }
325         if (0 != assignedV4AddressExpiry) {
326             builder.setAssignedV4AddressExpiry(assignedV4AddressExpiry);
327         }
328         builder.setCluster(cluster);
329         if (null != dnsAddressesBlob) {
330             builder.setDnsAddresses(decodeAddressList(dnsAddressesBlob));
331         }
332         if (mtu >= 0) {
333             builder.setMtu(mtu);
334         }
335         return builder.build();
336     }
337 
338     private static final String[] EXPIRY_COLUMN = new String[] {
339         NetworkAttributesContract.COLNAME_EXPIRYDATE
340     };
341     static final int EXPIRY_ERROR = -1; // Legal values for expiry are positive
342 
343     static final String SELECT_L2KEY = NetworkAttributesContract.COLNAME_L2KEY + " = ?";
344 
345     // Returns the expiry date of the specified row, or one of the error codes above if the
346     // row is not found or some other error
getExpiry(@onNull final SQLiteDatabase db, @NonNull final String key)347     static long getExpiry(@NonNull final SQLiteDatabase db, @NonNull final String key) {
348         try (Cursor cursor = db.query(NetworkAttributesContract.TABLENAME,
349                 EXPIRY_COLUMN, // columns
350                 SELECT_L2KEY, // selection
351                 new String[] { key }, // selectionArgs
352                 null, // groupBy
353                 null, // having
354                 null)) { // orderBy
355             // L2KEY is the primary key ; it should not be possible to get more than one
356             // result here. 0 results means the key was not found.
357             if (cursor.getCount() != 1) return EXPIRY_ERROR;
358             cursor.moveToFirst();
359             return cursor.getLong(0); // index in the EXPIRY_COLUMN array
360         }
361     }
362 
363     static final int RELEVANCE_ERROR = -1; // Legal values for relevance are positive
364 
365     // Returns the relevance of the specified row, or one of the error codes above if the
366     // row is not found or some other error
getRelevance(@onNull final SQLiteDatabase db, @NonNull final String key)367     static int getRelevance(@NonNull final SQLiteDatabase db, @NonNull final String key) {
368         final long expiry = getExpiry(db, key);
369         return expiry < 0 ? (int) expiry : RelevanceUtils.computeRelevanceForNow(expiry);
370     }
371 
372     // If the attributes are null, this will only write the expiry.
373     // Returns an int out of Status.{SUCCESS, ERROR_*}
storeNetworkAttributes(@onNull final SQLiteDatabase db, @NonNull final String key, final long expiry, @Nullable final NetworkAttributes attributes)374     static int storeNetworkAttributes(@NonNull final SQLiteDatabase db, @NonNull final String key,
375             final long expiry, @Nullable final NetworkAttributes attributes) {
376         final ContentValues cv = toContentValues(key, attributes, expiry);
377         db.beginTransaction();
378         try {
379             // Unfortunately SQLite does not have any way to do INSERT OR UPDATE. Options are
380             // to either insert with on conflict ignore then update (like done here), or to
381             // construct a custom SQL INSERT statement with nested select.
382             final long resultId = db.insertWithOnConflict(NetworkAttributesContract.TABLENAME,
383                     null, cv, SQLiteDatabase.CONFLICT_IGNORE);
384             if (resultId < 0) {
385                 db.update(NetworkAttributesContract.TABLENAME, cv, SELECT_L2KEY, new String[]{key});
386             }
387             db.setTransactionSuccessful();
388             return Status.SUCCESS;
389         } catch (SQLiteException e) {
390             // No space left on disk or something
391             Log.e(TAG, "Could not write to the memory store", e);
392         } finally {
393             db.endTransaction();
394         }
395         return Status.ERROR_STORAGE;
396     }
397 
398     // Returns an int out of Status.{SUCCESS, ERROR_*}
storeBlob(@onNull final SQLiteDatabase db, @NonNull final String key, @NonNull final String clientId, @NonNull final String name, @NonNull final byte[] data)399     static int storeBlob(@NonNull final SQLiteDatabase db, @NonNull final String key,
400             @NonNull final String clientId, @NonNull final String name,
401             @NonNull final byte[] data) {
402         final long res = db.insertWithOnConflict(PrivateDataContract.TABLENAME, null,
403                 toContentValues(key, clientId, name, data), SQLiteDatabase.CONFLICT_REPLACE);
404         return (res == -1) ? Status.ERROR_STORAGE : Status.SUCCESS;
405     }
406 
407     @Nullable
retrieveNetworkAttributes(@onNull final SQLiteDatabase db, @NonNull final String key)408     static NetworkAttributes retrieveNetworkAttributes(@NonNull final SQLiteDatabase db,
409             @NonNull final String key) {
410         try (Cursor cursor = db.query(NetworkAttributesContract.TABLENAME,
411                 null, // columns, null means everything
412                 NetworkAttributesContract.COLNAME_L2KEY + " = ?", // selection
413                 new String[] { key }, // selectionArgs
414                 null, // groupBy
415                 null, // having
416                 null)) { // orderBy
417             // L2KEY is the primary key ; it should not be possible to get more than one
418             // result here. 0 results means the key was not found.
419             if (cursor.getCount() != 1) return null;
420             cursor.moveToFirst();
421             return readNetworkAttributesLine(cursor);
422         }
423     }
424 
425     private static final String[] DATA_COLUMN = new String[] {
426             PrivateDataContract.COLNAME_DATA
427     };
428 
429     @Nullable
retrieveBlob(@onNull final SQLiteDatabase db, @NonNull final String key, @NonNull final String clientId, @NonNull final String name)430     static byte[] retrieveBlob(@NonNull final SQLiteDatabase db, @NonNull final String key,
431             @NonNull final String clientId, @NonNull final String name) {
432         try (Cursor cursor = db.query(PrivateDataContract.TABLENAME,
433                 DATA_COLUMN, // columns
434                 PrivateDataContract.COLNAME_L2KEY + " = ? AND " // selection
435                 + PrivateDataContract.COLNAME_CLIENT + " = ? AND "
436                 + PrivateDataContract.COLNAME_DATANAME + " = ?",
437                 new String[] { key, clientId, name }, // selectionArgs
438                 null, // groupBy
439                 null, // having
440                 null)) { // orderBy
441             // The query above is querying by (composite) primary key, so it should not be possible
442             // to get more than one result here. 0 results means the key was not found.
443             if (cursor.getCount() != 1) return null;
444             cursor.moveToFirst();
445             return cursor.getBlob(0); // index in the DATA_COLUMN array
446         }
447     }
448 
449     /**
450      * Wipe all data in tables when network factory reset occurs.
451      */
wipeDataUponNetworkReset(@onNull final SQLiteDatabase db)452     static void wipeDataUponNetworkReset(@NonNull final SQLiteDatabase db) {
453         for (int remainingRetries = 3; remainingRetries > 0; --remainingRetries) {
454             db.beginTransaction();
455             try {
456                 db.delete(NetworkAttributesContract.TABLENAME, null, null);
457                 db.delete(PrivateDataContract.TABLENAME, null, null);
458                 try (Cursor cursorNetworkAttributes = db.query(
459                         // table name
460                         NetworkAttributesContract.TABLENAME,
461                         // column name
462                         new String[] { NetworkAttributesContract.COLNAME_L2KEY },
463                         null, // selection
464                         null, // selectionArgs
465                         null, // groupBy
466                         null, // having
467                         null, // orderBy
468                         "1")) { // limit
469                     if (0 != cursorNetworkAttributes.getCount()) continue;
470                 }
471                 try (Cursor cursorPrivateData = db.query(
472                         // table name
473                         PrivateDataContract.TABLENAME,
474                         // column name
475                         new String[] { PrivateDataContract.COLNAME_L2KEY },
476                         null, // selection
477                         null, // selectionArgs
478                         null, // groupBy
479                         null, // having
480                         null, // orderBy
481                         "1")) { // limit
482                     if (0 != cursorPrivateData.getCount()) continue;
483                 }
484                 db.setTransactionSuccessful();
485             } catch (SQLiteException e) {
486                 Log.e(TAG, "Could not wipe the data in database", e);
487             } finally {
488                 db.endTransaction();
489             }
490         }
491     }
492 
493     /**
494      * The following is a horrible hack that is necessary because the Android SQLite API does not
495      * have a way to query a binary blob. This, almost certainly, is an overlook.
496      *
497      * The Android SQLite API has two family of methods : one for query that returns data, and
498      * one for more general SQL statements that can execute any statement but may not return
499      * anything. All the query methods, however, take only String[] for the arguments.
500      *
501      * In principle it is simple to write a function that will encode the binary blob in the
502      * way SQLite expects it. However, because the API forces the argument to be coerced into a
503      * String, the SQLiteQuery object generated by the default query methods will bind all
504      * arguments as Strings and SQL will *sanitize* them. This works okay for numeric types,
505      * but the format for blobs is x'<hex string>'. Note the presence of quotes, which will
506      * be sanitized, changing the contents of the field, and the query will fail to match the
507      * blob.
508      *
509      * As far as I can tell, there are two possible ways around this problem. The first one
510      * is to put the data in the query string and eschew it being an argument. This would
511      * require doing the sanitizing by hand. The other is to call bindBlob directly on the
512      * generated SQLiteQuery object, which not only is a lot less dangerous than rolling out
513      * sanitizing, but also will do the right thing if the underlying format ever changes.
514      *
515      * But none of the methods that take an SQLiteQuery object can return data ; this *must*
516      * be called with SQLiteDatabase#query. This object is not accessible from outside.
517      * However, there is a #query version that accepts a CursorFactory and this is pretty
518      * straightforward to implement as all the arguments are coming in and the SQLiteCursor
519      * class is public API.
520      * With this, it's possible to intercept the SQLiteQuery object, and assuming the args
521      * are available, to bind them directly and work around the API's oblivious coercion into
522      * Strings.
523      *
524      * This is really sad, but I don't see another way of having this work than this or the
525      * hand-rolled sanitizing, and this is the lesser evil.
526      */
527     private static class CustomCursorFactory implements SQLiteDatabase.CursorFactory {
528         @NonNull
529         private final ArrayList<Object> mArgs;
CustomCursorFactory(@onNull final ArrayList<Object> args)530         CustomCursorFactory(@NonNull final ArrayList<Object> args) {
531             mArgs = args;
532         }
533         @Override
newCursor(final SQLiteDatabase db, final SQLiteCursorDriver masterQuery, final String editTable, final SQLiteQuery query)534         public Cursor newCursor(final SQLiteDatabase db, final SQLiteCursorDriver masterQuery,
535                 final String editTable,
536                 final SQLiteQuery query) {
537             int index = 1; // bind is 1-indexed
538             for (final Object arg : mArgs) {
539                 if (arg instanceof String) {
540                     query.bindString(index++, (String) arg);
541                 } else if (arg instanceof Long) {
542                     query.bindLong(index++, (Long) arg);
543                 } else if (arg instanceof Integer) {
544                     query.bindLong(index++, Long.valueOf((Integer) arg));
545                 } else if (arg instanceof byte[]) {
546                     query.bindBlob(index++, (byte[]) arg);
547                 } else {
548                     throw new IllegalStateException("Unsupported type CustomCursorFactory "
549                             + arg.getClass().toString());
550                 }
551             }
552             return new SQLiteCursor(masterQuery, editTable, query);
553         }
554     }
555 
556     // Returns the l2key of the closest match, if and only if it matches
557     // closely enough (as determined by group-closeness).
558     @Nullable
findClosestAttributes(@onNull final SQLiteDatabase db, @NonNull final NetworkAttributes attr)559     static String findClosestAttributes(@NonNull final SQLiteDatabase db,
560             @NonNull final NetworkAttributes attr) {
561         if (attr.isEmpty()) return null;
562         final ContentValues values = toContentValues(attr);
563 
564         // Build the selection and args. To cut down on the number of lines to search, limit
565         // the search to those with at least one argument equals to the requested attributes.
566         // This works only because null attributes match only will not result in group-closeness.
567         final StringJoiner sj = new StringJoiner(" OR ");
568         final ArrayList<Object> args = new ArrayList<>();
569         args.add(System.currentTimeMillis());
570         for (final String field : values.keySet()) {
571             sj.add(field + " = ?");
572             args.add(values.get(field));
573         }
574 
575         final String selection = NetworkAttributesContract.COLNAME_EXPIRYDATE + " > ? AND ("
576                 + sj.toString() + ")";
577         try (Cursor cursor = db.queryWithFactory(new CustomCursorFactory(args),
578                 false, // distinct
579                 NetworkAttributesContract.TABLENAME,
580                 null, // columns, null means everything
581                 selection, // selection
582                 null, // selectionArgs, horrendously passed to the cursor factory instead
583                 null, // groupBy
584                 null, // having
585                 null, // orderBy
586                 null)) { // limit
587             if (cursor.getCount() <= 0) return null;
588             cursor.moveToFirst();
589             String bestKey = null;
590             float bestMatchConfidence =
591                     GROUPCLOSE_CONFIDENCE; // Never return a match worse than this.
592             while (!cursor.isAfterLast()) {
593                 final NetworkAttributes read = readNetworkAttributesLine(cursor);
594                 final float confidence = read.getNetworkGroupSamenessConfidence(attr);
595                 if (confidence > bestMatchConfidence) {
596                     bestKey = getString(cursor, NetworkAttributesContract.COLNAME_L2KEY);
597                     bestMatchConfidence = confidence;
598                 }
599                 cursor.moveToNext();
600             }
601             return bestKey;
602         }
603     }
604 
605     /**
606      * Delete a single entry by key.
607      *
608      * If |needWipe| is true, the data will be wiped from disk immediately. Otherwise, it will
609      * only be marked deleted, and overwritten by subsequent writes or reclaimed during the next
610      * maintenance window.
611      * Note that wiping data is a very expensive operation. This is meant for clients that need
612      * this data gone from disk immediately for security reasons. Functionally it makes no
613      * difference at all.
614      */
delete(@onNull final SQLiteDatabase db, @NonNull final String l2key, final boolean needWipe)615     static StatusAndCount delete(@NonNull final SQLiteDatabase db, @NonNull final String l2key,
616             final boolean needWipe) {
617         return deleteEntriesWithColumn(db,
618                 NetworkAttributesContract.COLNAME_L2KEY, l2key, needWipe);
619     }
620 
621     /**
622      * Delete all entries that have a particular cluster value.
623      *
624      * If |needWipe| is true, the data will be wiped from disk immediately. Otherwise, it will
625      * only be marked deleted, and overwritten by subsequent writes or reclaimed during the next
626      * maintenance window.
627      * Note that wiping data is a very expensive operation. This is meant for clients that need
628      * this data gone from disk immediately for security reasons. Functionally it makes no
629      * difference at all.
630      */
deleteCluster(@onNull final SQLiteDatabase db, @NonNull final String cluster, final boolean needWipe)631     static StatusAndCount deleteCluster(@NonNull final SQLiteDatabase db,
632             @NonNull final String cluster, final boolean needWipe) {
633         return deleteEntriesWithColumn(db,
634                 NetworkAttributesContract.COLNAME_CLUSTER, cluster, needWipe);
635     }
636 
637     // Delete all entries where the given column has the given value.
deleteEntriesWithColumn(@onNull final SQLiteDatabase db, @NonNull final String column, @NonNull final String value, final boolean needWipe)638     private static StatusAndCount deleteEntriesWithColumn(@NonNull final SQLiteDatabase db,
639             @NonNull final String column, @NonNull final String value, final boolean needWipe) {
640         db.beginTransaction();
641         int deleted = 0;
642         try {
643             deleted = db.delete(NetworkAttributesContract.TABLENAME,
644                     column + "= ?", new String[] { value });
645             db.setTransactionSuccessful();
646         } catch (SQLiteException e) {
647             Log.e(TAG, "Could not delete from the memory store", e);
648             // Unclear what might have happened ; deleting records is not supposed to be able
649             // to fail barring a syntax error in the SQL query.
650             return new StatusAndCount(Status.ERROR_UNKNOWN, 0);
651         } finally {
652             db.endTransaction();
653         }
654 
655         if (needWipe) {
656             final int vacuumStatus = vacuum(db);
657             // This is a problem for the client : return the failure
658             if (Status.SUCCESS != vacuumStatus) return new StatusAndCount(vacuumStatus, deleted);
659         }
660         return new StatusAndCount(Status.SUCCESS, deleted);
661     }
662 
663     // Drops all records that are expired. Relevance has decayed to zero of these records. Returns
664     // an int out of Status.{SUCCESS, ERROR_*}
dropAllExpiredRecords(@onNull final SQLiteDatabase db)665     static int dropAllExpiredRecords(@NonNull final SQLiteDatabase db) {
666         db.beginTransaction();
667         try {
668             // Deletes NetworkAttributes that have expired.
669             db.delete(NetworkAttributesContract.TABLENAME,
670                     NetworkAttributesContract.COLNAME_EXPIRYDATE + " < ?",
671                     new String[]{Long.toString(System.currentTimeMillis())});
672             db.setTransactionSuccessful();
673         } catch (SQLiteException e) {
674             Log.e(TAG, "Could not delete data from memory store", e);
675             return Status.ERROR_STORAGE;
676         } finally {
677             db.endTransaction();
678         }
679 
680         // Execute vacuuming here if above operation has no exception. If above operation got
681         // exception, vacuuming can be ignored for reducing unnecessary consumption.
682         try {
683             db.execSQL("VACUUM");
684         } catch (SQLiteException e) {
685             // Do nothing.
686         }
687         return Status.SUCCESS;
688     }
689 
690     // Drops number of records that start from the lowest expiryDate. Returns an int out of
691     // Status.{SUCCESS, ERROR_*}
dropNumberOfRecords(@onNull final SQLiteDatabase db, int number)692     static int dropNumberOfRecords(@NonNull final SQLiteDatabase db, int number) {
693         if (number <= 0) {
694             return Status.ERROR_ILLEGAL_ARGUMENT;
695         }
696 
697         // Queries number of NetworkAttributes that start from the lowest expiryDate.
698         final long expiryDate;
699         try (Cursor cursor = db.query(NetworkAttributesContract.TABLENAME,
700                 new String[] {NetworkAttributesContract.COLNAME_EXPIRYDATE}, // columns
701                 null, // selection
702                 null, // selectionArgs
703                 null, // groupBy
704                 null, // having
705                 NetworkAttributesContract.COLNAME_EXPIRYDATE, // orderBy
706                 Integer.toString(number))) { // limit
707             if (cursor == null || cursor.getCount() <= 0) return Status.ERROR_GENERIC;
708             cursor.moveToLast();
709 
710             // Get the expiryDate from last record.
711             expiryDate = getLong(cursor, NetworkAttributesContract.COLNAME_EXPIRYDATE, 0);
712         }
713 
714         db.beginTransaction();
715         try {
716             // Deletes NetworkAttributes that expiryDate are lower than given value.
717             db.delete(NetworkAttributesContract.TABLENAME,
718                     NetworkAttributesContract.COLNAME_EXPIRYDATE + " <= ?",
719                     new String[]{Long.toString(expiryDate)});
720             db.setTransactionSuccessful();
721         } catch (SQLiteException e) {
722             Log.e(TAG, "Could not delete data from memory store", e);
723             return Status.ERROR_STORAGE;
724         } finally {
725             db.endTransaction();
726         }
727 
728         // Execute vacuuming here if above operation has no exception. If above operation got
729         // exception, vacuuming can be ignored for reducing unnecessary consumption.
730         try {
731             db.execSQL("VACUUM");
732         } catch (SQLiteException e) {
733             // Do nothing.
734         }
735         return Status.SUCCESS;
736     }
737 
getTotalRecordNumber(@onNull final SQLiteDatabase db)738     static int getTotalRecordNumber(@NonNull final SQLiteDatabase db) {
739         // Query the total number of NetworkAttributes
740         try (Cursor cursor = db.query(NetworkAttributesContract.TABLENAME,
741                 new String[] {"COUNT(*)"}, // columns
742                 null, // selection
743                 null, // selectionArgs
744                 null, // groupBy
745                 null, // having
746                 null)) { // orderBy
747             cursor.moveToFirst();
748             return cursor == null ? 0 : cursor.getInt(0);
749         }
750     }
751 
752     // Helper methods
getString(final Cursor cursor, final String columnName)753     private static String getString(final Cursor cursor, final String columnName) {
754         final int columnIndex = cursor.getColumnIndex(columnName);
755         return (columnIndex >= 0) ? cursor.getString(columnIndex) : null;
756     }
getBlob(final Cursor cursor, final String columnName)757     private static byte[] getBlob(final Cursor cursor, final String columnName) {
758         final int columnIndex = cursor.getColumnIndex(columnName);
759         return (columnIndex >= 0) ? cursor.getBlob(columnIndex) : null;
760     }
getInt(final Cursor cursor, final String columnName, final int defaultValue)761     private static int getInt(final Cursor cursor, final String columnName,
762             final int defaultValue) {
763         final int columnIndex = cursor.getColumnIndex(columnName);
764         return (columnIndex >= 0) ? cursor.getInt(columnIndex) : defaultValue;
765     }
getLong(final Cursor cursor, final String columnName, final long defaultValue)766     private static long getLong(final Cursor cursor, final String columnName,
767             final long defaultValue) {
768         final int columnIndex = cursor.getColumnIndex(columnName);
769         return (columnIndex >= 0) ? cursor.getLong(columnIndex) : defaultValue;
770     }
vacuum(@onNull final SQLiteDatabase db)771     private static int vacuum(@NonNull final SQLiteDatabase db) {
772         try {
773             db.execSQL("VACUUM");
774             return Status.SUCCESS;
775         } catch (SQLiteException e) {
776             // Vacuuming may fail from lack of storage, because it makes a copy of the database.
777             return Status.ERROR_STORAGE;
778         }
779     }
780 }
781