SupportSQLiteOpenHelper.kt

/*
 * Copyright (C) 2016 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.sqlite.db

import android.content.Context
import android.database.sqlite.SQLiteException
import android.os.Build
import android.util.Log
import android.util.Pair
import androidx.annotation.RequiresApi
import androidx.sqlite.db.SupportSQLiteCompat.Api16Impl.deleteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper.Callback
import androidx.sqlite.db.SupportSQLiteOpenHelper.Factory
import java.io.Closeable
import java.io.File
import java.io.IOException

/**
 * An interface to map the behavior of [android.database.sqlite.SQLiteOpenHelper].
 * Note that since that class requires overriding certain methods, support implementation
 * uses [Factory.create] to create this and [Callback] to implement
 * the methods that should be overridden.
 */
interface SupportSQLiteOpenHelper : Closeable {
    /**
     * Return the name of the SQLite database being opened, as given to
     * the constructor. `null` indicates an in-memory database.
     */
    val databaseName: String?

    /**
     * Enables or disables the use of write-ahead logging for the database.
     *
     * See [SupportSQLiteDatabase.enableWriteAheadLogging] for details.
     *
     * Write-ahead logging cannot be used with read-only databases so the value of
     * this flag is ignored if the database is opened read-only.
     *
     * @param enabled True if write-ahead logging should be enabled, false if it
     * should be disabled.
     */
    @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
    fun setWriteAheadLoggingEnabled(enabled: Boolean)

    /**
     * Create and/or open a database that will be used for reading and writing.
     * The first time this is called, the database will be opened and
     * [Callback.onCreate], [Callback.onUpgrade] and/or [Callback.onOpen] will be
     * called.
     *
     * Once opened successfully, the database is cached, so you can
     * call this method every time you need to write to the database.
     * (Make sure to call [close] when you no longer need the database.)
     * Errors such as bad permissions or a full disk may cause this method
     * to fail, but future attempts may succeed if the problem is fixed.
     *
     * Database upgrade may take a long time, you
     * should not call this method from the application main thread, including
     * from [ContentProvider.onCreate()].
     *
     * @return a read/write database object valid until [close] is called
     * @throws SQLiteException if the database cannot be opened for writing
     */
    val writableDatabase: SupportSQLiteDatabase

    /**
     * Create and/or open a database.  This will be the same object returned by
     * [writableDatabase] unless some problem, such as a full disk,
     * requires the database to be opened read-only.  In that case, a read-only
     * database object will be returned.  If the problem is fixed, a future call
     * to [writableDatabase] may succeed, in which case the read-only
     * database object will be closed and the read/write object will be returned
     * in the future.
     *
     * Like [writableDatabase], this method may
     * take a long time to return, so you should not call it from the
     * application main thread, including from
     * [ContentProvider.onCreate()].
     *
     * @return a database object valid until [writableDatabase]
     * or [close] is called.
     * @throws SQLiteException if the database cannot be opened
     */
    val readableDatabase: SupportSQLiteDatabase

    /**
     * Close any open database object.
     */
    override fun close()

