InvalidationTracker.java

/*
 * Copyright (C) 2017 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 androidx.room;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.os.Build;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.arch.core.internal.SafeIterableMap;
import androidx.lifecycle.LiveData;
import androidx.sqlite.db.SimpleSQLiteQuery;
import androidx.sqlite.db.SupportSQLiteDatabase;
import androidx.sqlite.db.SupportSQLiteStatement;

import java.lang.ref.WeakReference;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;

/**
 * InvalidationTracker keeps a list of tables modified by queries and notifies its callbacks about
 * these tables.
 */
// Some details on how the InvalidationTracker works:
// * An in memory table is created with (table_id, invalidated) table_id is a hardcoded int from
// initialization, while invalidated is a boolean bit to indicate if the table has been invalidated.
// * ObservedTableTracker tracks list of tables we should be watching (e.g. adding triggers for).
// * Before each beginTransaction, RoomDatabase invokes InvalidationTracker to sync trigger states.
// * After each endTransaction, RoomDatabase invokes InvalidationTracker to refresh invalidated
// tables.
// * Each update (write operation) on one of the observed tables triggers an update into the
// memory table table, flipping the invalidated flag ON.
// * When multi-instance invalidation is turned on, MultiInstanceInvalidationClient will be created.
// It works as an Observer, and notifies other instances of table invalidation.
public class InvalidationTracker {

    private static final String[] TRIGGERS = new String[]{"UPDATE", "DELETE", "INSERT"};

    private static final String UPDATE_TABLE_NAME = "room_table_modification_log";

    private static final String TABLE_ID_COLUMN_NAME = "table_id";

    private static final String INVALIDATED_COLUMN_NAME = "invalidated";

    private static final String CREATE_TRACKING_TABLE_SQL = "CREATE TEMP TABLE " + UPDATE_TABLE_NAME
            + "(" + TABLE_ID_COLUMN_NAME + " INTEGER PRIMARY KEY, "
            + INVALIDATED_COLUMN_NAME + " INTEGER NOT NULL DEFAULT 0)";

    @VisibleForTesting
    static final String RESET_UPDATED_TABLES_SQL = "UPDATE " + UPDATE_TABLE_NAME
            + " SET " + INVALIDATED_COLUMN_NAME + " = 0 WHERE " + INVALIDATED_COLUMN_NAME + " = 1 ";

    @VisibleForTesting
    static final String SELECT_UPDATED_TABLES_SQL = "SELECT * FROM " + UPDATE_TABLE_NAME
            + " WHERE " + INVALIDATED_COLUMN_NAME + " = 1;";

    @NonNull
    final HashMap<String, Integer> mTableIdLookup;
    final String[] mTableNames;

    @NonNull
    private Map<String, Set<String>> mViewTables;

    @Nullable
    AutoCloser mAutoCloser = null;

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    final RoomDatabase mDatabase;

    AtomicBoolean mPendingRefresh = new AtomicBoolean(false);

    private volatile boolean mInitialized = false;

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    volatile SupportSQLiteStatement mCleanupStatement;

    private ObservedTableTracker mObservedTableTracker;

    private final InvalidationLiveDataContainer mInvalidationLiveDataContainer;

    // should be accessed with synchronization only.
    @VisibleForTesting
    @SuppressLint("RestrictedApi")
    final SafeIterableMap<Observer, ObserverWrapper> mObserverMap = new SafeIterableMap<>();

    private MultiInstanceInvalidationClient mMultiInstanceInvalidationClient;

    /**
     * Used by the generated code.
     *
     * @hide
     */
    @SuppressWarnings("WeakerAccess")
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public InvalidationTracker(RoomDatabase database, String... tableNames) {
        this(database, new HashMap<String, String>(), Collections.<String, Set<String>>emptyMap(),
                tableNames);
    }

