SandboxedSdkContextCompat.kt

/*
 * Copyright 2022 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.privacysandbox.sdkruntime.client.loader.impl

import android.content.Context
import android.content.ContextWrapper
import android.content.SharedPreferences
import android.database.DatabaseErrorHandler
import android.database.sqlite.SQLiteDatabase
import android.os.Build
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream

/**
 * Refers to the context of the SDK loaded locally.
 *
 * Supports Per-SDK storage by pointing storage related APIs to folders unique for each SDK.
 * Where possible maintains same folders hierarchy as for applications by creating folders
 * inside [getDataDir].
 * Folders with special permissions or additional logic (caches, etc) created as subfolders of same
 * application folders.
 * SDK Shared Preferences supported by adding prefix to name and delegating to Application
 * Shared Preferences.
 *
 * SDK Folders hierarchy (from application [getDataDir]):
 * 1) /cache/RuntimeEnabledSdksData/<sdk_package_name> - cache
 * 2) /code_cache/RuntimeEnabledSdksData/<sdk_package_name> - code_cache
 * 3) /no_backup/RuntimeEnabledSdksData/<sdk_package_name> - no_backup
 * 4) /app_RuntimeEnabledSdksData/<sdk_package_name>/ - SDK Root (data dir)
 * 5) /app_RuntimeEnabledSdksData/<sdk_package_name>/files - [getFilesDir]
 * 6) /app_RuntimeEnabledSdksData/<sdk_package_name>/app_<folder_name> - [getDir]
 * 7) /app_RuntimeEnabledSdksData/<sdk_package_name>/databases - SDK Databases
 */
