Skip to content

Commit c9e8b82

Browse files
committed
shared-storage: logging and db files utils
1 parent 23fc472 commit c9e8b82

File tree

10 files changed

+178
-86
lines changed

10 files changed

+178
-86
lines changed

gradle/libs.versions.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ android-compileSdk = "36"
44
android-minSdk = "24"
55
android-targetSdk = "36"
66
androidx-activity = "1.10.1"
7-
androidx-appcompat = "1.7.1"
87
androidx-lifecycle = "2.9.3"
98
composeHotReload = "1.0.0-beta06"
109
composeMultiplatform = "1.8.2"
@@ -15,9 +14,9 @@ sqlite = "2.6.0"
1514
ksp = "2.2.10-2.0.2" # must match kotlin
1615
publish = "0.34.0"
1716
skie = "0.10.6"
17+
kermit = "2.0.4"
1818

1919
[libraries]
20-
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
2120
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
2221
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
2322
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
@@ -31,6 +30,7 @@ tests-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", v
3130
tests-turbine = { module = "app.cash.turbine:turbine", version = "1.2.1" }
3231
tests-kotlin = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
3332
tests-junit = { module = "junit:junit", version = "4.13.2" }
33+
logging-kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
3434

3535
[plugins]
3636
androidApplication = { id = "com.android.application", version.ref = "agp" }

shared-storage/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ kotlin {
4949
implementation(libs.androidx.room.runtime)
5050
implementation(libs.androidx.sqlite.bundled)
5151
implementation(libs.kotlinx.coroutines)
52+
implementation(libs.logging.kermit)
5253
}
5354
}
5455

shared-storage/src/androidMain/kotlin/org/asyncstorage/shared_storage/SharedStorage.android.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,30 @@ import kotlinx.coroutines.DelicateCoroutinesApi
88
import kotlinx.coroutines.ExperimentalCoroutinesApi
99
import kotlinx.coroutines.newFixedThreadPoolContext
1010
import kotlinx.coroutines.newSingleThreadContext
11+
import org.asyncstorage.shared_storage.database.DatabaseFiles
1112
import org.asyncstorage.shared_storage.database.StorageDatabase
13+
import org.asyncstorage.shared_storage.database.of
14+
import org.asyncstorage.shared_storage.database.ofInMemory
1215
import kotlin.math.max
1316

1417
@Suppress("unused") // used in consumer app
1518
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
1619
actual fun SharedStorage(context: PlatformContext, databaseName: String): SharedStorage {
1720

18-
val dbFile = context.getDatabasePath(databaseName)
21+
val dbFiles = DatabaseFiles.of(context, databaseName)
1922
val writeDispatcher = newSingleThreadContext("$databaseName-writer")
2023
val readDispatcher =
2124
newFixedThreadPoolContext(getWALConnectionPoolSize(), "$databaseName-reader")
2225

2326
val db =
24-
Room.databaseBuilder<StorageDatabase>(context, name = dbFile.absolutePath)
27+
Room.databaseBuilder<StorageDatabase>(context, name = dbFiles.fileAbsolutePath)
2528
.setDriver(AndroidSQLiteDriver())
2629
.setQueryExecutor(readDispatcher.executor)
2730
.setTransactionExecutor(writeDispatcher.executor)
2831
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
2932
.build()
3033

31-
return SharedStorageImpl(db)
34+
return SharedStorageImpl(db, dbFiles)
3235
}
3336

3437
internal actual fun sharedStorageInMemory(context: PlatformContext): SharedStorage {
@@ -37,7 +40,7 @@ internal actual fun sharedStorageInMemory(context: PlatformContext): SharedStora
3740
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
3841
.build()
3942

40-
return SharedStorageImpl(db)
43+
return SharedStorageImpl(db, DatabaseFiles.ofInMemory())
4144
}
4245

4346
// as per https://blog.p-y.wtf/parallelism-with-android-sqlite
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package org.asyncstorage.shared_storage.database
2+
3+
import android.content.Context
4+
import org.asyncstorage.shared_storage.PlatformContext
5+
6+
actual fun DatabaseFiles.Companion.of(
7+
context: PlatformContext,
8+
databaseName: String,
9+
): DatabaseFiles {
10+
return DatabaseFiles(databaseName, getDatabasePath(context))
11+
}
12+
13+
internal fun DatabaseFiles.Companion.ofInMemory(): DatabaseFiles {
14+
return DatabaseFiles("in-memory", "/")
15+
}
16+
17+
private fun getDatabasePath(ctx: Context): String {
18+
val parent = ctx.getDatabasePath("temp").parentFile!!
19+
return parent.absolutePath.removeSuffix("/")
20+
}

