Skip to content

Commit

Permalink
Generic Storage interface for use on all client and on the server.
Browse files Browse the repository at this point in the history
Signed-off-by: Peter Sorotokin <[email protected]>
  • Loading branch information
sorotokin committed Dec 30, 2024
1 parent 7839a91 commit d9c0fc5
Show file tree
Hide file tree
Showing 26 changed files with 2,501 additions and 0 deletions.
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ zxing = "3.5.3"
gretty = "4.1.4"
hsqldb = "2.7.2"
mysql = "8.0.16"
postgresql = "42.7.4"
compose-junit4 = "1.6.8"
compose-test-manifest = "1.6.8"
androidx-fragment = "1.8.0"
Expand All @@ -61,6 +62,7 @@ cameraLifecycle = "1.3.4"
buildconfig = "5.3.5"
qrose = "1.0.1"
easyqrscan = "0.2.0"
androidx-sqlite = "2.5.0-alpha12"

[libraries]
face-detection = { module = "com.google.mlkit:face-detection", version.ref = "faceDetection" }
Expand Down Expand Up @@ -135,6 +137,10 @@ jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifec
camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraLifecycle" }
qrose = { group = "io.github.alexzhirkevich", name = "qrose", version.ref="qrose"}
easyqrscan = { module = "io.github.kalinjul.easyqrscan:scanner", version.ref = "easyqrscan" }
androidx-sqlite = { module="androidx.sqlite:sqlite", version.ref = "androidx-sqlite" }
androidx-sqlite-framework = { module="androidx.sqlite:sqlite-framework", version.ref = "androidx-sqlite" }
androidx-sqlite-bundled = { module="androidx.sqlite:sqlite-bundled", version.ref = "androidx-sqlite" }
postgresql = { module="org.postgresql:postgresql", version.ref = "postgresql" }

[bundles]
google-play-services = ["play-services-base", "play-services-basement", "play-services-tasks"]
Expand Down
39 changes: 39 additions & 0 deletions identity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,45 @@ kotlin {
implementation(libs.tink)
}
}

val appleMain by getting {
dependencies {
// This dependency is needed for SqliteStorage implementation.
// KMP-compatible version is still alpha and it is not compatible with
// other androidx packages, particularly androidx.work that we use in wallet.
// TODO: once compatibility issues are resolved, SqliteStorage and this
// dependency can be moved into commonMain.
implementation(libs.androidx.sqlite)
}
}

val jvmTest by getting {
dependencies {
implementation(libs.hsqldb)
implementation(libs.mysql)
implementation(libs.postgresql)
}
}

val androidInstrumentedTest by getting {
dependsOn(commonTest)
dependencies {
implementation(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.framework)
implementation(libs.androidx.sqlite.bundled)
implementation(libs.androidx.test.junit)
implementation(libs.androidx.espresso.core)
implementation(libs.compose.junit4)
}
}

