From 4d7e2bda8fc626646574c615bca1a9cae0c57832 Mon Sep 17 00:00:00 2001 From: Nick Williams Date: Mon, 26 Oct 2026 07:06:33 +0000 Subject: [PATCH 1/3] Bump the Firebase Android BoM to 34.18.0 Firestore 26.6.0, Realtime Database 22.0.1, Cloud Functions 22.1.1, Remote Config 23.1.0 and Installations 19.1.2. Functions 22.1.1 is the Kotlin rewrite of the module, in which FirebaseFunctionsException's constructors, Code.fromValue and Code.fromHttpStatus and the HttpsCallableOptions fields are public, as the firebase-kotlin-sdk compatibility layer declares them. --- README.md | 12 ++++++------ gradle/libs.versions.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 470a5b8..d29fdd0 100644 --- a/README.md +++ b/README.md @@ -106,13 +106,13 @@ The following libraries are available for the various Firebase products. | Service or Product | Port of Android version | |---------------------------------------------------------------------------------------------------|:------------------------| -| [Firebase Android BoM](https://maven.google.com/web/index.html?#com.google.firebase:firebase-bom) | `32.7.0` | +| [Firebase Android BoM](https://maven.google.com/web/index.html?#com.google.firebase:firebase-bom) | `34.18.0` | | [Authentication](https://firebase.google.com/docs/auth) | N/A[^1] | -| [Cloud Firestore](https://firebase.google.com/docs/firestore) | `24.10.0` | -| [Realtime Database](https://firebase.google.com/docs/database) | `20.3.0` | -| [Cloud Functions](https://firebase.google.com/docs/functions) | `20.4.0` | -| ~[Remote Config](https://firebase.google.com/docs/remote-config)~ | `21.6.0`[^2] | -| ~[Installations](https://firebase.google.com/docs/projects/manage-installations)~ | `17.2.0`[^2] | +| [Cloud Firestore](https://firebase.google.com/docs/firestore) | `26.6.0` | +| [Realtime Database](https://firebase.google.com/docs/database) | `22.0.1` | +| [Cloud Functions](https://firebase.google.com/docs/functions) | `22.1.1` | +| ~[Remote Config](https://firebase.google.com/docs/remote-config)~ | `23.1.0`[^2] | +| ~[Installations](https://firebase.google.com/docs/projects/manage-installations)~ | `19.1.2`[^2] | [^1]: Google has not open-sourced the Firebase Auth implementation for Android so a basic implementation using the Rest API is provided. [^2]: Although the libraries are inlcuded they are currently not funtional diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be3c5f4..f55507c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] androidx-lifecycle = "2.10.0-alpha01" ben-manes-versions = "0.51.0" -google-firebase-bom = "32.7.0" +google-firebase-bom = "34.18.0" io-grpc = "1.68.0" jlleitschuh-ktlint = "12.1.1" kotlin = "2.0.20" From 9dd1a7ee8ebce9349e74ac20f51593f33d4a97ca Mon Sep 17 00:00:00 2001 From: Nick Williams Date: Mon, 26 Oct 2026 07:45:46 +0000 Subject: [PATCH 2/3] Move to OkHttp 4 and provide the DataStore the firebase-common heartbeat store needs Cloud Functions 22.1.1 is compiled against OkHttp 4, so FirebaseAuth moves to its API too. firebase-common 22 keeps its heartbeat store in a preferences DataStore: the JVM variants of the core artifacts and the Android datastore and datastore-preferences AARs (KMP modules the attribute-less aar configuration picks no variant of) are added, and Context gains filesDir, backed by a new overridable FirebasePlatform.getFilesDir(). --- build.gradle.kts | 7 + gradle/libs.versions.toml | 7 +- src/main/java/android/content/Context.kt | 3 + .../com/google/firebase/FirebasePlatform.kt | 3 + .../com/google/firebase/auth/FirebaseAuth.kt | 143 ++++++++---------- 5 files changed, 79 insertions(+), 84 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 8466b03..8786f4f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -146,6 +146,10 @@ dependencies { aar(libs.google.firebase.config) aar(libs.google.firebase.installations) aar(libs.google.firebase.storage) + // firebase-common 22 keeps its heartbeat store in a preferences DataStore: the Android artifacts are KMP + // modules the attribute-less aar configuration does not pick a variant of, so they are named explicitly. + aar(libs.androidx.datastore.android) + aar(libs.androidx.datastore.preferences.android) // extracted aar dependencies // exclude lifecycle libs due to https://github.com/GitLiveApp/firebase-java-sdk/pull/15 - remove the exclude once the dependencies in the aars are updated to the required version api(fileTree(mapOf("dir" to "build/jar", "include" to listOf("*.jar"), "exclude" to listOf("lifecycle-*")))) @@ -160,6 +164,9 @@ dependencies { implementation(libs.io.grpc.protobuf.lite) implementation(libs.io.grpc.stub) implementation(libs.androidx.collection) + // firebase-common 22 keeps its heartbeat store in a preferences DataStore; the KMP artifacts' JVM variants + implementation(libs.androidx.datastore.core) + implementation(libs.androidx.datastore.preferences.core) implementation(libs.io.grpc.okhttp) implementation(libs.androidx.lifecycle.common) implementation(libs.androidx.lifecycle.viewmodel) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f55507c..27b03c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +androidx-datastore = "1.1.7" androidx-lifecycle = "2.10.0-alpha01" ben-manes-versions = "0.51.0" google-firebase-bom = "34.18.0" @@ -12,6 +13,10 @@ publish = "0.34.0" [libraries] androidx-collection = { module = "androidx.collection:collection", version = "1.4.3" } +androidx-datastore-android = { module = "androidx.datastore:datastore-android", version.ref = "androidx-datastore" } +androidx-datastore-preferences-android = { module = "androidx.datastore:datastore-preferences-android", version.ref = "androidx-datastore" } +androidx-datastore-core = { module = "androidx.datastore:datastore-core", version.ref = "androidx-datastore" } +androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidx-datastore" } androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } google-firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "google-firebase-bom" } @@ -32,7 +37,7 @@ kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -okhttp = { module = "com.squareup.okhttp3:okhttp", version = "3.12.13" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version = "4.12.0" } robolectric-android-all = { module = "org.robolectric:android-all", version = "14-robolectric-10818077" } xerial-sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version = "3.46.1.0" } diff --git a/src/main/java/android/content/Context.kt b/src/main/java/android/content/Context.kt index ef07fd9..36c6081 100644 --- a/src/main/java/android/content/Context.kt +++ b/src/main/java/android/content/Context.kt @@ -34,6 +34,9 @@ open class Context { val noBackupFilesDir: File get() = File(System.getProperty("java.io.tmpdir")) + val filesDir: File + get() = FirebasePlatform.firebasePlatform.getFilesDir() + val classLoader: ClassLoader get() = ClassLoader.getSystemClassLoader() diff --git a/src/main/java/com/google/firebase/FirebasePlatform.kt b/src/main/java/com/google/firebase/FirebasePlatform.kt index 4db9c84..d2bd6ed 100644 --- a/src/main/java/com/google/firebase/FirebasePlatform.kt +++ b/src/main/java/com/google/firebase/FirebasePlatform.kt @@ -24,4 +24,7 @@ abstract class FirebasePlatform { abstract fun log(msg: String) open fun getDatabasePath(name: String): File = File("${System.getProperty("java.io.tmpdir")}${File.separatorChar}$name") + + /** The directory the Firebase SDKs keep their files in, such as the DataStore of the firebase-common heartbeat store. */ + open fun getFilesDir(): File = File(System.getProperty("java.io.tmpdir")) } diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.kt b/src/main/java/com/google/firebase/auth/FirebaseAuth.kt index 15312ba..2e2ccf8 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseAuth.kt +++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.kt @@ -33,10 +33,11 @@ import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import okhttp3.Call import okhttp3.Callback -import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import java.io.IOException import java.util.Base64 @@ -112,7 +113,7 @@ data class FirebaseUserImpl internal constructor( override fun delete(): Task { val source = TaskCompletionSource() - val body = RequestBody.create(FirebaseAuth.getInstance(app).json, JsonObject(mapOf("idToken" to JsonPrimitive(idToken))).toString()) + val body = JsonObject(mapOf("idToken" to JsonPrimitive(idToken))).toString().toRequestBody(FirebaseAuth.getInstance(app).json) val request = Request .Builder() @@ -166,17 +167,14 @@ data class FirebaseUserImpl internal constructor( ): Task { val source = TaskCompletionSource() val body = - RequestBody.create( - FirebaseAuth.getInstance(app).json, - JsonObject( - mapOf( - "idToken" to JsonPrimitive(idToken), - "email" to JsonPrimitive(email), - "newEmail" to JsonPrimitive(newEmail), - "requestType" to JsonPrimitive(OobRequestType.VERIFY_AND_CHANGE_EMAIL.name) - ) - ).toString() - ) + JsonObject( + mapOf( + "idToken" to JsonPrimitive(idToken), + "email" to JsonPrimitive(email), + "newEmail" to JsonPrimitive(newEmail), + "requestType" to JsonPrimitive(OobRequestType.VERIFY_AND_CHANGE_EMAIL.name) + ) + ).toString().toRequestBody(FirebaseAuth.getInstance(app).json) val request = Request .Builder() @@ -237,7 +235,7 @@ data class FirebaseUserImpl internal constructor( class FirebaseAuth constructor( val app: FirebaseApp ) : InternalAuthProvider { - internal val json = MediaType.parse("application/json; charset=utf-8") + internal val json = "application/json; charset=utf-8".toMediaType() internal val client: OkHttpClient = OkHttpClient .Builder() @@ -276,7 +274,7 @@ class FirebaseAuth constructor( createAuthInvalidUserException("accounts", request, response) ) } else { - if (response.body()?.use { it.string() }?.also { responseBody -> + if (response.body?.use { it.string() }?.also { responseBody -> user = setResult(responseBody) source.setResult(AuthResult { user }) } == null @@ -352,7 +350,7 @@ class FirebaseAuth constructor( fun signInAnonymously(): Task { val source = enqueueAuthPost( url = "identitytoolkit.googleapis.com/v1/accounts:signUp", - body = RequestBody.create(json, JsonObject(mapOf("returnSecureToken" to JsonPrimitive(true))).toString()), + body = JsonObject(mapOf("returnSecureToken" to JsonPrimitive(true))).toString().toRequestBody(json), setResult = { responseBody -> FirebaseUserImpl(app, jsonParser.parseToJsonElement(responseBody).jsonObject, isAnonymous = true) } @@ -363,10 +361,7 @@ class FirebaseAuth constructor( fun signInWithCustomToken(customToken: String): Task { val source = enqueueAuthPost( url = "www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken", - body = RequestBody.create( - json, - JsonObject(mapOf("token" to JsonPrimitive(customToken), "returnSecureToken" to JsonPrimitive(true))).toString() - ), + body = JsonObject(mapOf("token" to JsonPrimitive(customToken), "returnSecureToken" to JsonPrimitive(true))).toString().toRequestBody(json), setResult = { responseBody -> FirebaseUserImpl(app, jsonParser.parseToJsonElement(responseBody).jsonObject) } @@ -378,10 +373,7 @@ class FirebaseAuth constructor( val source = TaskCompletionSource() val body = - RequestBody.create( - json, - JsonObject(mapOf("idToken" to JsonPrimitive(user?.idToken))).toString() - ) + JsonObject(mapOf("idToken" to JsonPrimitive(user?.idToken))).toString().toRequestBody(json) val request = Request .Builder() @@ -411,7 +403,7 @@ class FirebaseAuth constructor( val newBody = jsonParser .parseToJsonElement( - response.body()?.use { it.string() } ?: "" + response.body?.use { it.string() } ?: "" ).jsonObject user = user?.copy( @@ -436,16 +428,13 @@ class FirebaseAuth constructor( enqueueAuthPost( url = "www.googleapis.com/identitytoolkit/v3/relyingparty/signupNewUser", body = - RequestBody.create( - json, - JsonObject( - mapOf( - "email" to JsonPrimitive(email), - "password" to JsonPrimitive(password), - "returnSecureToken" to JsonPrimitive(true) - ) - ).toString() - ), + JsonObject( + mapOf( + "email" to JsonPrimitive(email), + "password" to JsonPrimitive(password), + "returnSecureToken" to JsonPrimitive(true) + ) + ).toString().toRequestBody(json), setResult = { responseBody -> FirebaseUserImpl( app = app, @@ -464,16 +453,13 @@ class FirebaseAuth constructor( enqueueAuthPost( url = "www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword", body = - RequestBody.create( - json, - JsonObject( - mapOf( - "email" to JsonPrimitive(email), - "password" to JsonPrimitive(password), - "returnSecureToken" to JsonPrimitive(true) - ) - ).toString() - ), + JsonObject( + mapOf( + "email" to JsonPrimitive(email), + "password" to JsonPrimitive(password), + "returnSecureToken" to JsonPrimitive(true) + ) + ).toString().toRequestBody(json), setResult = { responseBody -> FirebaseUserImpl(app, jsonParser.parseToJsonElement(responseBody).jsonObject) } @@ -486,7 +472,7 @@ class FirebaseAuth constructor( request: Request, response: Response ): FirebaseAuthInvalidUserException { - val body = response.body()!!.use { it.string() } + val body = response.body!!.use { it.string() } val jsonObject = jsonParser.parseToJsonElement(body).jsonObject return FirebaseAuthInvalidUserException( @@ -497,20 +483,20 @@ class FirebaseAuth constructor( ?.contentOrNull ?: "UNKNOWN_ERROR", "$action API returned an error, " + - "with url [${request.method()}] ${request.url()} ${request.body()} -- " + - "response [${response.code()}] ${response.message()} $body" + "with url [${request.method}] ${request.url} ${request.body} -- " + + "response [${response.code}] ${response.message} $body" ) } fun signOut() { // cancel token refresher client - .dispatcher() + .dispatcher .queuedCalls() .find { it.request().tag() == REFRESH_TOKEN_TAG } ?.cancel() ?: { client - .dispatcher() + .dispatcher .runningCalls() .find { it.request().tag() == REFRESH_TOKEN_TAG } ?.cancel() @@ -548,15 +534,12 @@ class FirebaseAuth constructor( private fun enqueueRefreshTokenCall(user: FirebaseUserImpl): TaskCompletionSource { val source = TaskCompletionSource() val body = - RequestBody.create( - json, - JsonObject( - mapOf( - "refresh_token" to JsonPrimitive(user.refreshToken), - "grant_type" to JsonPrimitive("refresh_token") - ) - ).toString() - ) + JsonObject( + mapOf( + "refresh_token" to JsonPrimitive(user.refreshToken), + "grant_type" to JsonPrimitive("refresh_token") + ) + ).toString().toRequestBody(json) val request = Request .Builder() @@ -579,7 +562,7 @@ class FirebaseAuth constructor( call: Call, response: Response ) { - val responseBody = response.body()?.use { it.string() } + val responseBody = response.body?.use { it.string() } if (!response.isSuccessful || responseBody == null) { signOutAndThrowInvalidUserException(responseBody.orEmpty(), "token API returned an error: $body") @@ -615,16 +598,13 @@ class FirebaseAuth constructor( val source = TaskCompletionSource() val body = - RequestBody.create( - json, - JsonObject( - mapOf( - "idToken" to JsonPrimitive(user?.idToken), - "email" to JsonPrimitive(email), - "returnSecureToken" to JsonPrimitive(true) - ) - ).toString() - ) + JsonObject( + mapOf( + "idToken" to JsonPrimitive(user?.idToken), + "email" to JsonPrimitive(email), + "returnSecureToken" to JsonPrimitive(true) + ) + ).toString().toRequestBody(json) val request = Request .Builder() @@ -659,7 +639,7 @@ class FirebaseAuth constructor( val newBody = jsonParser .parseToJsonElement( - response.body()?.use { it.string() } ?: "" + response.body?.use { it.string() } ?: "" ).jsonObject user?.let { prev -> @@ -689,17 +669,14 @@ class FirebaseAuth constructor( val source = TaskCompletionSource() val body = - RequestBody.create( - json, - JsonObject( - mapOf( - "idToken" to JsonPrimitive(user?.idToken), - "displayName" to JsonPrimitive(request.displayName), - "photoUrl" to JsonPrimitive(request.photoUrl), - "returnSecureToken" to JsonPrimitive(true) - ) - ).toString() - ) + JsonObject( + mapOf( + "idToken" to JsonPrimitive(user?.idToken), + "displayName" to JsonPrimitive(request.displayName), + "photoUrl" to JsonPrimitive(request.photoUrl), + "returnSecureToken" to JsonPrimitive(true) + ) + ).toString().toRequestBody(json) val req = Request .Builder() @@ -734,7 +711,7 @@ class FirebaseAuth constructor( val newBody = jsonParser .parseToJsonElement( - response.body()?.use { it.string() } ?: "" + response.body?.use { it.string() } ?: "" ).jsonObject user?.let { prev -> From e8535df0092436fbc0b457835552c16d8c4674ea Mon Sep 17 00:00:00 2001 From: Nick Williams Date: Mon, 26 Oct 2026 10:10:13 +0000 Subject: [PATCH 3/3] Ship the plain-jar dependencies Functions and Firestore need, and keep one heartbeat DataStore per file Functions 22 returns a Reactive Streams Publisher from HttpsCallableReference.stream() and Firestore 26 evaluates regex and like filters with re2j: both are plain jars the aar configuration resolves but never shipped, so they become api and implementation dependencies. firebase-common's JavaDataStorage creates a DataStore per instance through the Android preferencesDataStore delegate, and a DataStore stays active for the life of the process, so a FirebaseApp initialised again after delete() or clearInstancesForTest() failed with "multiple DataStores active for the same file". The port replaces the class with one that keeps a single DataStore per file, built with the core PreferenceDataStoreFactory, which also drops the need for the Android datastore AARs. --- build.gradle.kts | 11 +-- gradle/libs.versions.toml | 4 +- .../firebase/datastorage/JavaDataStorage.kt | 76 +++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/google/firebase/datastorage/JavaDataStorage.kt diff --git a/build.gradle.kts b/build.gradle.kts index 8786f4f..c91b9a9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -146,10 +146,6 @@ dependencies { aar(libs.google.firebase.config) aar(libs.google.firebase.installations) aar(libs.google.firebase.storage) - // firebase-common 22 keeps its heartbeat store in a preferences DataStore: the Android artifacts are KMP - // modules the attribute-less aar configuration does not pick a variant of, so they are named explicitly. - aar(libs.androidx.datastore.android) - aar(libs.androidx.datastore.preferences.android) // extracted aar dependencies // exclude lifecycle libs due to https://github.com/GitLiveApp/firebase-java-sdk/pull/15 - remove the exclude once the dependencies in the aars are updated to the required version api(fileTree(mapOf("dir" to "build/jar", "include" to listOf("*.jar"), "exclude" to listOf("lifecycle-*")))) @@ -164,12 +160,17 @@ dependencies { implementation(libs.io.grpc.protobuf.lite) implementation(libs.io.grpc.stub) implementation(libs.androidx.collection) - // firebase-common 22 keeps its heartbeat store in a preferences DataStore; the KMP artifacts' JVM variants + // firebase-common 22 keeps its heartbeat store in a preferences DataStore (see com.google.firebase.datastorage.JavaDataStorage) implementation(libs.androidx.datastore.core) implementation(libs.androidx.datastore.preferences.core) implementation(libs.io.grpc.okhttp) implementation(libs.androidx.lifecycle.common) implementation(libs.androidx.lifecycle.viewmodel) + // Plain-jar dependencies of the bumped libraries that the aar configuration resolves but does not ship: Functions + // 22 returns a Reactive Streams Publisher from HttpsCallableReference.stream(), Firestore 26 evaluates regex and + // like filters with re2j. + api(libs.reactive.streams) + implementation(libs.re2j) } tasks.named("publishToMavenLocal").configure { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27b03c8..3883ec6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,8 +13,6 @@ publish = "0.34.0" [libraries] androidx-collection = { module = "androidx.collection:collection", version = "1.4.3" } -androidx-datastore-android = { module = "androidx.datastore:datastore-android", version.ref = "androidx-datastore" } -androidx-datastore-preferences-android = { module = "androidx.datastore:datastore-preferences-android", version.ref = "androidx-datastore" } androidx-datastore-core = { module = "androidx.datastore:datastore-core", version.ref = "androidx-datastore" } androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidx-datastore" } androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "androidx-lifecycle" } @@ -38,6 +36,8 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } okhttp = { module = "com.squareup.okhttp3:okhttp", version = "4.12.0" } +re2j = { module = "com.google.re2j:re2j", version = "1.6" } +reactive-streams = { module = "org.reactivestreams:reactive-streams", version = "1.0.4" } robolectric-android-all = { module = "org.robolectric:android-all", version = "14-robolectric-10818077" } xerial-sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version = "3.46.1.0" } diff --git a/src/main/java/com/google/firebase/datastorage/JavaDataStorage.kt b/src/main/java/com/google/firebase/datastorage/JavaDataStorage.kt new file mode 100644 index 0000000..6596ed9 --- /dev/null +++ b/src/main/java/com/google/firebase/datastorage/JavaDataStorage.kt @@ -0,0 +1,76 @@ +package com.google.firebase.datastorage + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import com.google.firebase.FirebasePlatform +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Replaces firebase-common's `JavaDataStorage`, the blocking facade over a preferences DataStore that the heartbeat + * store uses, with one that keeps a single [DataStore] per file for the process. + * + * The original creates a new DataStore per instance through the Android `preferencesDataStore` delegate, and a + * DataStore stays active for the life of the process, so a [com.google.firebase.FirebaseApp] initialised again after + * `delete()` or `clearInstancesForTest()` (as test suites do) failed with "There are multiple DataStores active for + * the same file". The file lives under [Context.filesDir], which [FirebasePlatform.getFilesDir] provides, and no + * SharedPreferences migration runs: this port never kept the heartbeats there. + */ +class JavaDataStorage(val context: Context, val name: String) { + private val dataStore: DataStore = stores.computeIfAbsent(file().absolutePath) { path -> + PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler { ex -> + FirebasePlatform.firebasePlatform.log("CorruptionException in $name DataStore: $ex") + emptyPreferences() + }, + produceFile = { File(path) } + ) + } + + private val editLock = ThreadLocal() + + private fun file(): File = File(context.filesDir, "datastore${File.separatorChar}$name.preferences_pb") + + fun getSync(key: Preferences.Key, defaultValue: T): T = runBlocking { + dataStore.data.firstOrNull()?.get(key) ?: defaultValue + } + + fun contains(key: Preferences.Key): Boolean = runBlocking { + dataStore.data.firstOrNull()?.contains(key) ?: false + } + + fun putSync(key: Preferences.Key, value: T): Preferences = runBlocking { + dataStore.edit { it[key] = value } + } + + fun getAllSync(): Map, Any> = runBlocking { + dataStore.data.firstOrNull()?.asMap() ?: emptyMap() + } + + fun editSync(transform: (MutablePreferences) -> Unit): Preferences { + check(editLock.get() != true) { "Don't call JavaDataStorage.editSync from within a transform block" } + return runBlocking { + dataStore.edit { + editLock.set(true) + try { + transform(it) + } finally { + editLock.set(false) + } + } + } + } + + private companion object { + /** One DataStore per file for the process: a second one on the same file is an error in DataStore. */ + val stores = ConcurrentHashMap>() + } +}