    /**
     * Creates a new Callback to get database lifecycle events.
     *
     * Handles various lifecycle events for the SQLite connection, similar to
     * [room-runtime.SQLiteOpenHelper].
     */
    abstract class Callback(
        /**
         * Version number of the database (starting at 1); if the database is older,
         * [Callback.onUpgrade] will be used to upgrade the database; if the database is newer,
         * [Callback.onDowngrade] will be used to downgrade the database.
         */
        @JvmField
        val version: Int
    ) {
        /**
         * Called when the database connection is being configured, to enable features such as
         * write-ahead logging or foreign key support.
         *
         * This method is called before [onCreate], [onUpgrade], [onDowngrade],
         * or [onOpen] are called. It should not modify the database except to configure the
         * database connection as required.
         *
         * This method should only call methods that configure the parameters of the database
         * connection, such as [SupportSQLiteDatabase.enableWriteAheadLogging]
         * [SupportSQLiteDatabase.setForeignKeyConstraintsEnabled],
         * [SupportSQLiteDatabase.setLocale],
         * [SupportSQLiteDatabase.setMaximumSize], or executing PRAGMA statements.
         *
         * @param db The database.
         */
        open fun onConfigure(db: SupportSQLiteDatabase) {}

        /**
         * Called when the database is created for the first time. This is where the
         * creation of tables and the initial population of the tables should happen.
         *
         * @param db The database.
         */
        abstract fun onCreate(db: SupportSQLiteDatabase)

        /**
         * Called when the database needs to be upgraded. The implementation
         * should use this method to drop tables, add tables, or do anything else it
         * needs to upgrade to the new schema version.
         *
         * The SQLite ALTER TABLE documentation can be found
         * [here](http://sqlite.org/lang_altertable.html). If you add new columns
         * you can use ALTER TABLE to insert them into a live table. If you rename or remove columns
         * you can use ALTER TABLE to rename the old table, then create the new table and then
         * populate the new table with the contents of the old table.
         *
         * This method executes within a transaction.  If an exception is thrown, all changes
         * will automatically be rolled back.
         *
         * @param db         The database.
         * @param oldVersion The old database version.
         * @param newVersion The new database version.
         */
        abstract fun onUpgrade(
            db: SupportSQLiteDatabase,
            oldVersion: Int,
            newVersion: Int
        )

        /**
         * Called when the database needs to be downgraded. This is strictly similar to
         * [onUpgrade] method, but is called whenever current version is newer than requested
         * one.
         * However, this method is not abstract, so it is not mandatory for a customer to
         * implement it. If not overridden, default implementation will reject downgrade and
         * throws SQLiteException
         *
         * This method executes within a transaction.  If an exception is thrown, all changes
         * will automatically be rolled back.
         *
         * @param db         The database.
         * @param oldVersion The old database version.
         * @param newVersion The new database version.
         */
        open fun onDowngrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
            throw SQLiteException(
                "Can't downgrade database from version $oldVersion to $newVersion"
            )
        }

        /**
         * Called when the database has been opened.  The implementation
         * should check [SupportSQLiteDatabase.isReadOnly] before updating the
         * database.
         *
         * This method is called after the database connection has been configured
         * and after the database schema has been created, upgraded or downgraded as necessary.
         * If the database connection must be configured in some way before the schema
         * is created, upgraded, or downgraded, do it in [onConfigure] instead.
         *
         * @param db The database.
         */
        open fun onOpen(db: SupportSQLiteDatabase) {}

        /**
         * The method invoked when database corruption is detected. Default implementation will
         * delete the database file.
         *
         * @param db the [SupportSQLiteDatabase] object representing the database on which
         * corruption is detected.
         */
        open fun onCorruption(db: SupportSQLiteDatabase) {
            // the following implementation is taken from {@link DefaultDatabaseErrorHandler}.
            Log.e(TAG, "Corruption reported by sqlite on database: $db.path")
            // is the corruption detected even before database could be 'opened'?
            if (!db.isOpen) {
                // database files are not even openable. delete this database file.
                // NOTE if the database has attached databases, then any of them could be corrupt.
                // and not deleting all of them could cause corrupted database file to remain and
                // make the application crash on database open operation. To avoid this problem,
                // the application should provide its own {@link DatabaseErrorHandler} impl class
                // to delete ALL files of the database (including the attached databases).
                db.path?.let { deleteDatabaseFile(it) }
                return
            }
            var attachedDbs: List<Pair<String, String>>? = null
            try {
                // Close the database, which will cause subsequent operations to fail.
                // before that, get the attached database list first.
                try {
                    attachedDbs = db.attachedDbs
                } catch (e: SQLiteException) {
                    /* ignore */
                }
                try {
                    db.close()
                } catch (e: IOException) {
                    /* ignore */
                }
            } finally {
                // Delete all files of this corrupt database and/or attached databases
                // attachedDbs = null is possible when the database is so corrupt that even
                // "PRAGMA database_list;" also fails. delete the main database file
                attachedDbs?.forEach { p ->
                    deleteDatabaseFile(p.second)
                } ?: db.path?.let { deleteDatabaseFile(it) }
            }
        }

