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/build.gradle.kts b/build.gradle.kts index 8466b03..c91b9a9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -160,9 +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 (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 be3c5f4..3883ec6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,8 @@ [versions] +androidx-datastore = "1.1.7" 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" @@ -12,6 +13,8 @@ publish = "0.34.0" [libraries] androidx-collection = { module = "androidx.collection:collection", version = "1.4.3" } +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 +35,9 @@ 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" } +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/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 -> 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>() + } +}