shared-storage/src/appleMain/kotlin/org/asyncstorage/shared_storage/SharedStorage.apple.kt

Lines changed: 8 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,22 @@ package org.asyncstorage.shared_storage
33
import androidx.room.Room
44
import androidx.room.RoomDatabase
55
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
6-
import kotlinx.cinterop.*
6+
import org.asyncstorage.shared_storage.database.DatabaseFiles
77
import org.asyncstorage.shared_storage.database.StorageDatabase
8-
import platform.Foundation.*
8+
import org.asyncstorage.shared_storage.database.of
9+
import org.asyncstorage.shared_storage.database.ofInMemory
910

1011
@Suppress("unused") // used on iOS side
1112
actual fun SharedStorage(context: PlatformContext, databaseName: String): SharedStorage {
12-
val databases = getDatabasesPath()
13-
createDbDirectory(databases)
14-
val dbFilePath = "$databases/$databaseName"
13+
val dbFiles = DatabaseFiles.of(PlatformContext, databaseName)
14+
1515
val db =
16-
Room.databaseBuilder<StorageDatabase>(name = dbFilePath)
16+
Room.databaseBuilder<StorageDatabase>(name = dbFiles.fileAbsolutePath)
1717
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
1818
.setDriver(BundledSQLiteDriver())
1919
.build()
2020

21-
return SharedStorageImpl(database = db)
21+
return SharedStorageImpl(database = db, files = dbFiles)
2222
}
2323