val appleTest by getting {
dependencies {
implementation(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.framework)
implementation(libs.androidx.sqlite.bundled)
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.android.identity.storage

import android.app.Instrumentation
import androidx.test.platform.app.InstrumentationRegistry
import com.android.identity.storage.android.AndroidStorage
import com.android.identity.storage.ephemeral.EphemeralStorage
import kotlinx.datetime.Clock
import java.io.File

/**
* Creates a list of empty [Storage] objects for testing.
*/
actual fun createTransientStorageList(testClock: Clock): List<Storage> {
return listOf<Storage>(
EphemeralStorage(testClock),
/*
TODO: this can be enabled once SqliteStorage is moved into commonMain
com.android.identity.storage.sqlite.SqliteStorage(
connection = AndroidSQLiteDriver().open(":memory:"),
clock = testClock
),
com.android.identity.storage.sqlite.SqliteStorage(
connection = BundledSQLiteDriver().open(":memory:"),
clock = testClock,
// bundled sqlite crashes when used with Dispatchers.IO
coroutineContext = newSingleThreadContext("DB")
),
*/
AndroidStorage(
databasePath = null,
clock = testClock,
keySize = 3
)
)
}

val knownNames = mutableSetOf<String>()

actual fun createPersistentStorage(name: String, testClock: Clock): Storage? {
val context = InstrumentationRegistry.getInstrumentation().context
val dbFile = context.getDatabasePath("$name.db")
if (knownNames.add(name)) {
dbFile.delete()
}
return AndroidStorage(
databasePath = dbFile.absolutePath,
clock = testClock,
keySize = 3
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.android.identity.storage.android

import android.database.sqlite.SQLiteDatabase
import com.android.identity.storage.Storage
import com.android.identity.storage.base.BaseStorage
import com.android.identity.storage.base.BaseStorageTable
import com.android.identity.storage.StorageTableSpec
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.datetime.Clock
import kotlin.coroutines.CoroutineContext

/**
* [Storage] implementation based on Android [SQLiteDatabase] API.
*/
class AndroidStorage: BaseStorage {
private val coroutineContext: CoroutineContext
private val databaseFactory: () -> SQLiteDatabase
internal val keySize: Int
private var database: SQLiteDatabase? = null

constructor(
database: SQLiteDatabase,
clock: Clock,
coroutineContext: CoroutineContext = Dispatchers.IO,
keySize: Int = 9
): super(clock) {
this.database = database
databaseFactory = { throw IllegalStateException("unexpected call") }
this.coroutineContext = coroutineContext
this.keySize = keySize
}

constructor(
databasePath: String?,
clock: Clock,
coroutineContext: CoroutineContext = Dispatchers.IO,
keySize: Int = 9
): super(clock) {
databaseFactory = {
SQLiteDatabase.openOrCreateDatabase(databasePath ?: ":memory:", null)
}
this.coroutineContext = coroutineContext
this.keySize = keySize
}

override suspend fun createTable(tableSpec: StorageTableSpec): BaseStorageTable {
if (database == null) {
database = databaseFactory()
}
val table = AndroidStorageTable(this, tableSpec)
table.init()
return table
}

internal suspend fun<T> withDatabase(
block: suspend CoroutineScope.(database: SQLiteDatabase) -> T
): T {
return CoroutineScope(coroutineContext).async {
block(database!!)
}.await()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package com.android.identity.storage.android

import android.content.ContentValues
import android.database.AbstractWindowedCursor
import android.database.CursorWindow
import android.os.Build
import com.android.identity.storage.KeyExistsStorageException
import com.android.identity.storage.NoRecordStorageException
import com.android.identity.storage.base.BaseStorageTable
import com.android.identity.storage.StorageTableSpec
import com.android.identity.storage.base.SqlStatementMaker
import com.android.identity.util.toBase64Url
import kotlinx.datetime.Instant
import kotlinx.io.bytestring.ByteString
import kotlin.random.Random

internal class AndroidStorageTable(
private val owner: AndroidStorage,
spec: StorageTableSpec
): BaseStorageTable(spec) {
private val sql = SqlStatementMaker(
spec,
textType = "TEXT",
blobType = "BLOB",
longType = "INTEGER",
useReturningClause = false,
collationCharset = null
)

suspend fun init() {
owner.withDatabase { database ->
database.execSQL(sql.createTableStatement)
}
}

override suspend fun get(key: String, partitionId: String?): ByteString? {
checkPartition(partitionId)
return owner.withDatabase { database ->
val cursor = database.query(
sql.tableName,
arrayOf("data"),
sql.conditionWithExpiration(owner.clock.now().epochSeconds),
whereArgs(key, partitionId),
null,
null,
null
)
// TODO: Older OS versions don't support setting the cursor window size.
// What should we do with older OS versions?
// Also note that a large window size may lead to longer delays when loading from the
// database. And if we keep this, replace the magic number with a constant.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// The default window size of 2MB is too small for video files.
(cursor as? AbstractWindowedCursor)?.window = CursorWindow(
"Larger Window", 256 * 1024 * 1024)
}
if (cursor.moveToFirst()) {
val bytes = cursor.getBlob(0)
cursor.close()
ByteString(bytes)
} else {
cursor.close()
null
}
}
}

override suspend fun insert(
key: String?,
data: ByteString,
partitionId: String?,
expiration: Instant
): String {
if (key != null) {
checkKey(key)
}
checkPartition(partitionId)
checkExpiration(expiration)
return owner.withDatabase { database ->
if (key != null && spec.supportExpiration) {
// if there is an entry with this key, but it is expired, it needs to be purged.
// Purging expired keys does not interfere with operation atomicity
database.delete(
sql.tableName,
sql.purgeExpiredWithIdCondition(owner.clock.now().epochSeconds),
whereArgs(key, partitionId)
)
}
var newKey: String
var done = false
do {
newKey = key ?: Random.nextBytes(owner.keySize).toBase64Url()
val values = ContentValues().apply {
put("id", newKey)
if (spec.supportPartitions) {
put("partitionId", partitionId)
}
if (spec.supportExpiration) {
put("expiration", expiration.epochSeconds)
}
put("data", data.toByteArray())
}
val rowId = database.insert(sql.tableName, null, values)
if (rowId >= 0) {
done = true
} else if (key != null) {
throw KeyExistsStorageException(
"Record with ${recordDescription(key, partitionId)} already exists")
}
} while (!done)
newKey
}
}

override suspend fun update(
key: String,
data: ByteString,
partitionId: String?,
expiration: Instant?
) {
checkPartition(partitionId)
if (expiration != null) {
checkExpiration(expiration)
}
owner.withDatabase { database ->
val nowSeconds = owner.clock.now().epochSeconds
val values = ContentValues().apply {
if (expiration != null) {
put("expiration", expiration.epochSeconds)
}
put("data", data.toByteArray())
}
val count = database.update(
sql.tableName,
values,
sql.conditionWithExpiration(nowSeconds),
whereArgs(key, partitionId)
)
if (count != 1) {
throw NoRecordStorageException(
"No record with ${recordDescription(key, partitionId)}")
}
}
}

override suspend fun delete(key: String, partitionId: String?): Boolean {
checkPartition(partitionId)
return owner.withDatabase { database ->
val nowSeconds = owner.clock.now().epochSeconds
val count = database.delete(
sql.tableName,
sql.conditionWithExpiration(nowSeconds),
whereArgs(key, partitionId)
)
count > 0
}
}

override suspend fun deleteAll() {
owner.withDatabase { database ->
database.execSQL(sql.deleteAllStatement)
}
}

override suspend fun enumerate(
partitionId: String?,
afterKey: String?,
limit: Int
): List<String> {
checkPartition(partitionId)
return owner.withDatabase { database ->
val cursor = database.query(
sql.tableName,
arrayOf("id"),
sql.enumerateConditionWithExpiration(owner.clock.now().epochSeconds),
whereArgs(afterKey ?: "", partitionId),
null,
null,
"id",
if (limit < Int.MAX_VALUE) "0, $limit" else null
)
val list = mutableListOf<String>()
while (cursor.moveToNext()) {
list.add(cursor.getString(0))
}
cursor.close()
list
}
}

override suspend fun purgeExpired() {
owner.withDatabase { database ->
database.execSQL(sql.purgeExpiredStatement
.replace("?", owner.clock.now().epochSeconds.toString()))
}
}

private fun whereArgs(key: String, partitionId: String?): Array<String> {
return if (spec.supportPartitions) {
arrayOf(key, partitionId!!)
} else {
arrayOf(key)
}
}
}
Loading

0 comments on commit d9c0fc5

Please sign in to comment.