Enforce AnnotatedCallLog column constraints in its content provider.

Bug: 110185399
Test: AnnotatedCallLogConstraintsTest, AnnotatedCallLogContentProviderTest
PiperOrigin-RevId: 201444134
Change-Id: I105ec7a201265ee5e7708ffb9a4b66b4cef01174
diff --git a/java/com/android/dialer/calllog/database/AnnotatedCallLogConstraints.java b/java/com/android/dialer/calllog/database/AnnotatedCallLogConstraints.java
new file mode 100644
index 0000000..3dc8514
--- /dev/null
+++ b/java/com/android/dialer/calllog/database/AnnotatedCallLogConstraints.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.dialer.calllog.database;
+
+import android.content.ContentValues;
+import android.provider.CallLog.Calls;
+import android.support.annotation.IntDef;
+import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog;
+import com.android.dialer.common.Assert;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.function.Predicate;
+
+/** Constraints for columns in the {@link AnnotatedCallLog}. */
+final class AnnotatedCallLogConstraints {
+
+  /** Type of operation the {@link ContentValues} to be checked is used for. */
+  @Retention(RetentionPolicy.SOURCE)
+  @IntDef({Operation.INSERT, Operation.UPDATE})
+  @interface Operation {
+    int INSERT = 1;
+    int UPDATE = 2;
+  }
+
+  private AnnotatedCallLogConstraints() {}
+
+  /**
+   * Checks if the given {@link ContentValues} meets the constraints defined in this class. An
+   * {@link IllegalArgumentException} will be thrown if it doesn't.
+   */
+  public static void check(ContentValues contentValues, @Operation int operationType) {
+    checkBooleanColumn(AnnotatedCallLog.IS_READ, contentValues, operationType);
+    checkBooleanColumn(AnnotatedCallLog.NEW, contentValues, operationType);
+    checkBooleanColumn(AnnotatedCallLog.IS_VOICEMAIL_CALL, contentValues, operationType);
+    checkCallTypeColumn(contentValues, operationType);
+  }
+
+  /**
+   * Checks a boolean column.
+   *
+   * <p>Constraints: the value must be either 0 or 1 (SQLite database has no boolean type, so the
+   * value has to be an integer).
+   */
+  private static void checkBooleanColumn(
+      String columnName, ContentValues contentValues, @Operation int operationType) {
+    checkColumn(
+        columnName,
+        contentValues,
+        operationType,
+        contentValuesToCheck -> {
+          Integer value = contentValuesToCheck.getAsInteger(columnName);
+          return value != null && (value == 0 || value == 1);
+        });
+  }
+
+  /**
+   * Checks column {@link AnnotatedCallLog#CALL_TYPE}.
+   *
+   * <p>Constraints: the value must be one of {@link android.provider.CallLog.Calls#TYPE}.
+   */
+  private static void checkCallTypeColumn(
+      ContentValues contentValues, @Operation int operationType) {
+    checkColumn(
+        AnnotatedCallLog.CALL_TYPE,
+        contentValues,
+        operationType,
+        contentValuesToCheck -> {
+          Integer callType = contentValuesToCheck.getAsInteger(AnnotatedCallLog.CALL_TYPE);
+          return callType != null
+              && (callType == Calls.INCOMING_TYPE
+                  || callType == Calls.OUTGOING_TYPE
+                  || callType == Calls.MISSED_TYPE
+                  || callType == Calls.VOICEMAIL_TYPE
+                  || callType == Calls.REJECTED_TYPE
+                  || callType == Calls.BLOCKED_TYPE
+                  || callType == Calls.ANSWERED_EXTERNALLY_TYPE);
+        });
+  }
+
+  private static void checkColumn(
+      String columnName,
+      ContentValues contentValues,
+      @Operation int operationType,
+      Predicate<ContentValues> predicate) {
+    switch (operationType) {
+      case Operation.UPDATE:
+        if (!contentValues.containsKey(columnName)) {
+          return;
+        }
+        // fall through
+      case Operation.INSERT:
+        Assert.checkArgument(
+            predicate.test(contentValues),
+            "Column %s contains invalid value: %s",
+            columnName,
+            contentValues.get(columnName));
+        return;
+      default:
+        throw Assert.createUnsupportedOperationFailException(
+            String.format("Unsupported operation: %s", operationType));
+    }
+  }
+}
diff --git a/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java b/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java
index 3ca76ee..36fa5ff 100644
--- a/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java
+++ b/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java
@@ -31,6 +31,7 @@
 import android.os.Build;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
