1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4 
5 package org.mozilla.gecko.sync.repositories.android;
6 
7 import android.content.ContentProviderClient;
8 import android.content.Context;
9 import android.database.Cursor;
10 import android.database.sqlite.SQLiteDatabase;
11 import android.net.Uri;
12 import android.os.RemoteException;
13 
14 import org.json.simple.JSONArray;
15 import org.mozilla.gecko.background.common.log.Logger;
16 import org.mozilla.gecko.db.BrowserContract;
17 import org.mozilla.gecko.sync.ExtendedJSONObject;
18 import org.mozilla.gecko.sync.NonArrayJSONException;
19 import org.mozilla.gecko.sync.repositories.NullCursorException;
20 import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
21 import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
22 
23 import java.io.IOException;
24 
25 public class RepoUtils {
26 
27   private static final String LOG_TAG = "RepoUtils";
28 
29   /**
30    * A helper class for monotonous SQL querying. Does timing and logging,
31    * offers a utility to throw on a null cursor.
32    *
33    * @author rnewman
34    *
35    */
36   public static class QueryHelper {
37     private final Context context;
38     private final Uri     uri;
39     private final String  tag;
40 
QueryHelper(Context context, Uri uri, String tag)41     public QueryHelper(Context context, Uri uri, String tag) {
42       this.context = context;
43       this.uri     = uri;
44       this.tag     = tag;
45     }
46 
47     // For ContentProvider queries.
safeQuery(String label, String[] projection, String selection, String[] selectionArgs, String sortOrder)48     public Cursor safeQuery(String label, String[] projection,
49                             String selection, String[] selectionArgs, String sortOrder) throws NullCursorException {
50       long queryStart = android.os.SystemClock.uptimeMillis();
51       Cursor c = context.getContentResolver().query(uri, projection, selection, selectionArgs, sortOrder);
52       return checkAndLogCursor(label, queryStart, c);
53     }
54 
safeQuery(String[] projection, String selection, String[] selectionArgs, String sortOrder)55     public Cursor safeQuery(String[] projection, String selection, String[] selectionArgs, String sortOrder) throws NullCursorException {
56       return this.safeQuery(null, projection, selection, selectionArgs, sortOrder);
57     }
58 
59     // For ContentProviderClient queries.
safeQuery(ContentProviderClient client, String label, String[] projection, String selection, String[] selectionArgs, String sortOrder)60     public Cursor safeQuery(ContentProviderClient client, String label, String[] projection,
61                             String selection, String[] selectionArgs, String sortOrder) throws NullCursorException, RemoteException {
62       long queryStart = android.os.SystemClock.uptimeMillis();
63       Cursor c = client.query(uri, projection, selection, selectionArgs, sortOrder);
64       return checkAndLogCursor(label, queryStart, c);
65     }
66 
67     // For SQLiteOpenHelper queries.
safeQuery(SQLiteDatabase db, String label, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit)68     public Cursor safeQuery(SQLiteDatabase db, String label, String table, String[] columns,
69                             String selection, String[] selectionArgs,
70                             String groupBy, String having, String orderBy, String limit) throws NullCursorException {
71       long queryStart = android.os.SystemClock.uptimeMillis();
72       Cursor c = db.query(table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
73       return checkAndLogCursor(label, queryStart, c);
74     }
75 
safeQuery(SQLiteDatabase db, String label, String table, String[] columns, String selection, String[] selectionArgs)76     public Cursor safeQuery(SQLiteDatabase db, String label, String table, String[] columns,
77                             String selection, String[] selectionArgs) throws NullCursorException {
78       return safeQuery(db, label, table, columns, selection, selectionArgs, null, null, null, null);
79     }
80 
checkAndLogCursor(String label, long queryStart, Cursor c)81     private Cursor checkAndLogCursor(String label, long queryStart, Cursor c) throws NullCursorException {
82       long queryEnd = android.os.SystemClock.uptimeMillis();
83       String logLabel = (label == null) ? tag : (tag + label);
84       RepoUtils.queryTimeLogger(logLabel, queryStart, queryEnd);
85       return checkNullCursor(logLabel, c);
86     }
87 
checkNullCursor(String logLabel, Cursor cursor)88     public Cursor checkNullCursor(String logLabel, Cursor cursor) throws NullCursorException {
89       if (cursor == null) {
90         Logger.error(tag, "Got null cursor exception in " + logLabel);
91         throw new NullCursorException(null);
92       }
93       return cursor;
94     }
95   }
96 
97   /**
98    * This method exists because the behavior of <code>cur.getString()</code> is undefined
99    * when the value in the database is <code>NULL</code>.
100    * This method will return <code>null</code> in that case.
101    */
optStringFromCursor(final Cursor cur, final String colId)102   public static String optStringFromCursor(final Cursor cur, final String colId) {
103     final int col = cur.getColumnIndex(colId);
104     if (cur.isNull(col)) {
105       return null;
106     }
107     return cur.getString(col);
108   }
109 
110   /**
111    * The behavior of this method when the value in the database is <code>NULL</code> is
112    * determined by the implementation of the {@link Cursor}.
113    */
getStringFromCursor(final Cursor cur, final String colId)114   public static String getStringFromCursor(final Cursor cur, final String colId) {
115     // TODO: getColumnIndexOrThrow?
116     // TODO: don't look up columns by name!
117     return cur.getString(cur.getColumnIndex(colId));
118   }
119 
getLongFromCursor(Cursor cur, String colId)120   public static long getLongFromCursor(Cursor cur, String colId) {
121     return cur.getLong(cur.getColumnIndex(colId));
122   }
123 
getIntFromCursor(Cursor cur, String colId)124   public static int getIntFromCursor(Cursor cur, String colId) {
125     return cur.getInt(cur.getColumnIndex(colId));
126   }
127 
getJSONArrayFromCursor(Cursor cur, String colId)128   public static JSONArray getJSONArrayFromCursor(Cursor cur, String colId) {
129     String jsonArrayAsString = getStringFromCursor(cur, colId);
130     if (jsonArrayAsString == null) {
131       return new JSONArray();
132     }
133     try {
134       return ExtendedJSONObject.parseJSONArray(getStringFromCursor(cur, colId));
135     } catch (NonArrayJSONException e) {
136       Logger.error(LOG_TAG, "JSON parsing error for " + colId, e);
137       return null;
138     } catch (IOException e) {
139       Logger.error(LOG_TAG, "JSON parsing error for " + colId, e);
140       return null;
141     }
142   }
143 
144   /**
145    * Return true if the provided URI is non-empty and acceptable to Fennec
146    * (i.e., not an undesirable scheme).
147    *
148    * This code is pilfered from Fennec, which pilfered from Places.
149    */
isValidHistoryURI(String uri)150   public static boolean isValidHistoryURI(String uri) {
151     if (uri == null || uri.length() == 0) {
152       return false;
153     }
154 
155     // First, check the most common cases (HTTP, HTTPS) to avoid most of the work.
156     if (uri.startsWith("http:") || uri.startsWith("https:")) {
157       return true;
158     }
159 
160     String scheme = Uri.parse(uri).getScheme();
161     if (scheme == null) {
162       return false;
163     }
164 
165     // Now check for all bad things.
166     if (scheme.equals("about") ||
167         scheme.equals("imap") ||
168         scheme.equals("news") ||
169         scheme.equals("mailbox") ||
170         scheme.equals("moz-anno") ||
171         scheme.equals("view-source") ||
172         scheme.equals("chrome") ||
173         scheme.equals("resource") ||
174         scheme.equals("data") ||
175         scheme.equals("wyciwyg") ||
176         scheme.equals("javascript")) {
177       return false;
178     }
179 
180     return true;
181   }
182 
183   /**
184    * Create a HistoryRecord object from a cursor row.
185    *
186    * @return a HistoryRecord, or null if this row would produce
187    *         an invalid record (e.g., with a null URI or no visits).
188    */
historyFromMirrorCursor(Cursor cur)189   public static HistoryRecord historyFromMirrorCursor(Cursor cur) {
190     final String guid = getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
191     if (guid == null) {
192       Logger.debug(LOG_TAG, "Skipping history record with null GUID.");
193       return null;
194     }
195 
196     final String historyURI = getStringFromCursor(cur, BrowserContract.History.URL);
197     if (!isValidHistoryURI(historyURI)) {
198       Logger.debug(LOG_TAG, "Skipping history record " + guid + " with unwanted/invalid URI " + historyURI);
199       return null;
200     }
201 
202     final long visitCount = getLongFromCursor(cur, BrowserContract.History.VISITS);
203     if (visitCount <= 0) {
204       Logger.debug(LOG_TAG, "Skipping history record " + guid + " with <= 0 visit count.");
205       return null;
206     }
207 
208     final String collection = "history";
209     final long lastModified = getLongFromCursor(cur, BrowserContract.SyncColumns.DATE_MODIFIED);
210     final boolean deleted = getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1;
211 
212     final HistoryRecord rec = new HistoryRecord(guid, collection, lastModified, deleted);
213 
214     rec.androidID         = getLongFromCursor(cur, BrowserContract.History._ID);
215     rec.fennecDateVisited = getLongFromCursor(cur, BrowserContract.History.DATE_LAST_VISITED);
216     rec.fennecVisitCount  = visitCount;
217     rec.histURI           = historyURI;
218     rec.title             = getStringFromCursor(cur, BrowserContract.History.TITLE);
219 
220     return logHistory(rec);
221   }
222 
logHistory(HistoryRecord rec)223   private static HistoryRecord logHistory(HistoryRecord rec) {
224     try {
225       Logger.debug(LOG_TAG, "Returning history record " + rec.guid + " (" + rec.androidID + ")");
226       Logger.debug(LOG_TAG, "> Visited:          " + rec.fennecDateVisited);
227       Logger.debug(LOG_TAG, "> Visits:           " + rec.fennecVisitCount);
228       if (Logger.LOG_PERSONAL_INFORMATION) {
229         Logger.pii(LOG_TAG, "> Title:            " + rec.title);
230         Logger.pii(LOG_TAG, "> URI:              " + rec.histURI);
231       }
232     } catch (Exception e) {
233       Logger.debug(LOG_TAG, "Exception logging history record " + rec, e);
234     }
235     return rec;
236   }
237 
logClient(ClientRecord rec)238   public static void logClient(ClientRecord rec) {
239     if (Logger.shouldLogVerbose(LOG_TAG)) {
240       Logger.trace(LOG_TAG, "Returning client record " + rec.guid + " (" + rec.androidID + ")");
241       Logger.trace(LOG_TAG, "Client Name:   " + rec.name);
242       Logger.trace(LOG_TAG, "Client Type:   " + rec.type);
243       Logger.trace(LOG_TAG, "Last Modified: " + rec.lastModified);
244       Logger.trace(LOG_TAG, "Deleted:       " + rec.deleted);
245     }
246   }
247 
queryTimeLogger(String methodCallingQuery, long queryStart, long queryEnd)248   public static void queryTimeLogger(String methodCallingQuery, long queryStart, long queryEnd) {
249     long elapsedTime = queryEnd - queryStart;
250     Logger.debug(LOG_TAG, "Query timer: " + methodCallingQuery + " took " + elapsedTime + "ms.");
251   }
252 
stringsEqual(String a, String b)253   public static boolean stringsEqual(String a, String b) {
254     // Check for nulls
255     if (a == b) return true;
256     if (a == null && b != null) return false;
257     if (a != null && b == null) return false;
258 
259     return a.equals(b);
260   }
261 
computeSQLLongInClause(long[] items, String field)262   public static String computeSQLLongInClause(long[] items, String field) {
263     final StringBuilder builder = new StringBuilder(field);
264     builder.append(" IN (");
265     int i = 0;
266     for (; i < items.length - 1; ++i) {
267       builder.append(items[i]);
268       builder.append(", ");
269     }
270     if (i < items.length) {
271       builder.append(items[i]);
272     }
273     builder.append(")");
274     return builder.toString();
275   }
276 
computeSQLInClause(int items, String field)277   public static String computeSQLInClause(int items, String field) {
278     final StringBuilder builder = new StringBuilder(field);
279     builder.append(" IN (");
280     int i = 0;
281     for (; i < items - 1; ++i) {
282       builder.append("?, ");
283     }
284     if (i < items) {
285       builder.append("?");
286     }
287     builder.append(")");
288     return builder.toString();
289   }
290 }
291