1 /*
2  * Copyright (C) 2018 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.dialer.speeddial.database;
18 
19 import android.content.ContentValues;
20 import android.content.Context;
21 import android.database.Cursor;
22 import android.database.sqlite.SQLiteDatabase;
23 import android.database.sqlite.SQLiteOpenHelper;
24 import android.text.TextUtils;
25 import com.android.dialer.common.Assert;
26 import com.android.dialer.common.database.Selection;
27 import com.android.dialer.speeddial.database.SpeedDialEntry.Channel;
28 import com.google.common.base.Optional;
29 import com.google.common.collect.ImmutableList;
30 import com.google.common.collect.ImmutableMap;
31 import java.util.ArrayList;
32 import java.util.List;
33 
34 /**
35  * {@link SpeedDialEntryDao} implemented as an SQLite database.
36  *
37  * @see SpeedDialEntryDao
38  */
39 public final class SpeedDialEntryDatabaseHelper extends SQLiteOpenHelper
40     implements SpeedDialEntryDao {
41 
42   /**
43    * If the pinned position is absent, then we need to write an impossible value in the table like
44    * -1 so that it doesn't default to 0. When we read this value from the table, we'll translate it
45    * to Optional.absent() in the resulting {@link SpeedDialEntry}.
46    */
47   private static final int PINNED_POSITION_ABSENT = -1;
48 
49   private static final int DATABASE_VERSION = 2;
50   private static final String DATABASE_NAME = "CPSpeedDialEntry";
51 
52   // Column names
53   private static final String TABLE_NAME = "speed_dial_entries";
54   private static final String ID = "id";
55   private static final String PINNED_POSITION = "pinned_position";
56   private static final String CONTACT_ID = "contact_id";
57   private static final String LOOKUP_KEY = "lookup_key";
58   private static final String PHONE_NUMBER = "phone_number";
59   private static final String PHONE_TYPE = "phone_type";
60   private static final String PHONE_LABEL = "phone_label";
61   private static final String PHONE_TECHNOLOGY = "phone_technology";
62 
63   // Column positions
64   private static final int POSITION_ID = 0;
65   private static final int POSITION_PINNED_POSITION = 1;
66   private static final int POSITION_CONTACT_ID = 2;
67   private static final int POSITION_LOOKUP_KEY = 3;
68   private static final int POSITION_PHONE_NUMBER = 4;
69   private static final int POSITION_PHONE_TYPE = 5;
70   private static final int POSITION_PHONE_LABEL = 6;
71   private static final int POSITION_PHONE_TECHNOLOGY = 7;
72 
73   // Create Table Query
74   private static final String CREATE_TABLE_SQL =
75       "create table if not exists "
76           + TABLE_NAME
77           + " ("
78           + (ID + " integer primary key, ")
79           + (PINNED_POSITION + " integer, ")
80           + (CONTACT_ID + " integer, ")
81           + (LOOKUP_KEY + " text, ")
82           + (PHONE_NUMBER + " text, ")
83           + (PHONE_TYPE + " integer, ")
84           + (PHONE_LABEL + " text, ")
85           + (PHONE_TECHNOLOGY + " integer ")
86           + ");";
87 
88   private static final String DELETE_TABLE_SQL = "drop table if exists " + TABLE_NAME;
89 
SpeedDialEntryDatabaseHelper(Context context)90   public SpeedDialEntryDatabaseHelper(Context context) {
91     super(context, DATABASE_NAME, null, DATABASE_VERSION);
92   }
93 
94   @Override
onCreate(SQLiteDatabase db)95   public void onCreate(SQLiteDatabase db) {
96     db.execSQL(CREATE_TABLE_SQL);
97   }
98 
99   @Override
onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)100   public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
101     // TODO(calderwoodra): handle upgrades more elegantly
102     db.execSQL(DELETE_TABLE_SQL);
103     this.onCreate(db);
104   }
105 
106   @Override
onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion)107   public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
108     // TODO(calderwoodra): handle upgrades more elegantly
109     this.onUpgrade(db, oldVersion, newVersion);
110   }
111 
112   @Override
getAllEntries()113   public ImmutableList<SpeedDialEntry> getAllEntries() {
114     List<SpeedDialEntry> entries = new ArrayList<>();
115 
116     String query = "SELECT * FROM " + TABLE_NAME;
117     try (SQLiteDatabase db = getReadableDatabase();
118         Cursor cursor = db.rawQuery(query, null)) {
119       cursor.moveToPosition(-1);
120       while (cursor.moveToNext()) {
121         String number = cursor.getString(POSITION_PHONE_NUMBER);
122         Channel channel = null;
123         if (!TextUtils.isEmpty(number)) {
124           channel =
125               Channel.builder()
126                   .setNumber(number)
127                   .setPhoneType(cursor.getInt(POSITION_PHONE_TYPE))
128                   .setLabel(Optional.of(cursor.getString(POSITION_PHONE_LABEL)).or(""))
129                   .setTechnology(cursor.getInt(POSITION_PHONE_TECHNOLOGY))
130                   .build();
131         }
132 
133         Optional<Integer> pinnedPosition = Optional.of(cursor.getInt(POSITION_PINNED_POSITION));
134         if (pinnedPosition.or(PINNED_POSITION_ABSENT) == PINNED_POSITION_ABSENT) {
135           pinnedPosition = Optional.absent();
136         }
137 
138         SpeedDialEntry entry =
139             SpeedDialEntry.builder()
140                 .setDefaultChannel(channel)
141                 .setContactId(cursor.getLong(POSITION_CONTACT_ID))
142                 .setLookupKey(cursor.getString(POSITION_LOOKUP_KEY))
143                 .setPinnedPosition(pinnedPosition)
144                 .setId(cursor.getLong(POSITION_ID))
145                 .build();
146         entries.add(entry);
147       }
148     }
149     return ImmutableList.copyOf(entries);
150   }
151 
152   @Override
insert(ImmutableList<SpeedDialEntry> entries)153   public ImmutableMap<SpeedDialEntry, Long> insert(ImmutableList<SpeedDialEntry> entries) {
154     if (entries.isEmpty()) {
155       return ImmutableMap.of();
156     }
157 
158     SQLiteDatabase db = getWritableDatabase();
159     db.beginTransaction();
160     try {
161       ImmutableMap<SpeedDialEntry, Long> insertedEntriesToIdsMap = insert(db, entries);
162       db.setTransactionSuccessful();
163       return insertedEntriesToIdsMap;
164     } finally {
165       db.endTransaction();
166       db.close();
167     }
168   }
169 
insert( SQLiteDatabase writeableDatabase, ImmutableList<SpeedDialEntry> entries)170   private ImmutableMap<SpeedDialEntry, Long> insert(
171       SQLiteDatabase writeableDatabase, ImmutableList<SpeedDialEntry> entries) {
172     ImmutableMap.Builder<SpeedDialEntry, Long> insertedEntriesToIdsMap = ImmutableMap.builder();
173     for (SpeedDialEntry entry : entries) {
174       Assert.checkArgument(entry.id() == null);
175       long id = writeableDatabase.insert(TABLE_NAME, null, buildContentValuesWithoutId(entry));
176       if (id == -1L) {
177         throw Assert.createUnsupportedOperationFailException(
178             "Attempted to insert a row that already exists.");
179       }
180       // It's impossible to insert two identical entries but this is an important assumption we need
181       // to verify because there's an assumption that each entry will correspond to exactly one id.
182       // ImmutableMap#put verifies this check for us.
183       insertedEntriesToIdsMap.put(entry, id);
184     }
185     return insertedEntriesToIdsMap.build();
186   }
187 
188   @Override
insert(SpeedDialEntry entry)189   public long insert(SpeedDialEntry entry) {
190     long updateRowId;
191     try (SQLiteDatabase db = getWritableDatabase()) {
192       updateRowId = db.insert(TABLE_NAME, null, buildContentValuesWithoutId(entry));
193     }
194     if (updateRowId == -1) {
195       throw Assert.createUnsupportedOperationFailException(
196           "Attempted to insert a row that already exists.");
197     }
198     return updateRowId;
199   }
200 
201   @Override
update(ImmutableList<SpeedDialEntry> entries)202   public void update(ImmutableList<SpeedDialEntry> entries) {
203     if (entries.isEmpty()) {
204       return;
205     }
206 
207     SQLiteDatabase db = getWritableDatabase();
208     db.beginTransaction();
209     try {
210       update(db, entries);
211       db.setTransactionSuccessful();
212     } finally {
213       db.endTransaction();
214       db.close();
215     }
216   }
217 
update(SQLiteDatabase writeableDatabase, ImmutableList<SpeedDialEntry> entries)218   private void update(SQLiteDatabase writeableDatabase, ImmutableList<SpeedDialEntry> entries) {
219     for (SpeedDialEntry entry : entries) {
220       int count =
221           writeableDatabase.update(
222               TABLE_NAME,
223               buildContentValuesWithId(entry),
224               ID + " = ?",
225               new String[] {Long.toString(entry.id())});
226       if (count != 1) {
227         throw Assert.createUnsupportedOperationFailException(
228             "Attempted to update an undetermined number of rows: " + count);
229       }
230     }
231   }
232 
buildContentValuesWithId(SpeedDialEntry entry)233   private ContentValues buildContentValuesWithId(SpeedDialEntry entry) {
234     return buildContentValues(entry, true);
235   }
236 
buildContentValuesWithoutId(SpeedDialEntry entry)237   private ContentValues buildContentValuesWithoutId(SpeedDialEntry entry) {
238     return buildContentValues(entry, false);
239   }
240 
buildContentValues(SpeedDialEntry entry, boolean includeId)241   private ContentValues buildContentValues(SpeedDialEntry entry, boolean includeId) {
242     ContentValues values = new ContentValues();
243     if (includeId) {
244       values.put(ID, entry.id());
245     }
246     values.put(PINNED_POSITION, entry.pinnedPosition().or(PINNED_POSITION_ABSENT));
247     values.put(CONTACT_ID, entry.contactId());
248     values.put(LOOKUP_KEY, entry.lookupKey());
249     if (entry.defaultChannel() != null) {
250       values.put(PHONE_NUMBER, entry.defaultChannel().number());
251       values.put(PHONE_TYPE, entry.defaultChannel().phoneType());
252       values.put(PHONE_LABEL, entry.defaultChannel().label());
253       values.put(PHONE_TECHNOLOGY, entry.defaultChannel().technology());
254     }
255     return values;
256   }
257 
258   @Override
delete(ImmutableList<Long> ids)259   public void delete(ImmutableList<Long> ids) {
260     if (ids.isEmpty()) {
261       return;
262     }
263 
264     try (SQLiteDatabase db = getWritableDatabase()) {
265       delete(db, ids);
266     }
267   }
268 
delete(SQLiteDatabase writeableDatabase, ImmutableList<Long> ids)269   private void delete(SQLiteDatabase writeableDatabase, ImmutableList<Long> ids) {
270     List<String> idStrings = new ArrayList<>();
271     for (Long id : ids) {
272       idStrings.add(Long.toString(id));
273     }
274 
275     Selection selection = Selection.builder().and(Selection.column(ID).in(idStrings)).build();
276     int count =
277         writeableDatabase.delete(
278             TABLE_NAME, selection.getSelection(), selection.getSelectionArgs());
279     if (count != ids.size()) {
280       throw Assert.createUnsupportedOperationFailException(
281           "Attempted to delete an undetermined number of rows: " + count);
282     }
283   }
284 
285   @Override
insertUpdateAndDelete( ImmutableList<SpeedDialEntry> entriesToInsert, ImmutableList<SpeedDialEntry> entriesToUpdate, ImmutableList<Long> entriesToDelete)286   public ImmutableMap<SpeedDialEntry, Long> insertUpdateAndDelete(
287       ImmutableList<SpeedDialEntry> entriesToInsert,
288       ImmutableList<SpeedDialEntry> entriesToUpdate,
289       ImmutableList<Long> entriesToDelete) {
290     if (entriesToInsert.isEmpty() && entriesToUpdate.isEmpty() && entriesToDelete.isEmpty()) {
291       return ImmutableMap.of();
292     }
293     SQLiteDatabase db = getWritableDatabase();
294     db.beginTransaction();
295     try {
296       ImmutableMap<SpeedDialEntry, Long> insertedEntriesToIdsMap = insert(db, entriesToInsert);
297       update(db, entriesToUpdate);
298       delete(db, entriesToDelete);
299       db.setTransactionSuccessful();
300       return insertedEntriesToIdsMap;
301     } finally {
302       db.endTransaction();
303       db.close();
304     }
305   }
306 
307   @Override
deleteAll()308   public void deleteAll() {
309     SQLiteDatabase db = getWritableDatabase();
310     db.beginTransaction();
311     try {
312       // Passing null into where clause will delete all rows
313       db.delete(TABLE_NAME, /* whereClause=*/ null, null);
314       db.setTransactionSuccessful();
315     } finally {
316       db.endTransaction();
317       db.close();
318     }
319   }
320 }
321