+import com.android.dialer.calllog.database.AnnotatedCallLogConstraints.Operation;
 import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract;
 import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog;
 import com.android.dialer.common.Assert;
@@ -41,8 +42,6 @@
 /** {@link ContentProvider} for the annotated call log. */
 public class AnnotatedCallLogContentProvider extends ContentProvider {
 
-
-
   private static final int ANNOTATED_CALL_LOG_TABLE_CODE = 1;
   private static final int ANNOTATED_CALL_LOG_TABLE_ID_CODE = 2;
   private static final int ANNOTATED_CALL_LOG_TABLE_DISTINCT_NUMBER_CODE = 3;
@@ -150,6 +149,8 @@
     // Javadoc states values is not nullable, even though it is annotated as such (a bug)!
     Assert.checkArgument(values != null);
 
+    AnnotatedCallLogConstraints.check(values, Operation.INSERT);
+
     SQLiteDatabase database = databaseHelper.getWritableDatabase();
     int match = uriMatcher.match(uri);
     switch (match) {
@@ -230,6 +231,8 @@
     // Javadoc states values is not nullable, even though it is annotated as such (a bug)!
     Assert.checkArgument(values != null);
 
+    AnnotatedCallLogConstraints.check(values, Operation.UPDATE);
+
     SQLiteDatabase database = databaseHelper.getWritableDatabase();
     int match = uriMatcher.match(uri);
     switch (match) {
diff --git a/java/com/android/dialer/calllog/database/AnnotatedCallLogDatabaseHelper.java b/java/com/android/dialer/calllog/database/AnnotatedCallLogDatabaseHelper.java
index 8fc80a6..0693c79 100644
--- a/java/com/android/dialer/calllog/database/AnnotatedCallLogDatabaseHelper.java
+++ b/java/com/android/dialer/calllog/database/AnnotatedCallLogDatabaseHelper.java
@@ -35,7 +35,7 @@
 @Singleton
 public class AnnotatedCallLogDatabaseHelper extends SQLiteOpenHelper {
 
-  @VisibleForTesting static final int VERSION = 3;
+  @VisibleForTesting static final int VERSION = 4;
 
   private static final String FILENAME = "annotated_call_log.db";
 
@@ -55,6 +55,18 @@
     this.backgroundExecutor = backgroundExecutor;
   }
 
+  /**
+   * Important note:
+   *
+   * <p>Do NOT modify/delete columns (e.g., adding constraints, changing column type, etc).
+   *
+   * <p>As SQLite's "ALTER TABLE" statement doesn't support such operations, doing so requires
+   * complex, expensive, and error-prone operations to upgrade the database (see
+   * https://www.sqlite.org/lang_altertable.html "Making Other Kinds Of Table Schema Changes").
+   *
+   * <p>All column constraints are enforced when data are inserted/updated via
+   * AnnotatedCallLogContentProvider. See AnnotatedCallLogConstraints for details.
+   */
   private static final String CREATE_TABLE_SQL =
       "create table if not exists "
           + AnnotatedCallLog.TABLE
@@ -66,17 +78,17 @@
           + (AnnotatedCallLog.NUMBER_PRESENTATION + " integer, ")
           + (AnnotatedCallLog.DURATION + " integer, ")
           + (AnnotatedCallLog.DATA_USAGE + " integer, ")
-          + (AnnotatedCallLog.IS_READ + " integer not null, ")
-          + (AnnotatedCallLog.NEW + " integer not null, ")
+          + (AnnotatedCallLog.IS_READ + " integer, ")
+          + (AnnotatedCallLog.NEW + " integer, ")
           + (AnnotatedCallLog.GEOCODED_LOCATION + " text, ")
           + (AnnotatedCallLog.PHONE_ACCOUNT_COMPONENT_NAME + " text, ")
           + (AnnotatedCallLog.PHONE_ACCOUNT_ID + " text, ")
           + (AnnotatedCallLog.FEATURES + " integer, ")
           + (AnnotatedCallLog.TRANSCRIPTION + " integer, ")
           + (AnnotatedCallLog.VOICEMAIL_URI + " text, ")
-          + (AnnotatedCallLog.CALL_TYPE + " integer not null, ")
+          + (AnnotatedCallLog.CALL_TYPE + " integer, ")
           + (AnnotatedCallLog.NUMBER_ATTRIBUTES + " blob, ")
-          + (AnnotatedCallLog.IS_VOICEMAIL_CALL + " integer default 0, ")
+          + (AnnotatedCallLog.IS_VOICEMAIL_CALL + " integer, ")
           + (AnnotatedCallLog.VOICEMAIL_CALL_TAG + " text, ")
           + (AnnotatedCallLog.TRANSCRIPTION_STATE + " integer, ")
           + (AnnotatedCallLog.CALL_MAPPING_ID + " text")
@@ -151,8 +163,11 @@
     if (oldVersion < 2) {
       upgradeToV2(db);
     }
-    if (oldVersion < 3) {
-      upgradeToV3(db);
+
+    // Version 3 upgrade was buggy and didn't make any schema changes.
+    // So we go directly to version 4.
+    if (oldVersion < 4) {
+      upgradeToV4(db);
     }
   }
 
@@ -172,7 +187,21 @@
             + AnnotatedCallLog.TIMESTAMP);
   }
 
-  private static void upgradeToV3(SQLiteDatabase db) {
+  private static void upgradeToV4(SQLiteDatabase db) {
+    // Starting from v4, we will enforce column constraints in the AnnotatedCallLogContentProvider
+    // instead of on the database level.
+    // The constraints are as follows (see AnnotatedCallLogConstraints for details).
+    //   IS_READ:           not null, must be 0 or 1;
+    //   NEW:               not null, must be 0 or 1;
+    //   IS_VOICEMAIL_CALL: not null, must be 0 or 1; and
+    //   CALL_TYPE:         not null, must be one of android.provider.CallLog.Calls#TYPE.
+    //
+    // There is no need to update the old schema as the constraints above are more strict than
+    // those in the old schema.
+    //
+    // Version 3 schema defaulted column IS_VOICEMAIL_CALL to 0 but we didn't update the schema in
+    // onUpgrade. As a result, null values can still be inserted if the user has an older version of
+    // the database. For version 4, we need to set all null values to 0.
     db.execSQL(
         "update "
             + AnnotatedCallLog.TABLE
diff --git a/java/com/android/dialer/calllog/datasources/voicemail/VoicemailDataSource.java b/java/com/android/dialer/calllog/datasources/voicemail/VoicemailDataSource.java
index cbda9ac..8d9cae2 100644
--- a/java/com/android/dialer/calllog/datasources/voicemail/VoicemailDataSource.java
+++ b/java/com/android/dialer/calllog/datasources/voicemail/VoicemailDataSource.java
@@ -63,8 +63,12 @@
   @SuppressWarnings("missingPermission")
   public ListenableFuture<Void> fill(CallLogMutations mutations) {
     if (!PermissionsUtil.hasReadPhoneStatePermissions(appContext)) {
+      for (Entry<Long, ContentValues> insert : mutations.getInserts().entrySet()) {
+        insert.getValue().put(AnnotatedCallLog.IS_VOICEMAIL_CALL, 0);
+      }
       return Futures.immediateFuture(null);
     }
+
     return backgroundExecutor.submit(
         () -> {
           TelecomManager telecomManager = appContext.getSystemService(TelecomManager.class);
@@ -90,6 +94,8 @@
                       appContext, phoneAccountHandle);
               values.put(
                   AnnotatedCallLog.VOICEMAIL_CALL_TAG, telephonyManager.getVoiceMailAlphaTag());
+            } else {
+              values.put(AnnotatedCallLog.IS_VOICEMAIL_CALL, 0);
             }
           }
           return null;