    /**
     * Used by the generated code.
     *
     * @hide
     */
    @SuppressWarnings("WeakerAccess")
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public InvalidationTracker(RoomDatabase database, Map<String, String> shadowTablesMap,
            Map<String, Set<String>> viewTables, String... tableNames) {
        mDatabase = database;
        mObservedTableTracker = new ObservedTableTracker(tableNames.length);
        mTableIdLookup = new HashMap<>();
        mViewTables = viewTables;
        mInvalidationLiveDataContainer = new InvalidationLiveDataContainer(mDatabase);
        final int size = tableNames.length;
        mTableNames = new String[size];
        for (int id = 0; id < size; id++) {
            final String tableName = tableNames[id].toLowerCase(Locale.US);
            mTableIdLookup.put(tableName, id);
            String shadowTableName = shadowTablesMap.get(tableNames[id]);
            if (shadowTableName != null) {
                mTableNames[id] = shadowTableName.toLowerCase(Locale.US);
            } else {
                mTableNames[id] = tableName;
            }
        }
        // Adjust table id lookup for those tables whose shadow table is another already mapped
        // table (e.g. external content fts tables).
        for (Map.Entry<String, String> shadowTableEntry : shadowTablesMap.entrySet()) {
            String shadowTableName = shadowTableEntry.getValue().toLowerCase(Locale.US);
            if (mTableIdLookup.containsKey(shadowTableName)) {
                String tableName = shadowTableEntry.getKey().toLowerCase(Locale.US);
                mTableIdLookup.put(tableName, mTableIdLookup.get(shadowTableName));
            }
        }
    }

    /**
     * Sets the auto closer for this invalidation tracker so that the invalidation tracker can
     * ensure that the database is not closed if there are pending invalidations that haven't yet
     * been flushed.
     *
     * This also adds a callback to the autocloser to ensure that the InvalidationTracker is in
     * an ok state once the table is invalidated.
     *
     * This must be called before the database is used.
     *
     * @param autoCloser the autocloser associated with the db
     */
    void setAutoCloser(AutoCloser autoCloser) {
        this.mAutoCloser = autoCloser;
        mAutoCloser.setAutoCloseCallback(this::onAutoCloseCallback);
    }

    /**
     * Internal method to initialize table tracking.
     * <p>
     * You should never call this method, it is called by the generated code.
     */
    void internalInit(SupportSQLiteDatabase database) {
        synchronized (this) {
            if (mInitialized) {
                Log.e(Room.LOG_TAG, "Invalidation tracker is initialized twice :/.");
                return;
            }

            // These actions are not in a transaction because temp_store is not allowed to be
            // performed on a transaction, and recursive_triggers is not affected by transactions.
            database.execSQL("PRAGMA temp_store = MEMORY;");
            database.execSQL("PRAGMA recursive_triggers='ON';");
            database.execSQL(CREATE_TRACKING_TABLE_SQL);
            syncTriggers(database);
            mCleanupStatement = database.compileStatement(RESET_UPDATED_TABLES_SQL);
            mInitialized = true;
        }
    }

    void onAutoCloseCallback() {
        synchronized (this) {
            mInitialized = false;
            mObservedTableTracker.resetTriggerState();
        }
    }

    void startMultiInstanceInvalidation(Context context, String name, Intent serviceIntent) {
        mMultiInstanceInvalidationClient = new MultiInstanceInvalidationClient(context, name,
                serviceIntent, this, mDatabase.getQueryExecutor());
    }

    void stopMultiInstanceInvalidation() {
        if (mMultiInstanceInvalidationClient != null) {
            mMultiInstanceInvalidationClient.stop();
            mMultiInstanceInvalidationClient = null;
        }
    }

    private static void appendTriggerName(StringBuilder builder, String tableName,
            String triggerType) {
        builder.append("`")
                .append("room_table_modification_trigger_")
                .append(tableName)
                .append("_")
                .append(triggerType)
                .append("`");
    }

    private void stopTrackingTable(SupportSQLiteDatabase writableDb, int tableId) {
        final String tableName = mTableNames[tableId];
        StringBuilder stringBuilder = new StringBuilder();
        for (String trigger : TRIGGERS) {
            stringBuilder.setLength(0);
            stringBuilder.append("DROP TRIGGER IF EXISTS ");
            appendTriggerName(stringBuilder, tableName, trigger);
            writableDb.execSQL(stringBuilder.toString());
        }
    }