        private fun deleteDatabaseFile(fileName: String) {
            if (fileName.equals(":memory:", ignoreCase = true) ||
                fileName.trim { it <= ' ' }.isEmpty()
            ) {
                return
            }
            Log.w(TAG, "deleting the database file: $fileName")
            try {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                    deleteDatabase(File(fileName))
                } else {
                    try {
                        val deleted = File(fileName).delete()
                        if (!deleted) {
                            Log.e(TAG, "Could not delete the database file $fileName")
                        }
                    } catch (error: Exception) {
                        Log.e(TAG, "error while deleting corrupted database file", error)
                    }
                }
            } catch (e: Exception) {
                /* print warning and ignore exception */
                Log.w(TAG, "delete failed: ", e)
            }
        }

        internal companion object {
            private const val TAG = "SupportSQLite"
        }
    }

    /**
     * The configuration to create an SQLite open helper object using [Factory].
     */
    class Configuration
    @Suppress("ExecutorRegistration") // For backwards compatibility
    constructor(
        /**
         * Context to use to open or create the database.
         */
        @JvmField
        val context: Context,
        /**
         * Name of the database file, or null for an in-memory database.
         */
        @JvmField
        val name: String?,
        /**
         * The callback class to handle creation, upgrade and downgrade.
         */
        @JvmField
        val callback: Callback,
        /**
         * If `true` the database will be stored in the no-backup directory.
         */
        @JvmField
        @Suppress("ListenerLast")
        val useNoBackupDirectory: Boolean = false,
        /**
         * If `true` the database will be delete and its data loss in the case that it
         * cannot be opened.
         */
        @JvmField
        @Suppress("ListenerLast")
        val allowDataLossOnRecovery: Boolean = false
    ) {

        /**
         * Builder class for [Configuration].
         */
        open class Builder internal constructor(context: Context) {
            private val context: Context
            private var name: String? = null
            private var callback: Callback? = null
            private var useNoBackupDirectory = false
            private var allowDataLossOnRecovery = false

            /**
             * Throws an [IllegalArgumentException] if the [Callback] is `null`.
             *
             * Throws an [IllegalArgumentException] if the [Context] is `null`.
             *
             * Throws an [IllegalArgumentException] if the [String] database
             * name is `null`. [Context.getNoBackupFilesDir]
             *
             * @return The [Configuration] instance
             */
            open fun build(): Configuration {
                val callback = callback
                requireNotNull(callback) {
                    "Must set a callback to create the configuration."
                }
                require(!useNoBackupDirectory || !name.isNullOrEmpty()) {
                    "Must set a non-null database name to a configuration that uses the " +
                        "no backup directory."
                }
                return Configuration(
                    context,
                    name,
                    callback,
                    useNoBackupDirectory,
                    allowDataLossOnRecovery
                )
            }

            init {
                this.context = context
            }

            /**
             * @param name Name of the database file, or null for an in-memory database.
             * @return This builder instance.
             */
            open fun name(name: String?): Builder = apply {
                this.name = name
            }

            /**
             * @param callback The callback class to handle creation, upgrade and downgrade.
             * @return This builder instance.
             */
            open fun callback(callback: Callback): Builder = apply {
                this.callback = callback
            }

            /**
             * Sets whether to use a no backup directory or not.
             *
             * @param useNoBackupDirectory If `true` the database file will be stored in the
             * no-backup directory.
             * @return This builder instance.
             */
            open fun noBackupDirectory(useNoBackupDirectory: Boolean): Builder = apply {
                this.useNoBackupDirectory = useNoBackupDirectory
            }

            /**
             * Sets whether to delete and recreate the database file in situations when the
             * database file cannot be opened, thus allowing for its data to be lost.
             *
             * @param allowDataLossOnRecovery If `true` the database file might be recreated
             * in the case that it cannot be opened.
             * @return this
             */
            open fun allowDataLossOnRecovery(allowDataLossOnRecovery: Boolean): Builder = apply {
                this.allowDataLossOnRecovery = allowDataLossOnRecovery
            }
        }

        companion object {
            /**
             * Creates a new Configuration.Builder to create an instance of Configuration.
             *
             * @param context to use to open or create the database.
             */
            @JvmStatic
            fun builder(context: Context): Builder {
                return Builder(context)
            }
        }
    }

    /**
     * Factory class to create instances of [SupportSQLiteOpenHelper] using
     * [Configuration].
     */
    fun interface Factory {
        /**
         * Creates an instance of [SupportSQLiteOpenHelper] using the given configuration.
         *
         * @param configuration The configuration to use while creating the open helper.
         *
         * @return A SupportSQLiteOpenHelper which can be used to open a database.
         */
        fun create(configuration: Configuration): SupportSQLiteOpenHelper
    }
}