internal class SandboxedSdkContextCompat(
    base: Context,
    private val sdkPackageName: String,
    private val classLoader: ClassLoader?
) : ContextWrapper(base) {

    @RequiresApi(Build.VERSION_CODES.N)
    override fun createDeviceProtectedStorageContext(): Context {
        return SandboxedSdkContextCompat(
            Api24.createDeviceProtectedStorageContext(baseContext),
            sdkPackageName,
            classLoader
        )
    }

    /**
     *  Points to <app_data_dir>/app_RuntimeEnabledSdksData/<sdk_package_name>
     */
    override fun getDataDir(): File {
        val sdksDataRoot = baseContext.getDir(
            SDK_ROOT_FOLDER,
            Context.MODE_PRIVATE
        )
        return ensureDirExists(sdksDataRoot, sdkPackageName)
    }

    /**
     *  Points to <app_data_dir>/cache/RuntimeEnabledSdksData/<sdk_package_name>
     */
    override fun getCacheDir(): File {
        val sdksCacheRoot = ensureDirExists(baseContext.cacheDir, SDK_ROOT_FOLDER)
        return ensureDirExists(sdksCacheRoot, sdkPackageName)
    }

    /**
     *  Points to <app_data_dir>/code_cache/RuntimeEnabledSdksData/<sdk_package_name>
     */
    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    override fun getCodeCacheDir(): File {
        val sdksCodeCacheRoot = ensureDirExists(
            Api21.codeCacheDir(baseContext),
            SDK_ROOT_FOLDER
        )
        return ensureDirExists(sdksCodeCacheRoot, sdkPackageName)
    }

    /**
     *  Points to <app_data_dir>/no_backup/RuntimeEnabledSdksData/<sdk_package_name>
     */
    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    override fun getNoBackupFilesDir(): File {
        val sdksNoBackupRoot = ensureDirExists(
            Api21.noBackupFilesDir(baseContext),
            SDK_ROOT_FOLDER
        )
        return ensureDirExists(sdksNoBackupRoot, sdkPackageName)
    }

    /**
     *  Points to <app_data_dir>/app_RuntimeEnabledSdksData/<sdk_package_name>/app_<folder_name>
     *  Prefix required to maintain same hierarchy as for applications - when dir could be
     *  accessed by both [getDir] and [getDir]/app_<folder_name>.
     */
    override fun getDir(name: String, mode: Int): File {
        val dirName = "app_$name"
        return ensureDirExists(dataDir, dirName)
    }

    /**
     *  Points to <app_data_dir>/app_RuntimeEnabledSdksData/<sdk_package_name>/files
     */
    override fun getFilesDir(): File {
        return ensureDirExists(dataDir, "files")
    }

    override fun openFileInput(name: String): FileInputStream {
        val file = makeFilename(filesDir, name)
        return FileInputStream(file)
    }

    override fun openFileOutput(name: String, mode: Int): FileOutputStream {
        val file = makeFilename(filesDir, name)
        val append = (mode and MODE_APPEND) != 0
        return FileOutputStream(file, append)
    }

    override fun deleteFile(name: String): Boolean {
        val file = makeFilename(filesDir, name)
        return file.delete()
    }

    override fun getFileStreamPath(name: String): File {
        return makeFilename(filesDir, name)
    }

    override fun fileList(): Array<String> {
        return listOrEmpty(filesDir)
    }

    override fun getDatabasePath(name: String): File {
        if (name[0] == File.separatorChar) {
            return baseContext.getDatabasePath(name)
        }
        val absolutePath = File(getDatabasesDir(), name)
        return baseContext.getDatabasePath(absolutePath.absolutePath)
    }

    override fun openOrCreateDatabase(
        name: String,
        mode: Int,
        factory: SQLiteDatabase.CursorFactory?
    ): SQLiteDatabase {
        return openOrCreateDatabase(name, mode, factory, null)
    }

    override fun openOrCreateDatabase(
        name: String,
        mode: Int,
        factory: SQLiteDatabase.CursorFactory?,
        errorHandler: DatabaseErrorHandler?
    ): SQLiteDatabase {
        return baseContext.openOrCreateDatabase(
            getDatabasePath(name).absolutePath,
            mode,
            factory,
            errorHandler
        )
    }

    @RequiresApi(Build.VERSION_CODES.N)
    override fun moveDatabaseFrom(sourceContext: Context, name: String): Boolean {
        synchronized(SandboxedSdkContextCompat::class.java) {
            val source = sourceContext.getDatabasePath(name)
            val target = getDatabasePath(name)
            return MigrationUtils.moveFiles(
                source.parentFile!!,
                target.parentFile!!,
                source.name
            )
        }
    }

    override fun deleteDatabase(name: String): Boolean {
        return baseContext.deleteDatabase(
            getDatabasePath(name).absolutePath
        )
    }

    override fun databaseList(): Array<String> {
        return listOrEmpty(getDatabasesDir())
    }

    override fun getSharedPreferences(name: String, mode: Int): SharedPreferences {
        return baseContext.getSharedPreferences(
            getSdkSharedPreferenceName(name),
            mode
        )
    }

    /**
     * Only moving between instances of [SandboxedSdkContextCompat] supported.
     * Supporting of other contexts not possible as prefixed name will not be found by
     * internal context implementation.
     * SDK should work ONLY with SDK context.
     */
    @RequiresApi(Build.VERSION_CODES.N)
    override fun moveSharedPreferencesFrom(sourceContext: Context, name: String): Boolean {
        if (sourceContext !is SandboxedSdkContextCompat) {
            return false
        }

        val sourceBaseContext = sourceContext.baseContext
        val sourceBaseDataDir = Api24.dataDir(sourceBaseContext)
        val sourceSharedPreferencesDir = File(sourceBaseDataDir, "shared_prefs")

        val targetBaseDataDir = Api24.dataDir(baseContext)
        val targetSharedPreferencesDir = File(targetBaseDataDir, "shared_prefs")
        if (!targetSharedPreferencesDir.exists()) {
            targetSharedPreferencesDir.mkdir()
        }

        if (sourceSharedPreferencesDir == targetSharedPreferencesDir) {
            return true
        }

        synchronized(SandboxedSdkContextCompat::class.java) {
            val sdkSharedPreferencesName = getSdkSharedPreferenceName(name)
            val moveResult = MigrationUtils.moveFiles(
                sourceSharedPreferencesDir,
                targetSharedPreferencesDir,
                "$sdkSharedPreferencesName.xml"
            )

            if (moveResult) {
                // clean cache in source context
                Api24.deleteSharedPreferences(sourceBaseContext, sdkSharedPreferencesName)
            }

            return moveResult
        }
    }

    @RequiresApi(Build.VERSION_CODES.N)
    override fun deleteSharedPreferences(name: String): Boolean {
        return Api24.deleteSharedPreferences(
            baseContext,
            getSdkSharedPreferenceName(name)
        )
    }

    override fun getClassLoader(): ClassLoader? {
        return classLoader
    }

    private fun getDatabasesDir(): File =
        ensureDirExists(dataDir, "databases")

    private fun getSdkSharedPreferenceName(originalName: String) =
        "${SDK_SHARED_PREFERENCES_PREFIX}_${sdkPackageName}_$originalName"

    private fun listOrEmpty(dir: File?): Array<String> {
        return dir?.list() ?: emptyArray()
    }

    private fun makeFilename(parent: File, name: String): File {
        if (name.indexOf(File.separatorChar) >= 0) {
            throw IllegalArgumentException(
                "File $name contains a path separator"
            )
        }
        return File(parent, name)
    }

    private fun ensureDirExists(parent: File, dirName: String): File {
        val dir = File(parent, dirName)
        if (!dir.exists()) {
            dir.mkdir()
        }
        return dir
    }

    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    private object Api21 {
        @DoNotInline
        fun codeCacheDir(context: Context): File = context.codeCacheDir

        @DoNotInline
        fun noBackupFilesDir(context: Context): File = context.noBackupFilesDir
    }

    @RequiresApi(Build.VERSION_CODES.N)
    private object Api24 {
        @DoNotInline
        fun createDeviceProtectedStorageContext(context: Context): Context =
            context.createDeviceProtectedStorageContext()

        @DoNotInline
        fun dataDir(context: Context): File = context.dataDir

        @DoNotInline
        fun deleteSharedPreferences(context: Context, name: String): Boolean =
            context.deleteSharedPreferences(name)
    }

    private companion object {
        private const val SDK_ROOT_FOLDER = "RuntimeEnabledSdksData"
        private const val SDK_SHARED_PREFERENCES_PREFIX = "RuntimeEnabledSdk"
    }
}