    private void startTrackingTable(SupportSQLiteDatabase writableDb, int tableId) {
        writableDb.execSQL(
                "INSERT OR IGNORE INTO " + UPDATE_TABLE_NAME + " VALUES(" + tableId + ", 0)");
        final String tableName = mTableNames[tableId];
        StringBuilder stringBuilder = new StringBuilder();
        for (String trigger : TRIGGERS) {
            stringBuilder.setLength(0);
            stringBuilder.append("CREATE TEMP TRIGGER IF NOT EXISTS ");
            appendTriggerName(stringBuilder, tableName, trigger);
            stringBuilder.append(" AFTER ")
                    .append(trigger)
                    .append(" ON `")
                    .append(tableName)
                    .append("` BEGIN UPDATE ")
                    .append(UPDATE_TABLE_NAME)
                    .append(" SET ").append(INVALIDATED_COLUMN_NAME).append(" = 1")
                    .append(" WHERE ").append(TABLE_ID_COLUMN_NAME).append(" = ").append(tableId)
                    .append(" AND ").append(INVALIDATED_COLUMN_NAME).append(" = 0")
                    .append("; END");
            writableDb.execSQL(stringBuilder.toString());
        }
    }

    /**
     * Adds the given observer to the observers list and it will be notified if any table it
     * observes changes.
     * <p>
     * Database changes are pulled on another thread so in some race conditions, the observer might
     * be invoked for changes that were done before it is added.
     * <p>
     * If the observer already exists, this is a no-op call.
     * <p>
     * If one of the tables in the Observer does not exist in the database, this method throws an
     * {@link IllegalArgumentException}.
     * <p>
     * This method should be called on a background/worker thread as it performs database
     * operations.
     *
     * @param observer The observer which listens the database for changes.
     */
    @SuppressLint("RestrictedApi")
    @WorkerThread
    public void addObserver(@NonNull Observer observer) {
        final String[] tableNames = resolveViews(observer.mTables);
        int[] tableIds = new int[tableNames.length];
        final int size = tableNames.length;

        for (int i = 0; i < size; i++) {
            Integer tableId = mTableIdLookup.get(tableNames[i].toLowerCase(Locale.US));
            if (tableId == null) {
                throw new IllegalArgumentException("There is no table with name " + tableNames[i]);
            }
            tableIds[i] = tableId;
        }
        ObserverWrapper wrapper = new ObserverWrapper(observer, tableIds, tableNames);
        ObserverWrapper currentObserver;
        synchronized (mObserverMap) {
            currentObserver = mObserverMap.putIfAbsent(observer, wrapper);
        }
        if (currentObserver == null && mObservedTableTracker.onAdded(tableIds)) {
            syncTriggers();
        }
    }

    private String[] validateAndResolveTableNames(String[] tableNames) {
        String[] resolved = resolveViews(tableNames);
        for (String tableName : resolved) {
            if (!mTableIdLookup.containsKey(tableName.toLowerCase(Locale.US))) {
                throw new IllegalArgumentException("There is no table with name " + tableName);
            }
        }
        return resolved;
    }

    /**
     * Resolves the list of tables and views into a list of unique tables that are underlying them.
     *
     * @param names The names of tables or views.
     * @return The names of the underlying tables.
     */
    private String[] resolveViews(String[] names) {
        Set<String> tables = new HashSet<>();
        for (String name : names) {
            final String lowercase = name.toLowerCase(Locale.US);
            if (mViewTables.containsKey(lowercase)) {
                tables.addAll(mViewTables.get(lowercase));
            } else {
                tables.add(name);
            }
        }
        return tables.toArray(new String[tables.size()]);
    }