2424
internal actual fun sharedStorageInMemory(context: PlatformContext): SharedStorage {
@@ -28,53 +28,5 @@ internal actual fun sharedStorageInMemory(context: PlatformContext): SharedStora
2828
.setDriver(BundledSQLiteDriver())
2929
.build()
3030

31-
return SharedStorageImpl(database = db)
32-
}
33-
34-
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
35-
private fun createDbDirectory(dir: String) {
36-
val fs = NSFileManager.defaultManager
37-
val url = NSURL.URLWithString(dir)
38-
requireNotNull(url)
39-
40-
if (!fs.fileExistsAtPath(dir)) {
41-
memScoped {
42-
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
43-
fs.createDirectoryAtPath(
44-
path = dir,
45-
withIntermediateDirectories = true,
46-
attributes = null,
47-
error = errorPtr.ptr,
48-
)
49-
errorPtr.value?.let { error(it.localizedDescription) }
50-
}
51-
}
52-
53-
// exclude databases directory from iCloud backup
54-
url.setResourceValue(value = true, forKey = NSURLIsExcludedFromBackupKey, error = null)
55-
}
56-
57-
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
58-
private fun getDatabasesPath(): String {
59-
memScoped {
60-
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
61-
62-
val supportDirUrl =
63-
NSFileManager.defaultManager.URLForDirectory(
64-
directory = NSApplicationSupportDirectory,
65-
inDomain = NSUserDomainMask,
66-
appropriateForURL = null,
67-
create = false,
68-
error = errorPtr.ptr,
69-
)
70-
71-
errorPtr.value?.let { error(it.localizedDescription) }
72-
val supportDir = supportDirUrl?.path
73-
requireNotNull(supportDir)
74-
75-
val bundleId = NSBundle.mainBundle.bundleIdentifier ?: "AsyncStorageDatabases"
76-
val databaseDir = "databases"
77-
78-
return supportDir.removeSuffix("/") + "/${bundleId.removeSuffix("/")}" + "/$databaseDir"
79-
}
31+
return SharedStorageImpl(database = db, DatabaseFiles.ofInMemory())
8032
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package org.asyncstorage.shared_storage.database
2+
3+
import kotlinx.cinterop.*
4+
import org.asyncstorage.shared_storage.PlatformContext
5+
import platform.Foundation.*
6+
7+
actual fun DatabaseFiles.Companion.of(
8+
context: PlatformContext,
9+
databaseName: String,
10+
): DatabaseFiles {
11+
val databasePath = getDatabasesPath()
12+
return DatabaseFiles(databaseName, databasePath).also {
13+
createDbDirectory(it.directoryAbsolutePath)
14+
}
15+
}
16+
17+
internal fun DatabaseFiles.Companion.ofInMemory(): DatabaseFiles {
18+
return DatabaseFiles("in-memory", "/")
19+
}
20+
21+
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
22+
private fun createDbDirectory(dir: String) {
23+
val fs = NSFileManager.defaultManager
24+
val url = NSURL.URLWithString(dir)
25+
requireNotNull(url)
26+
27+
if (!fs.fileExistsAtPath(dir)) {
28+
memScoped {
29+
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
30+
fs.createDirectoryAtPath(
31+
path = dir,
32+
withIntermediateDirectories = true,
33+
attributes = null,
34+
error = errorPtr.ptr,
35+
)
36+
errorPtr.value?.let { error(it.localizedDescription) }
37+
}
38+
}
39+
40+
// exclude databases directory from iCloud backup
41+
url.setResourceValue(value = true, forKey = NSURLIsExcludedFromBackupKey, error = null)
42+
}
43+
44+
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
45+
private fun getDatabasesPath(): String {
46+
memScoped {
47+
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
48+
49+
val supportDirUrl =
50+
NSFileManager.defaultManager.URLForDirectory(
51+
directory = NSApplicationSupportDirectory,
52+
inDomain = NSUserDomainMask,
53+
appropriateForURL = null,
54+
create = false,
55+
error = errorPtr.ptr,
56+
)
57+
58+
errorPtr.value?.let { error(it.localizedDescription) }
59+
val supportDir = supportDirUrl?.path
60+
requireNotNull(supportDir)
61+
62+
val bundleId = NSBundle.mainBundle.bundleIdentifier ?: "AsyncStorageDatabases"
63+
64+
return supportDir.removeSuffix("/") + "/${bundleId.removeSuffix("/")}"
65+
}
66+
}
Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package org.asyncstorage.shared_storage
22

3+
import co.touchlab.kermit.Logger
34
import kotlinx.coroutines.flow.Flow
45
import kotlinx.coroutines.flow.distinctUntilChanged
56
import kotlinx.coroutines.flow.map
7+
import org.asyncstorage.shared_storage.database.DatabaseFiles
68
import org.asyncstorage.shared_storage.database.StorageDatabase
79
import org.asyncstorage.shared_storage.database.StorageEntry
810

@@ -13,33 +15,39 @@ import org.asyncstorage.shared_storage.database.StorageEntry
1315
* row level, non-observed requested keys will trigger emits. Therefor, flow returned has
1416
* .distinctUntilChanged to mimic row level update.
1517
*/
16-
internal class SharedStorageImpl(val database: StorageDatabase) : SharedStorage {
18+
internal class SharedStorageImpl(val database: StorageDatabase, files: DatabaseFiles) :
19+
SharedStorage {
1720

1821
private val storage = database.storageDao()
22+
private val log = Logger.withTag("AsyncStorage:${files.databaseName}")
1923

20-
override suspend fun getValues(keys: List<String>): List<Entry> = catchStorageException {
21-
storage.getValues(keys).map(StorageEntry::toEntry)
24+
init {
25+
log.i { "Storage opened at ${files.directoryAbsolutePath}" }
2226
}
2327

28+
override suspend fun getValues(keys: List<String>): List<Entry> =
29+
catchStorageException(log) { storage.getValues(keys).map(StorageEntry::toEntry) }
30+
2431
override fun getValuesFlow(keys: List<String>): Flow<List<Entry>> =
2532
storage
2633
.getValuesFlow(keys)
2734
.map { list -> list.map(StorageEntry::toEntry) }
2835
.distinctUntilChanged()
29-
.catchStorageException()
36+
.catchStorageException(log)
3037

31-
override suspend fun setValues(entries: List<Entry>): List<Entry> = catchStorageException {
32-
val values = entries.map(Entry::toStorageEntry)
33-
storage.setValuesAndGet(values).map(StorageEntry::toEntry)
34-
}
38+
override suspend fun setValues(entries: List<Entry>): List<Entry> =
39+
catchStorageException(log) {
40+
val values = entries.map(Entry::toStorageEntry)
41+
storage.setValuesAndGet(values).map(StorageEntry::toEntry)
42+
}
3543

36-
override suspend fun removeValues(keys: List<String>) = catchStorageException {
37-
storage.removeValues(keys)
38-
}
44+
override suspend fun removeValues(keys: List<String>) =
45+
catchStorageException(log) { storage.removeValues(keys) }
3946

40-
override suspend fun getKeys(): List<String> = catchStorageException { storage.getKeys() }
47+
override suspend fun getKeys(): List<String> = catchStorageException(log) { storage.getKeys() }
4148

42-
override fun getKeysFlow(): Flow<List<String>> = storage.getKeysFlow().catchStorageException()
49+
override fun getKeysFlow(): Flow<List<String>> =
50+
storage.getKeysFlow().catchStorageException(log)
4351

44-
override suspend fun clear() = catchStorageException { storage.clear() }
52+
override suspend fun clear() = catchStorageException(log) { storage.clear() }
4553
}
Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.asyncstorage.shared_storage
22

33
import androidx.sqlite.SQLiteException
4+
import co.touchlab.kermit.Logger
45
import kotlinx.coroutines.CancellationException
56
import kotlinx.coroutines.flow.Flow
67
import kotlinx.coroutines.flow.catch
@@ -13,28 +14,33 @@ sealed class StorageException(message: String, cause: Throwable?) : Exception(me
1314
class OtherException(message: String, cause: Throwable?) : StorageException(message, cause)
1415
}
1516

16-
internal suspend fun <T> catchStorageException(block: suspend () -> T): T {
17+
internal suspend fun <T> catchStorageException(log: Logger, block: suspend () -> T): T {
1718
try {
1819
return block()
1920
} catch (e: CancellationException) {
21+
log.i { "operation cancelled" }
2022
throw e
2123
} catch (e: Throwable) {
22-
throw e.asStorageException()
24+
throw e.asStorageException(log)
2325
}
2426
}
2527

26-
internal fun <T> Flow<T>.catchStorageException(): Flow<T> = catch { throw it }
28+
internal fun <T> Flow<T>.catchStorageException(log: Logger): Flow<T> = catch {
29+
throw it.asStorageException(log)
30+
}
2731

28-
private fun Throwable.asStorageException(): StorageException {
32+
private fun Throwable.asStorageException(log: Logger): StorageException {
2933
if (this is SQLiteException) {
3034
return StorageException.SqliteException(
31-
message ?: "Unexcepted Sqlite exception: ${this::class.qualifiedName}",
32-
cause,
33-
)
35+
message ?: "Unexcepted Sqlite exception: ${this::class.qualifiedName}",
36+
cause,
37+
)
38+
.also { log.w(throwable = this) { "Sqlite exception caught: ${this.message}" } }
3439
}
3540

3641
return StorageException.OtherException(
37-
message ?: "Unknown storage exception: ${this::class.qualifiedName}",
38-
cause,
39-
)
42+
message ?: "Unknown storage exception: ${this::class.qualifiedName}",
43+
cause,
44+
)
45+
.also { log.w(throwable = this) { "Unknown storage exception caught: ${this.message}" } }
4046
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package org.asyncstorage.shared_storage.database
2+
3+
import org.asyncstorage.shared_storage.PlatformContext
4+
5+
@ConsistentCopyVisibility
6+
data class DatabaseFiles
7+
internal constructor(val databaseName: String, private val platformDbDirectory: String) {
8+
9+
val databaseFileName = databaseName.removeSuffix(".") + ".$EXT_NAME"
10+
11+
val directoryAbsolutePath: String
12+
get() = platformDbDirectory.removeSuffix("/") + "/$GROUP_DIR_NAME" + "/$databaseName"
13+
14+
val fileAbsolutePath: String
15+
get() = directoryAbsolutePath.removeSuffix("/") + "/$databaseFileName"
16+
17+
companion object Companion {
18+
internal const val EXT_NAME = "sqlite"
19+
internal const val GROUP_DIR_NAME = "async-storage"
20+
}
21+
}
22+
23+
expect fun DatabaseFiles.Companion.of(
24+
context: PlatformContext,
25+
databaseName: String,
26+
): DatabaseFiles

0 commit comments

Comments
 (0)