NativeSQLiteLoader.kt

/*
 * Copyright 2021 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.verifier

import org.sqlite.SQLiteJDBCLoader
import org.sqlite.util.OSInfo
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.util.UUID

/**
 * A custom sqlite-jdbc native library extractor and loader.
 *
 * This class is used instead of [SQLiteJDBCLoader.initialize] since it workarounds current issues
 * in the loading strategy, specifically: https://github.com/xerial/sqlite-jdbc/pull/578.
 */
internal object NativeSQLiteLoader {

    private var loaded = false

    private val tempDir: File by lazy {
        File(System.getProperty("org.sqlite.tmpdir", System.getProperty("java.io.tmpdir")))
    }

    private val version: String by lazy { SQLiteJDBCLoader.getVersion() }

    @JvmStatic
    fun load() = synchronized(loaded) {
        if (loaded) return
        try {
            // Cleanup target temporary folder for a new extraction.
            cleanupTempFolder()
            // Extract and load native library.
            loadNativeLibrary()
            // Reflect into original loader and mark library as extracted.
            SQLiteJDBCLoader::class.java.getDeclaredField("extracted")
                .apply { trySetAccessible() }
                .set(null, true)
        } catch (ex: Exception) {
            // Fallback to main library if our attempt failed, do print error juuust in case, so if
            // there is an error with our approach we get to know, instead of fully swallowing it.
            RuntimeException("Failed to load native SQLite library, will try again though.", ex)
                .printStackTrace()
            SQLiteJDBCLoader.initialize()
        }
        loaded = true
    }

    private fun cleanupTempFolder() {
        tempDir.listFiles { file ->
            file.name.startsWith("sqlite-$version") && !file.name.endsWith(".lck")
        }?.forEach { libFile ->
            val lckFile = File(libFile.absolutePath + ".lck")
            if (!lckFile.exists()) {
                libFile.delete()
            }
        }
    }

    // Load the OS-dependent library from the Jar file.
    private fun loadNativeLibrary() {
        val packagePath =
            SQLiteJDBCLoader::class.java.getPackage().name.replace(".", "/")
        val nativeLibraryPath =
            "/$packagePath/native/${OSInfo.getNativeLibFolderPathForCurrentOS()}"
        val nativeLibraryName = let {
            val libName = System.mapLibraryName("sqlitejdbc")
                .apply { replace("dylib", "jnilib") }
            if (hasResource("$nativeLibraryPath/$libName")) {
                return@let libName
            }
            if (OSInfo.getOSName() == "Mac") {
                // Fix for openjdk7 for Mac
                val altLibName = "libsqlitejdbc.jnilib"
                if (hasResource("$nativeLibraryPath/$altLibName")) {
                    return@let altLibName
                }
            }
            error(
                "No native library is found for os.name=${OSInfo.getOSName()} and " +
                    "os.arch=${OSInfo.getArchName()}. path=$nativeLibraryPath"
            )
        }

        val extractedNativeLibraryFile = try {
            extractNativeLibrary(nativeLibraryPath, nativeLibraryName, tempDir.absolutePath)
        } catch (ex: IOException) {
            throw RuntimeException("Couldn't extract native SQLite library.", ex)
        }
        try {
            @Suppress("UnsafeDynamicallyLoadedCode") // Loading an from an absolute path.
            System.load(extractedNativeLibraryFile.absolutePath)
        } catch (ex: UnsatisfiedLinkError) {
            throw RuntimeException("Couldn't load native SQLite library.", ex)
        }
    }

    private fun extractNativeLibrary(
        libraryPath: String,
        libraryName: String,
        targetDirPath: String
    ): File {
        val libraryFilePath = "$libraryPath/$libraryName"
        // Include arch name in temporary filename in order to avoid conflicts when multiple JVMs
        // with different architectures are running.
        val outputLibraryFile = File(
            targetDirPath,
            "sqlite-$version-${UUID.randomUUID()}-$libraryName"
        ).apply { deleteOnExit() }
        val outputLibraryLckFile = File(
            targetDirPath,
            "${outputLibraryFile.name}.lck"
        ).apply { deleteOnExit() }
        if (!outputLibraryLckFile.exists()) {
            outputLibraryLckFile.outputStream().close()
        }
        getResourceAsStream(libraryFilePath).use { inputStream ->
            outputLibraryFile.outputStream().use { outputStream ->
                inputStream.copyTo(outputStream)
            }
        }
        // Set executable flag (x) to enable loading the library.
        outputLibraryFile.setReadable(true)
        outputLibraryFile.setExecutable(true)
        return outputLibraryFile
    }

    private fun hasResource(path: String) = SQLiteJDBCLoader::class.java.getResource(path) != null

    // Replacement of java.lang.Class#getResourceAsStream(String) to disable sharing the resource
    // stream in multiple class loaders and specifically to avoid
    // https://bugs.openjdk.java.net/browse/JDK-8205976
    private fun getResourceAsStream(name: String): InputStream {
        // Remove leading '/' since all our resource paths include a leading directory
        // See: https://github.com/openjdk/jdk/blob/jdk-11+0/src/java.base/share/classes/java/lang/Class.java#L2573
        val resolvedName = name.drop(1)
        val url = SQLiteJDBCLoader::class.java.classLoader.getResource(resolvedName)
            ?: throw IOException("Resource '$resolvedName' could not be found.")
        return url.openConnection().apply {
            defaultUseCaches = false
        }.getInputStream()
    }
}