    private static void beginTransactionInternal(SupportSQLiteDatabase database) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN
                && database.isWriteAheadLoggingEnabled()) {
            database.beginTransactionNonExclusive();
        } else {
            database.beginTransaction();
        }
    }

    /**
     * Adds an observer but keeps a weak reference back to it.
     * <p>
     * Note that you cannot remove this observer once added. It will be automatically removed
     * when the observer is GC'ed.
     *
     * @param observer The observer to which InvalidationTracker will keep a weak reference.
     * @hide
     */
    @SuppressWarnings("unused")
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public void addWeakObserver(Observer observer) {
        addObserver(new WeakObserver(this, observer));
    }

    /**
     * Removes the observer from the observers list.
     * <p>
     * This method should be called on a background/worker thread as it performs database
     * operations.
     *
     * @param observer The observer to remove.
     */
    @SuppressLint("RestrictedApi")
    @SuppressWarnings("WeakerAccess")
    @WorkerThread
    public void removeObserver(@NonNull final Observer observer) {
        ObserverWrapper wrapper;
        synchronized (mObserverMap) {
            wrapper = mObserverMap.remove(observer);
        }
        if (wrapper != null && mObservedTableTracker.onRemoved(wrapper.mTableIds)) {
            syncTriggers();
        }
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    boolean ensureInitialization() {
        if (!mDatabase.isOpen()) {
            return false;
        }
        if (!mInitialized) {
            // trigger initialization
            mDatabase.getOpenHelper().getWritableDatabase();
        }
        if (!mInitialized) {
            Log.e(Room.LOG_TAG, "database is not initialized even though it is open");
            return false;
        }
        return true;
    }

    @VisibleForTesting
    Runnable mRefreshRunnable = new Runnable() {
        @Override
        public void run() {
            final Lock closeLock = mDatabase.getCloseLock();
            Set<Integer> invalidatedTableIds = null;
            closeLock.lock();
            try {

                if (!ensureInitialization()) {
                    return;
                }

                if (!mPendingRefresh.compareAndSet(true, false)) {
                    // no pending refresh
                    return;
                }

                if (mDatabase.inTransaction()) {
                    // current thread is in a transaction. when it ends, it will invoke
                    // refreshRunnable again. mPendingRefresh is left as false on purpose
                    // so that the last transaction can flip it on again.
                    return;
                }

                // This transaction has to be on the underlying DB rather than the RoomDatabase
                // in order to avoid a recursive loop after endTransaction.
                SupportSQLiteDatabase db = mDatabase.getOpenHelper().getWritableDatabase();
                db.beginTransactionNonExclusive();
                try {
                    invalidatedTableIds = checkUpdatedTable();
                    db.setTransactionSuccessful();
                } finally {
                    db.endTransaction();
                }
            } catch (IllegalStateException | SQLiteException exception) {
                // may happen if db is closed. just log.
                Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?",
                        exception);
            } finally {
                closeLock.unlock();

                if (mAutoCloser != null) {
                    mAutoCloser.decrementCountAndScheduleClose();
                }
            }
            if (invalidatedTableIds != null && !invalidatedTableIds.isEmpty()) {
                synchronized (mObserverMap) {
                    for (Map.Entry<Observer, ObserverWrapper> entry : mObserverMap) {
                        entry.getValue().notifyByTableInvalidStatus(invalidatedTableIds);
                    }
                }
            }
        }

        private Set<Integer> checkUpdatedTable() {
            HashSet<Integer> invalidatedTableIds = new HashSet<>();
            Cursor cursor = mDatabase.query(new SimpleSQLiteQuery(SELECT_UPDATED_TABLES_SQL));
            //noinspection TryFinallyCanBeTryWithResources
            try {
                while (cursor.moveToNext()) {
                    final int tableId = cursor.getInt(0);
                    invalidatedTableIds.add(tableId);
                }
            } finally {
                cursor.close();
            }
            if (!invalidatedTableIds.isEmpty()) {
                mCleanupStatement.executeUpdateDelete();
            }
            return invalidatedTableIds;
        }
    };

    /**
     * Enqueues a task to refresh the list of updated tables.
     * <p>
     * This method is automatically called when {@link RoomDatabase#endTransaction()} is called but
     * if you have another connection to the database or directly use {@link
     * SupportSQLiteDatabase}, you may need to call this manually.
     */
    @SuppressWarnings("WeakerAccess")
    public void refreshVersionsAsync() {
        // TODO we should consider doing this sync instead of async.
        if (mPendingRefresh.compareAndSet(false, true)) {
            if (mAutoCloser != null) {
                // refreshVersionsAsync is called with the ref count incremented from
                // RoomDatabase, so the db can't be closed here, but we need to be sure that our
                // db isn't closed until refresh is completed. This increment call must be
                // matched with a corresponding call in mRefreshRunnable.
                mAutoCloser.incrementCountAndEnsureDbIsOpen();
            }
            mDatabase.getQueryExecutor().execute(mRefreshRunnable);
        }
    }

    /**
     * Check versions for tables, and run observers synchronously if tables have been updated.
     *
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    @WorkerThread
    public void refreshVersionsSync() {
        if (mAutoCloser != null) {
            // This increment call must be matched with a corresponding call in mRefreshRunnable.
            mAutoCloser.incrementCountAndEnsureDbIsOpen();
        }
        syncTriggers();
        mRefreshRunnable.run();
    }

    /**
     * Notifies all the registered {@link Observer}s of table changes.
     * <p>
     * This can be used for notifying invalidation that cannot be detected by this
     * {@link InvalidationTracker}, for example, invalidation from another process.
     *
     * @param tables The invalidated tables.
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public void notifyObserversByTableNames(String... tables) {
        synchronized (mObserverMap) {
            for (Map.Entry<Observer, ObserverWrapper> entry : mObserverMap) {
                if (!entry.getKey().isRemote()) {
                    entry.getValue().notifyByTableNames(tables);
                }
            }
        }
    }

    void syncTriggers(SupportSQLiteDatabase database) {
        if (database.inTransaction()) {
            // we won't run this inside another transaction.
            return;
        }
        try {
            // This method runs in a while loop because while changes are synced to db, another
            // runnable may be skipped. If we cause it to skip, we need to do its work.
            while (true) {
                Lock closeLock = mDatabase.getCloseLock();
                closeLock.lock();
                try {
                    // there is a potential race condition where another mSyncTriggers runnable
                    // can start running right after we get the tables list to sync.
                    final int[] tablesToSync = mObservedTableTracker.getTablesToSync();
                    if (tablesToSync == null) {
                        return;
                    }
                    final int limit = tablesToSync.length;
                    beginTransactionInternal(database);
                    try {
                        for (int tableId = 0; tableId < limit; tableId++) {
                            switch (tablesToSync[tableId]) {
                                case ObservedTableTracker.ADD:
                                    startTrackingTable(database, tableId);
                                    break;
                                case ObservedTableTracker.REMOVE:
                                    stopTrackingTable(database, tableId);
                                    break;
                            }
                        }
                        database.setTransactionSuccessful();
                    } finally {
                        database.endTransaction();
                    }
                    mObservedTableTracker.onSyncCompleted();
                } finally {
                    closeLock.unlock();
                }
            }
        } catch (IllegalStateException | SQLiteException exception) {
            // may happen if db is closed. just log.
            Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?",
                    exception);
        }
    }

    /**
     * Called by RoomDatabase before each beginTransaction call.
     * <p>
     * It is important that pending trigger changes are applied to the database before any query
     * runs. Otherwise, we may miss some changes.
     * <p>
     * This api should eventually be public.
     */
    void syncTriggers() {
        if (!mDatabase.isOpen()) {
            return;
        }
        syncTriggers(mDatabase.getOpenHelper().getWritableDatabase());
    }

    /**
     * Creates a LiveData that computes the given function once and for every other invalidation
     * of the database.
     * <p>
     * Holds a strong reference to the created LiveData as long as it is active.
     *
     * @deprecated Use {@link #createLiveData(String[], boolean, Callable)}
     *
     * @param computeFunction The function that calculates the value
     * @param tableNames      The list of tables to observe
     * @param <T>             The return type
     * @return A new LiveData that computes the given function when the given list of tables
     * invalidates.
     * @hide
     */
    @Deprecated
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public <T> LiveData<T> createLiveData(String[] tableNames, Callable<T> computeFunction) {
        return createLiveData(tableNames, false, computeFunction);
    }

    /**
     * Creates a LiveData that computes the given function once and for every other invalidation
     * of the database.
     * <p>
     * Holds a strong reference to the created LiveData as long as it is active.
     *
     * @param tableNames      The list of tables to observe
     * @param inTransaction   True if the computeFunction will be done in a transaction, false
     *                        otherwise.
     * @param computeFunction The function that calculates the value
     * @param <T>             The return type
     * @return A new LiveData that computes the given function when the given list of tables
     * invalidates.
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public <T> LiveData<T> createLiveData(String[] tableNames, boolean inTransaction,
            Callable<T> computeFunction) {
        return mInvalidationLiveDataContainer.create(
                validateAndResolveTableNames(tableNames), inTransaction, computeFunction);
    }

    /**
     * Wraps an observer and keeps the table information.
     * <p>
     * Internally table ids are used which may change from database to database so the table
     * related information is kept here rather than in the Observer.
     */
    @SuppressWarnings("WeakerAccess")
    static class ObserverWrapper {
        final int[] mTableIds;
        private final String[] mTableNames;
        final Observer mObserver;
        private final Set<String> mSingleTableSet;

        ObserverWrapper(Observer observer, int[] tableIds, String[] tableNames) {
            mObserver = observer;
            mTableIds = tableIds;
            mTableNames = tableNames;
            if (tableIds.length == 1) {
                HashSet<String> set = new HashSet<>();
                set.add(mTableNames[0]);
                mSingleTableSet = Collections.unmodifiableSet(set);
            } else {
                mSingleTableSet = null;
            }
        }

        /**
         * Notifies the underlying {@link #mObserver} if any of the observed tables are invalidated
         * based on the given invalid status set.
         *
         * @param invalidatedTablesIds The table ids of the tables that are invalidated.
         */
        void notifyByTableInvalidStatus(Set<Integer> invalidatedTablesIds) {
            Set<String> invalidatedTables = null;
            final int size = mTableIds.length;
            for (int index = 0; index < size; index++) {
                final int tableId = mTableIds[index];
                if (invalidatedTablesIds.contains(tableId)) {
                    if (size == 1) {
                        // Optimization for a single-table observer
                        invalidatedTables = mSingleTableSet;
                    } else {
                        if (invalidatedTables == null) {
                            invalidatedTables = new HashSet<>(size);
                        }
                        invalidatedTables.add(mTableNames[index]);
                    }
                }
            }
            if (invalidatedTables != null) {
                mObserver.onInvalidated(invalidatedTables);
            }
        }

        /**
         * Notifies the underlying {@link #mObserver} if it observes any of the specified
         * {@code tables}.
         *
         * @param tables The invalidated table names.
         */
        void notifyByTableNames(String[] tables) {
            Set<String> invalidatedTables = null;
            if (mTableNames.length == 1) {
                for (String table : tables) {
                    if (table.equalsIgnoreCase(mTableNames[0])) {
                        // Optimization for a single-table observer
                        invalidatedTables = mSingleTableSet;
                        break;
                    }
                }
            } else {
                HashSet<String> set = new HashSet<>();
                for (String table : tables) {
                    for (String ourTable : mTableNames) {
                        if (ourTable.equalsIgnoreCase(table)) {
                            set.add(ourTable);
                            break;
                        }
                    }
                }
                if (set.size() > 0) {
                    invalidatedTables = set;
                }
            }
            if (invalidatedTables != null) {
                mObserver.onInvalidated(invalidatedTables);
            }
        }
    }

    /**
     * An observer that can listen for changes in the database.
     */
    public abstract static class Observer {
        final String[] mTables;

        /**
         * Observes the given list of tables and views.
         *
         * @param firstTable The name of the table or view.
         * @param rest       More names of tables or views.
         */
        @SuppressWarnings("unused")
        protected Observer(@NonNull String firstTable, String... rest) {
            mTables = Arrays.copyOf(rest, rest.length + 1);
            mTables[rest.length] = firstTable;
        }

        /**
         * Observes the given list of tables and views.
         *
         * @param tables The list of tables or views to observe for changes.
         */
        public Observer(@NonNull String[] tables) {
            // copy tables in case user modifies them afterwards
            mTables = Arrays.copyOf(tables, tables.length);
        }

        /**
         * Called when one of the observed tables is invalidated in the database.
         *
         * @param tables A set of invalidated tables. This is useful when the observer targets
         *               multiple tables and you want to know which table is invalidated. This will
         *               be names of underlying tables when you are observing views.
         */
        public abstract void onInvalidated(@NonNull Set<String> tables);

        boolean isRemote() {
            return false;
        }
    }

    /**
     * Keeps a list of tables we should observe. Invalidation tracker lazily syncs this list w/
     * triggers in the database.
     * <p>
     * This class is thread safe
     */
    static class ObservedTableTracker {
        static final int NO_OP = 0; // don't change trigger state for this table
        static final int ADD = 1; // add triggers for this table
        static final int REMOVE = 2; // remove triggers for this table

        // number of observers per table
        final long[] mTableObservers;
        // trigger state for each table at last sync
        // this field is updated when syncAndGet is called.
        final boolean[] mTriggerStates;
        // when sync is called, this field is returned. It includes actions as ADD, REMOVE, NO_OP
        final int[] mTriggerStateChanges;

        boolean mNeedsSync;

        /**
         * After we return non-null value from getTablesToSync, we expect a onSyncCompleted before
         * returning any non-null value from getTablesToSync.
         * This allows us to workaround any multi-threaded state syncing issues.
         */
        boolean mPendingSync;

        ObservedTableTracker(int tableCount) {
            mTableObservers = new long[tableCount];
            mTriggerStates = new boolean[tableCount];
            mTriggerStateChanges = new int[tableCount];
            Arrays.fill(mTableObservers, 0);
            Arrays.fill(mTriggerStates, false);
        }

        /**
         * @return true if # of triggers is affected.
         */
        boolean onAdded(int... tableIds) {
            boolean needTriggerSync = false;
            synchronized (this) {
                for (int tableId : tableIds) {
                    final long prevObserverCount = mTableObservers[tableId];
                    mTableObservers[tableId] = prevObserverCount + 1;
                    if (prevObserverCount == 0) {
                        mNeedsSync = true;
                        needTriggerSync = true;
                    }
                }
            }
            return needTriggerSync;
        }

        /**
         * @return true if # of triggers is affected.
         */
        boolean onRemoved(int... tableIds) {
            boolean needTriggerSync = false;
            synchronized (this) {
                for (int tableId : tableIds) {
                    final long prevObserverCount = mTableObservers[tableId];
                    mTableObservers[tableId] = prevObserverCount - 1;
                    if (prevObserverCount == 1) {
                        mNeedsSync = true;
                        needTriggerSync = true;
                    }
                }
            }
            return needTriggerSync;
        }

        /**
         * If we are re-opening the db we'll need to add all the triggers that we need so change
         * the current state to false for all.
         */
        void resetTriggerState() {
            synchronized (this) {
                Arrays.fill(mTriggerStates, false);
                mNeedsSync = true;
            }
        }

        /**
         * If this returns non-null, you must call onSyncCompleted.
         *
         * @return int[] An int array where the index for each tableId has the action for that
         * table.
         */
        @Nullable
        int[] getTablesToSync() {
            synchronized (this) {
                if (!mNeedsSync || mPendingSync) {
                    return null;
                }
                final int tableCount = mTableObservers.length;
                for (int i = 0; i < tableCount; i++) {
                    final boolean newState = mTableObservers[i] > 0;
                    if (newState != mTriggerStates[i]) {
                        mTriggerStateChanges[i] = newState ? ADD : REMOVE;
                    } else {
                        mTriggerStateChanges[i] = NO_OP;
                    }
                    mTriggerStates[i] = newState;
                }
                mPendingSync = true;
                mNeedsSync = false;
                return mTriggerStateChanges;
            }
        }

        /**
         * if getTablesToSync returned non-null, the called should call onSyncCompleted once it
         * is done.
         */
        void onSyncCompleted() {
            synchronized (this) {
                mPendingSync = false;
            }
        }
    }

    /**
     * An Observer wrapper that keeps a weak reference to the given object.
     * <p>
     * This class will automatically unsubscribe when the wrapped observer goes out of memory.
     */
    static class WeakObserver extends Observer {
        final InvalidationTracker mTracker;
        final WeakReference<Observer> mDelegateRef;

        WeakObserver(InvalidationTracker tracker, Observer delegate) {
            super(delegate.mTables);
            mTracker = tracker;
            mDelegateRef = new WeakReference<>(delegate);
        }

        @Override
        public void onInvalidated(@NonNull Set<String> tables) {
            final Observer observer = mDelegateRef.get();
            if (observer == null) {
                mTracker.removeObserver(this);
            } else {
                observer.onInvalidated(tables);
            }
        }
    }
}