From e8c9652618fcb1bbb67f2abc54d62a42c8e4e658 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:22:14 +0300 Subject: [PATCH 01/33] Add Remote Config support design spec --- .../specs/2026-10-07-remote-config-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-07-remote-config-design.md diff --git a/docs/superpowers/specs/2026-10-07-remote-config-design.md b/docs/superpowers/specs/2026-10-07-remote-config-design.md new file mode 100644 index 0000000..cddb7c5 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-remote-config-design.md @@ -0,0 +1,152 @@ +# Remote Config support — design + +Date: 2026-10-07 +Branch: `feature/remote-config` + +## Goal + +Make Firebase Remote Config usable on the JVM through this SDK. The Android +`firebase-config` 21.6.0 and `firebase-installations` 17.2.0 libraries already +ship in the jar (see `build.gradle.kts`), but the README marks both as not +functional. This work fills the Android shims they depend on so the unmodified +Android code runs. + +Success means `Firebase.remoteConfig(app)` supports `setDefaultsAsync(Map)`, +`fetch`, `activate`, `fetchAndActivate`, `get*` values, `getAll`, +`getKeysByPrefix`, `getInfo`, `setConfigSettingsAsync`, and `reset` against the +live `fir-java-sdk` project. The same behaviour lets the JVM target of +[firebase-kotlin-sdk](https://github.com/GitLiveApp/firebase-kotlin-sdk) pass its +`firebase-config` common tests. + +## Scope + +In scope: the APIs listed above. + +Out of scope: + +- Realtime updates (`addOnConfigUpdateListener`). +- `setDefaultsAsync(@XmlRes int)`, which needs Android XML resources. +- Real `PackageManager.getPackageInfo` data. Both libraries catch the + `NameNotFoundException` it throws today. + +## Approach + +Fill the shims; do not reimplement Remote Config. Firestore, Database, +Functions, and Storage already work this way. A REST-based rewrite like Auth's +would duplicate code Google ships and drift from it. Auth took that route only +because its Android source is closed. + +The Admin SDK ([firebase-admin-java](https://github.com/firebase/firebase-admin-java)) +is not a reference for this work. It manages templates +(`/v1/projects/{id}/remoteConfig`) and evaluates server templates +(`namespaces/firebase-server/serverRemoteConfig`) with service-account OAuth. +Client fetch uses `namespaces/firebase:fetch` with the API key and a Firebase +Installations token, which the bundled Android library already implements. + +## Gaps found + +Found by scanning the extracted jars with `javap` for Android API calls and +comparing against `src/main/java/android/**`: + +1. `PackageManager.getServiceInfo` does not list `RemoteConfigRegistrar` or + `AbtRegistrar`, so component discovery cannot create Remote Config. +2. `Context` lacks `getFilesDir`, `openFileInput`, `openFileOutput`, and + `deleteFile`. Remote Config stores fetched, activated, and default configs as + files; Installations stores `PersistedInstallation..json`. +3. `Context.getSharedPreferences` returns one object that accepts only + whitelisted keys and throws `IllegalArgumentException` otherwise. Remote + Config uses `frc__firebase_settings`; Installations reads + `com.google.android.gms.appid`. +4. The `SharedPreferences` interface lacks `getInt`, `Editor.putInt`, and + `Editor.clear`, all called by Remote Config. +5. Missing classes or members: `android.util.JsonReader`, + `android.net.TrafficStats`, `android.text.format.DateUtils.formatElapsedTime`, + `android.content.res.Configuration` (with `locale`), and + `Resources.getConfiguration()`. + +## Components + +| Change | File | Purpose | +|---|---|---| +| Register `com.google.firebase.remoteconfig.RemoteConfigRegistrar` and `com.google.firebase.abt.component.AbtRegistrar` | `android/content/pm/PackageManager.java` | Component discovery | +| `open fun getFilesDir(): File`, default `File(java.io.tmpdir, "firebase-files")` | `com/google/firebase/FirebasePlatform.kt` | User-configurable location for persisted files; non-breaking | +| `filesDir`, `openFileInput`, `openFileOutput`, `deleteFile` | `android/content/Context.kt` | Resolve names inside `FirebasePlatform.getFilesDir()` | +| `getInt`, `Editor.putInt`, `Editor.clear` | `android/content/SharedPreferences.java` | Interface methods Remote Config calls | +| Platform-backed preferences for files named `frc_*` and `com.google.android.gms.appid` | `android/content/PlatformSharedPreferences.kt` | Store each key as `"\|"` via `FirebasePlatform.store/retrieve/clear`; `clear()` removes only that file's keys | +| Route those two file names to `PlatformSharedPreferences` | `android/content/Context.kt` | All other files keep the strict whitelist, which also gains throwing `getInt`/`putInt`/`clear` | +| `JsonReader` and its `JsonToken`/`JsonScope` support | `android/util/` | Port from AOSP (Apache 2.0), like the other ported shims | +| `TrafficStats` with no-op `setThreadStatsTag`/`clearThreadStatsTag` | `android/net/TrafficStats.java` | Installations tags its network thread | +| `DateUtils.formatElapsedTime(long)` | `android/text/format/DateUtils.java` | Throttling messages in `ConfigFetchHandler` | +| `Configuration` with `locale = Locale.getDefault()`; `Resources.getConfiguration()` | `android/content/res/` | Locale sent in the fetch request | +| Remove strikethrough and footnote 2 for Remote Config and Installations; document `getFilesDir` beside `getDatabasePath` | `README.md` | User docs | + +`clear()` must enumerate a file's keys, but `FirebasePlatform` exposes no key +listing. `PlatformSharedPreferences` therefore stores the set of keys it wrote +under `"|__keys"` and clears each one. + +## Data flow: `fetchAndActivate()` + +1. `RemoteConfigRegistrar` builds the component. Fetch metadata lives in + `frc__firebase_settings` (platform-backed preferences). +2. Installations reads `com.google.android.gms.appid` (empty on the JVM), + generates a FID, registers it with `firebaseinstallations.googleapis.com`, + parses the response with `JsonReader`, and writes + `PersistedInstallation..json` to `getFilesDir()`. +3. `ConfigFetchHttpClient` posts to `firebaseremoteconfig.googleapis.com` + with the FID, its auth token, and the default locale. +4. Remote Config writes the fetched, activated, and default configs to + `getFilesDir()` through `openFileOutput` and reads them with + `openFileInput`. + +## Error handling + +No new error handling. Failures reach callers as they do on Android: +`FirebaseRemoteConfigFetchException` or `FirebaseInstallationsException` on the +returned `Task`. Preference files outside the two platform-backed ones still +throw on unknown keys, so new usages fail loudly. If a runtime gap appears that +the bytecode scan missed, work stops and the gap is reported before any fix. + +## Testing + +TDD: each test is written and seen failing for the expected reason before its +shim exists. + +`src/test/kotlin/FirebaseRemoteConfigTest.kt` extends `FirebaseTest` and ports +the firebase-kotlin-sdk common tests +(`firebase-config/src/commonTest/.../FirebaseRemoteConfig.kt`) one-to-one onto +the Android API, keeping names, keys, values, and assertions: + +| Test | Android API used | +|---|---| +| `testGettingValues` | `setDefaultsAsync(map)`, `getBoolean`/`getDouble`/`getLong`/`getString`, `getValue(..).source == VALUE_SOURCE_DEFAULT`, `asByteArray()` | +| `testNamedApp` | Second app `"named"` with a different application ID; its defaults stay out of the default app's `all` | +| `testGetAll` | `all[..]?.asBoolean()` etc. | +| `testGetKeysByPrefix` | Four `test_default_*` keys | +| `testGetInfo` | `fetchTimeMillis == -1`, `lastFetchStatus == LAST_FETCH_STATUS_NO_FETCH_YET`, default settings | +| `testSetConfigSettings` | 42 s timeout and minimum interval round-trip through `info.configSettings` | +| `testFetch`, `testFetchAndActivate` | `@Ignore`, as upstream: they need `test_remote_string = "Hello from remote!"` published in the console | + +Teardown calls `reset().await()` before `FirebaseTest` clears the apps. + +One added live test, `fetchAndActivate succeeds`, asserts +`info.lastFetchStatus == LAST_FETCH_STATUS_SUCCESS` against `fir-java-sdk`. It +exercises the Installations and fetch network path without console changes. It +does not prove remote values are applied; the ignored tests cover that once a +parameter is published. + +Shim unit tests: + +- `PlatformSharedPreferences`: round-trip of string, int, and long; isolation + between two files; `clear()` removes only its own file's keys. +- `Context` file APIs: write, read, and delete inside a temporary + `getFilesDir()`. +- `JsonReader`: parses an Installations-shaped response. + +Gradle runs with `JAVA_HOME` set to JDK 17; Kotlin 2.0.20 fails to parse the +JDK 25 version string. The full suite must pass with clean output. + +## Possible follow-up + +Publishing `test_remote_string` would let the two ignored fetch tests run. A +fixture could do this with the Admin SDK, but it needs service-account +credentials in the test environment, so it stays out of this work. From b097227635d72435b567cf60aa7cfc74f5913d44 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:31:52 +0300 Subject: [PATCH 02/33] Add Remote Config implementation plan --- .../plans/2026-10-07-remote-config.md | 1020 +++++++++++++++++ 1 file changed, 1020 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-07-remote-config.md diff --git a/docs/superpowers/plans/2026-10-07-remote-config.md b/docs/superpowers/plans/2026-10-07-remote-config.md new file mode 100644 index 0000000..6cc68c4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-remote-config.md @@ -0,0 +1,1020 @@ +# Remote Config Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the bundled Android Remote Config 21.6.0 and Installations 17.2.0 libraries work on the JVM by filling the Android shims they call. + +**Architecture:** The SDK runs unmodified Android Firebase code against hand-written `android.*` shims in `src/main/java`. This plan adds file APIs to `Context`, platform-backed `SharedPreferences` for two preference files, ports `JsonReader` from AOSP, adds three small shims, and registers the Remote Config and ABT component registrars. No Remote Config logic is written here. + +**Tech Stack:** Kotlin 2.0.20 / Java 17, Gradle 8.13, JUnit 4, kotlinx-coroutines-test (`runTest`), kotlinx-coroutines-play-services (`await`), kotlinx-serialization-json (already an `implementation` dependency). + +**Spec:** `docs/superpowers/specs/2026-10-07-remote-config-design.md` + +## Global Constraints + +- Run every Gradle command with `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr`. The default JDK 25 fails at configuration with only `> 25.0.4.1`. +- Run the full suite inside the Auth emulator, as CI does: `firebase emulators:exec --project my-firebase-project --import=src/test/resources/firebase_data ''`. Without it, the 6 `FirebaseAuthTest` tests fail with `ConnectException ... localhost:9099`. Baseline on `master`: 12/12 pass and `ktlintCheck` is clean. +- Single test classes may run without the emulator: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests ''`. Read failures with `grep -A8 '.xml`. +- `ktlintCheck` must pass (ktlint 0.47.1). Do not write trailing commas. If it fails on formatting only, run `./gradlew ktlintFormat` and do not hand-edit whitespace. +- Every new hand-written file starts with a brief header comment stating its purpose and how it fits the project. Verbatim AOSP ports keep the AOSP license header and class javadoc unchanged; those serve as the header. +- Match the style of the file you edit. Add no dependencies. +- Commit messages carry no AI attribution (no `Co-Authored-By`, no session links). +- If a test fails for a reason this plan does not predict, stop. Find the root cause, report it, and wait for Angelos before changing course. Do not add workarounds. +- Out of scope: realtime updates (`addOnConfigUpdateListener`), `setDefaultsAsync(@XmlRes int)`, real `PackageManager.getPackageInfo` data. + +## Review Focus + +Inputs the spec implies but its listed tests do not exercise, most likely first. Each has a test in the owning task. + +1. **App IDs contain `:`**, and Remote Config names its files `frc__firebase_{fetch,activate,defaults}.json`. Windows forbids `:` in file names, so these files must still read and write there. Task 1, `file names with colons are stored without colons`. +2. **Concurrent preference writes.** Remote Config writes metadata from background executors. A lost update to the key index would make `reset()` leave stale metadata behind. Task 2, `concurrent writes are all cleared`. +3. **App restart.** Values written through one preferences instance must be readable from a fresh instance, as after a process restart. Task 2, `values persist across instances`. +4. **First run on a clean machine.** `getFilesDir()` may not exist yet, and a missing config file must raise `FileNotFoundException`, which Remote Config reads as "no config yet". Task 1, `filesDir is created when missing` and `opening a missing file throws FileNotFoundException`. +5. **Other products keep failing loudly.** Preference files other than the two platform-backed ones must still throw on unknown keys, including the new `getInt`/`putInt`/`clear`. Task 2, `other preference files still reject unknown keys`. + +--- + +### Task 1: File storage on `Context` + +**Files:** +- Modify: `src/main/java/com/google/firebase/FirebasePlatform.kt` +- Modify: `src/main/java/android/content/Context.kt` +- Modify: `src/test/kotlin/fakes/FakeFirebasePlatform.kt` +- Test: `src/test/kotlin/ContextFilesTest.kt` (create) + +**Interfaces:** +- Produces: `FirebasePlatform.getFilesDir(): File` (open, default `/firebase-files`); `Context.filesDir: File` (Java `getFilesDir()`), `Context.openFileInput(name: String): FileInputStream`, `Context.openFileOutput(name: String, mode: Int): FileOutputStream`, `Context.deleteFile(name: String): Boolean`, `Context.MODE_APPEND = 0x8000`; `FakeFirebasePlatform(storage, databaseFolderPath, filesFolderPath)`. + +- [ ] **Step 1: Give `FakeFirebasePlatform` a files folder** + +In `src/test/kotlin/fakes/FakeFirebasePlatform.kt`, add a constructor parameter and override: + +```kotlin +class FakeFirebasePlatform( + val storage: MutableMap = mutableMapOf(), + databaseFolderPath: String = "./build/database/", + filesFolderPath: String = "./build/files/" +) : FirebasePlatform() { + + private val databaseFolder = File(databaseFolderPath) + + private val filesFolder = File(filesFolderPath) +``` + +and after `getDatabasePath`: + +```kotlin + override fun getFilesDir() = filesFolder +``` + +- [ ] **Step 2: Write the failing tests** + +Create `src/test/kotlin/ContextFilesTest.kt`: + +```kotlin +/* + * Tests for the file APIs on the android.content.Context shim, which Remote Config and + * Installations use to persist configs and installation data in FirebasePlatform.getFilesDir(). + */ +import android.app.Application +import android.content.Context +import com.google.firebase.FirebasePlatform +import fakes.FakeFirebasePlatform +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.FileNotFoundException + +class ContextFilesTest { + + @get:Rule + val folder = TemporaryFolder() + + private lateinit var filesDir: File + + private val context = Application() + + @Before + fun setUp() { + filesDir = File(folder.root, "files") + FirebasePlatform.initializeFirebasePlatform(FakeFirebasePlatform(filesFolderPath = filesDir.path)) + } + + @Test + fun `filesDir is created when missing`() { + assertFalse(filesDir.exists()) + assertEquals(filesDir, context.filesDir) + assertTrue(filesDir.isDirectory) + } + + @Test + fun `written file can be read back`() { + context.openFileOutput("config.json", 0).use { it.write("hello".toByteArray()) } + assertEquals("hello", context.openFileInput("config.json").use { String(it.readBytes()) }) + } + + @Test + fun `opening a missing file throws FileNotFoundException`() { + assertThrows(FileNotFoundException::class.java) { context.openFileInput("missing.json") } + } + + @Test + fun `deleteFile removes the file`() { + context.openFileOutput("config.json", 0).use { it.write("hello".toByteArray()) } + assertTrue(context.deleteFile("config.json")) + assertThrows(FileNotFoundException::class.java) { context.openFileInput("config.json") } + assertFalse(context.deleteFile("config.json")) + } + + @Test + fun `append mode appends`() { + context.openFileOutput("log.txt", 0).use { it.write("a".toByteArray()) } + context.openFileOutput("log.txt", Context.MODE_APPEND).use { it.write("b".toByteArray()) } + assertEquals("ab", context.openFileInput("log.txt").use { String(it.readBytes()) }) + } + + @Test + fun `file names with colons are stored without colons`() { + val name = "frc_1:341458593155:web:bf8e1aa37efe01f32d42b6_firebase_fetch.json" + context.openFileOutput(name, 0).use { it.write("{}".toByteArray()) } + assertEquals("{}", context.openFileInput(name).use { String(it.readBytes()) }) + assertTrue(filesDir.list()!!.none { ':' in it }) + } + + @Test + fun `file names with path separators are rejected`() { + assertThrows(IllegalArgumentException::class.java) { context.openFileOutput("../escape.json", 0) } + } +} +``` + +- [ ] **Step 3: Run the tests and confirm they fail** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'ContextFilesTest'` +Expected: test compilation fails: `getFilesDir` overrides nothing in `FakeFirebasePlatform`, and `filesDir`, `openFileOutput`, `openFileInput`, `deleteFile`, `MODE_APPEND` are unresolved. + +- [ ] **Step 4: Add `getFilesDir` to `FirebasePlatform`** + +In `src/main/java/com/google/firebase/FirebasePlatform.kt`, after `getDatabasePath`: + +```kotlin + open fun getFilesDir(): File = File("${System.getProperty("java.io.tmpdir")}${File.separatorChar}firebase-files") +``` + +- [ ] **Step 5: Add the file APIs to `Context`** + +In `src/main/java/android/content/Context.kt`, add imports `java.io.FileInputStream`, `java.io.FileOutputStream`, `java.net.URLEncoder`. After `getDatabasePath`, add: + +```kotlin + val filesDir: File + get() = FirebasePlatform.firebasePlatform.getFilesDir().apply { mkdirs() } + + fun openFileInput(name: String): FileInputStream = FileInputStream(fileStreamPath(name)) + + fun openFileOutput(name: String, mode: Int): FileOutputStream = + FileOutputStream(fileStreamPath(name), mode and MODE_APPEND != 0) + + fun deleteFile(name: String): Boolean = fileStreamPath(name).delete() + + // URL-encodes the name so characters such as ':' in Firebase app IDs are valid on every OS + private fun fileStreamPath(name: String): File { + require('/' !in name && File.separatorChar !in name) { "File $name contains a path separator" } + return File(filesDir, URLEncoder.encode(name, Charsets.UTF_8)) + } +``` + +In the companion object, add: + +```kotlin + const val MODE_APPEND = 0x8000 +``` + +- [ ] **Step 6: Run the tests and confirm they pass** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'ContextFilesTest' ktlintCheck` +Expected: 7 tests pass; ktlint clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/google/firebase/FirebasePlatform.kt src/main/java/android/content/Context.kt src/test/kotlin/fakes/FakeFirebasePlatform.kt src/test/kotlin/ContextFilesTest.kt +git commit -m "Add file storage APIs to Context backed by FirebasePlatform.getFilesDir" +``` + +--- + +### Task 2: Platform-backed preferences for Remote Config and Installations + +**Files:** +- Modify: `src/main/java/android/content/SharedPreferences.java` +- Create: `src/main/java/android/content/PlatformSharedPreferences.kt` +- Modify: `src/main/java/android/content/Context.kt` (`getSharedPreferences`) +- Test: `src/test/kotlin/PlatformSharedPreferencesTest.kt` (create) + +**Interfaces:** +- Consumes: `FakeFirebasePlatform(storage, ...)` from Task 1. +- Produces: `SharedPreferences.getInt(String, int)`, `Editor.putInt(String, int)`, `Editor.clear()`; `internal class PlatformSharedPreferences(name: String) : SharedPreferences`. `Context.getSharedPreferences` returns it for names starting with `frc_` and for `com.google.android.gms.appid`. Storage keys: `"|"`; key index: `"|__keys"` (JSON array). + +- [ ] **Step 1: Write the failing tests** + +Create `src/test/kotlin/PlatformSharedPreferencesTest.kt`: + +```kotlin +/* + * Tests for the SharedPreferences that Context returns for Remote Config ("frc_*") and + * Installations ("com.google.android.gms.appid"), persisted through FirebasePlatform. + */ +import android.app.Application +import com.google.firebase.FirebasePlatform +import fakes.FakeFirebasePlatform +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.concurrent.thread + +class PlatformSharedPreferencesTest { + + private val platform = FakeFirebasePlatform() + + private val context = Application() + + @Before + fun setUp() { + FirebasePlatform.initializeFirebasePlatform(platform) + } + + @Test + fun `values round-trip by type`() { + val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) + prefs.edit() + .putString("last_fetch_etag", "abc") + .putLong("last_fetch_time_in_millis", 42L) + .putInt("last_fetch_status", -1) + .commit() + + assertEquals("abc", prefs.getString("last_fetch_etag", null)) + assertEquals(42L, prefs.getLong("last_fetch_time_in_millis", 0L)) + assertEquals(-1, prefs.getInt("last_fetch_status", 0)) + assertEquals(7, prefs.getInt("missing", 7)) + assertTrue(prefs.contains("last_fetch_etag")) + assertFalse(prefs.contains("missing")) + } + + @Test + fun `putString null removes the key`() { + val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) + prefs.edit().putString("last_fetch_etag", "abc").commit() + prefs.edit().putString("last_fetch_etag", null).commit() + + assertFalse(prefs.contains("last_fetch_etag")) + assertTrue(prefs.all.isEmpty()) + } + + @Test + fun `files do not see each other's keys`() { + val first = context.getSharedPreferences("frc_first_firebase_settings", 0) + val second = context.getSharedPreferences("frc_second_firebase_settings", 0) + first.edit().putString("key", "first").commit() + + assertNull(second.getString("key", null)) + } + + @Test + fun `clear removes only this file's keys`() { + val first = context.getSharedPreferences("frc_first_firebase_settings", 0) + val second = context.getSharedPreferences("frc_second_firebase_settings", 0) + first.edit().putString("key", "first").commit() + second.edit().putString("key", "second").commit() + + first.edit().clear().commit() + + assertNull(first.getString("key", null)) + assertTrue(first.all.isEmpty()) + assertEquals("second", second.getString("key", null)) + assertTrue(platform.storage.keys.none { it.startsWith("frc_first_firebase_settings|") }) + } + + @Test + fun `values persist across instances`() { + context.getSharedPreferences("frc_app_firebase_settings", 0).edit().putLong("num_failed_fetches", 3L).commit() + + assertEquals(3L, Application().getSharedPreferences("frc_app_firebase_settings", 0).getLong("num_failed_fetches", 0L)) + } + + @Test + fun `installations preferences are platform-backed`() { + val prefs = context.getSharedPreferences("com.google.android.gms.appid", 0) + + assertNull(prefs.getString("|S|id", null)) + } + + @Test + fun `concurrent writes are all cleared`() { + val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) + (0 until 8).map { i -> + thread { repeat(50) { j -> prefs.edit().putInt("key-$i-$j", j).apply() } } + }.forEach { it.join() } + + assertEquals(400, prefs.all.size) + prefs.edit().clear().commit() + assertTrue(platform.storage.keys.none { it.startsWith("frc_app_firebase_settings|") }) + } + + @Test + fun `other preference files still reject unknown keys`() { + val prefs = context.getSharedPreferences("FirebaseHeartBeat", 0) + + assertThrows(IllegalArgumentException::class.java) { prefs.getInt("unknown", 0) } + assertThrows(IllegalArgumentException::class.java) { prefs.edit().putInt("unknown", 0) } + assertThrows(IllegalArgumentException::class.java) { prefs.edit().clear() } + } +} +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'PlatformSharedPreferencesTest'` +Expected: test compilation fails with unresolved `putInt`, `getInt`, and `clear`. + +- [ ] **Step 3: Add the interface methods** + +In `src/main/java/android/content/SharedPreferences.java`, after `long getLong(String key, long defValue);` add: + +```java + int getInt(String key, int defValue); +``` + +and in `Editor`, after `Editor putString(String key, String value);` add: + +```java + Editor putInt(String key, int value); + Editor clear(); +``` + +- [ ] **Step 4: Implement the new methods in the strict preferences** + +In `src/main/java/android/content/Context.kt`, inside the anonymous `SharedPreferences` in `getSharedPreferences`, after `getLong`: + +```kotlin + override fun getInt(key: String?, defValue: Int): Int { + throw IllegalArgumentException(key) + } +``` + +and inside the anonymous `Editor`, after `putString`: + +```kotlin + override fun putInt(key: String?, value: Int): Editor { + throw IllegalArgumentException(key) + } + + override fun clear(): Editor { + throw IllegalArgumentException(name) + } +``` + +- [ ] **Step 5: Create `PlatformSharedPreferences`** + +Create `src/main/java/android/content/PlatformSharedPreferences.kt`: + +```kotlin +/* + * SharedPreferences persisted through FirebasePlatform, returned by Context.getSharedPreferences + * for the preference files of Remote Config and Installations. Each key is stored as + * "|", and the file's key set is stored under "|__keys" so clear() can find them. + * Puts are written immediately, so commit() and apply() have nothing left to do. + */ +package android.content + +import com.google.firebase.FirebasePlatform +import kotlinx.serialization.builtins.SetSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json + +internal class PlatformSharedPreferences(private val name: String) : SharedPreferences { + + private val platform: FirebasePlatform + get() = FirebasePlatform.firebasePlatform + + override fun contains(key: String): Boolean = platform.retrieve(storageKey(key)) != null + + override fun getString(key: String, defaultValue: String?): String? = platform.retrieve(storageKey(key)) ?: defaultValue + + override fun getLong(key: String, defValue: Long): Long = platform.retrieve(storageKey(key))?.toLong() ?: defValue + + override fun getInt(key: String, defValue: Int): Int = platform.retrieve(storageKey(key))?.toInt() ?: defValue + + override fun getAll(): Map = synchronized(lock) { + keys().mapNotNull { key -> platform.retrieve(storageKey(key))?.let { key to it } }.toMap() + } + + override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor { + override fun putString(key: String, value: String?): SharedPreferences.Editor { + put(key, value) + return this + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor { + put(key, value.toString()) + return this + } + + override fun putInt(key: String, value: Int): SharedPreferences.Editor { + put(key, value.toString()) + return this + } + + override fun clear(): SharedPreferences.Editor { + clearAll() + return this + } + + override fun commit(): Boolean = true + + override fun apply() { + } + } + + private fun put(key: String, value: String?) = synchronized(lock) { + if (value == null) { + platform.clear(storageKey(key)) + writeKeys(keys() - key) + } else { + platform.store(storageKey(key), value) + writeKeys(keys() + key) + } + } + + private fun clearAll() = synchronized(lock) { + keys().forEach { platform.clear(storageKey(it)) } + platform.clear(keysKey) + } + + private fun keys(): Set = platform.retrieve(keysKey)?.let { Json.decodeFromString(keySetSerializer, it) } ?: emptySet() + + private fun writeKeys(keys: Set) = platform.store(keysKey, Json.encodeToString(keySetSerializer, keys)) + + private fun storageKey(key: String) = "$name|$key" + + private val keysKey: String + get() = "$name|__keys" + + companion object { + private val lock = Any() + private val keySetSerializer = SetSerializer(String.serializer()) + } +} +``` + +- [ ] **Step 6: Route the two preference files to it** + +In `src/main/java/android/content/Context.kt`, make the first lines of `getSharedPreferences`: + +```kotlin + fun getSharedPreferences(name: String, mode: Int): SharedPreferences { + if (name.startsWith("frc_") || name == "com.google.android.gms.appid") { + return PlatformSharedPreferences(name) + } + return object : SharedPreferences { +``` + +- [ ] **Step 7: Run the tests and confirm they pass** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'PlatformSharedPreferencesTest' --tests 'ContextFilesTest' ktlintCheck` +Expected: 8 + 7 tests pass; ktlint clean. + +- [ ] **Step 8: Commit** + +```bash +git add src/main/java/android/content/SharedPreferences.java src/main/java/android/content/PlatformSharedPreferences.kt src/main/java/android/content/Context.kt src/test/kotlin/PlatformSharedPreferencesTest.kt +git commit -m "Persist Remote Config and Installations preferences through FirebasePlatform" +``` + +--- + +### Task 3: Port `android.util.JsonReader` from AOSP + +Installations parses its REST responses with `android.util.JsonReader`, which has no shim. + +**Files:** +- Create (verbatim AOSP ports): `src/main/java/android/util/JsonReader.java`, `src/main/java/android/util/JsonToken.java`, `src/main/java/android/util/JsonScope.java`, `src/main/java/android/util/MalformedJsonException.java`, `src/main/java/com/android/internal/util/StringPool.java` +- Test: `src/test/kotlin/JsonReaderTest.kt` (create) + +**Interfaces:** +- Produces: `android.util.JsonReader(Reader)` with the Android API (`beginObject`, `endObject`, `hasNext`, `nextName`, `nextString`, `skipValue`, `close`). + +- [ ] **Step 1: Write the failing test** + +Create `src/test/kotlin/JsonReaderTest.kt`: + +```kotlin +/* + * Tests the android.util.JsonReader port on the response shape Firebase Installations parses. + */ +import android.util.JsonReader +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.StringReader + +class JsonReaderTest { + + @Test + fun `parses an installations response`() { + val json = """{"name":"projects/1/installations/fid-123","fid":"fid-123","refreshToken":"refresh","authToken":{"token":"auth","expiresIn":"604800s"},"unknown":[1,{"a":true}]}""" + val values = mutableMapOf() + + JsonReader(StringReader(json)).use { reader -> + reader.beginObject() + while (reader.hasNext()) { + when (val name = reader.nextName()) { + "fid", "refreshToken" -> values[name] = reader.nextString() + "authToken" -> { + reader.beginObject() + while (reader.hasNext()) { + values["authToken." + reader.nextName()] = reader.nextString() + } + reader.endObject() + } + else -> reader.skipValue() + } + } + reader.endObject() + } + + assertEquals( + mapOf( + "fid" to "fid-123", + "refreshToken" to "refresh", + "authToken.token" to "auth", + "authToken.expiresIn" to "604800s" + ), + values + ) + } +} +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'JsonReaderTest'` +Expected: test compilation fails with `Unresolved reference: JsonReader`. Robolectric's `android-all` is `compileOnly` for main and is not on the test classpath. + +- [ ] **Step 3: Copy the AOSP sources verbatim** + +```bash +for f in JsonReader JsonToken JsonScope MalformedJsonException; do + gh api "repos/aosp-mirror/platform_frameworks_base/contents/core/java/android/util/$f.java?ref=android14-release" \ + -H 'Accept: application/vnd.github.raw' > src/main/java/android/util/$f.java +done +gh api "repos/aosp-mirror/platform_frameworks_base/contents/core/java/com/android/internal/util/StringPool.java?ref=android14-release" \ + -H 'Accept: application/vnd.github.raw' > src/main/java/com/android/internal/util/StringPool.java +wc -l src/main/java/android/util/Json*.java src/main/java/android/util/MalformedJsonException.java src/main/java/com/android/internal/util/StringPool.java +``` + +Expected line counts: JsonReader 1173, JsonToken 82, JsonScope 68, MalformedJsonException 31, StringPool 77. Each file must start with the AOSP Apache 2.0 header. Do not edit the files. + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'JsonReaderTest' ktlintCheck` +Expected: PASS; ktlint clean (it does not check Java). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/android/util/JsonReader.java src/main/java/android/util/JsonToken.java src/main/java/android/util/JsonScope.java src/main/java/android/util/MalformedJsonException.java src/main/java/com/android/internal/util/StringPool.java src/test/kotlin/JsonReaderTest.kt +git commit -m "Port android.util.JsonReader from AOSP for Firebase Installations" +``` + +--- + +### Task 4: Register Remote Config and port the offline tests + +**Files:** +- Modify: `src/main/java/android/content/pm/PackageManager.java` (`getServiceInfo`) +- Modify: `src/test/kotlin/FirebaseTest.kt` (platform `getFilesDir`) +- Test: `src/test/kotlin/FirebaseRemoteConfigTest.kt` (create) + +**Interfaces:** +- Consumes: everything from Tasks 1–3. +- Produces: `FirebaseRemoteConfigTest` with fields `defaults: Map` and `remoteConfig: FirebaseRemoteConfig`, which Task 5 extends. + +The tests port firebase-kotlin-sdk `firebase-config/src/commonTest/kotlin/dev/gitlive/firebase/remoteconfig/FirebaseRemoteConfig.kt` one-to-one onto the Android API: same names, keys, values, and assertions. + +- [ ] **Step 1: Point test files at `build/`** + +In `src/test/kotlin/FirebaseTest.kt`, inside the `FirebasePlatform` object after `getDatabasePath`: + +```kotlin + override fun getFilesDir() = File("./build/files") +``` + +- [ ] **Step 2: Write the failing tests** + +Create `src/test/kotlin/FirebaseRemoteConfigTest.kt`: + +```kotlin +/* + * Remote Config tests ported one-to-one from firebase-kotlin-sdk's common tests + * (firebase-config/src/commonTest/.../FirebaseRemoteConfig.kt) onto the Android API, + * so the Kotlin SDK's JVM target behaves the same when it runs on this SDK. + */ +import android.app.Application +import com.google.firebase.Firebase +import com.google.firebase.FirebaseOptions +import com.google.firebase.initialize +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings +import com.google.firebase.remoteconfig.FirebaseRemoteConfigValue +import com.google.firebase.remoteconfig.get +import com.google.firebase.remoteconfig.remoteConfig +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Ignore +import org.junit.Test + +class FirebaseRemoteConfigTest : FirebaseTest() { + + private val defaults = mapOf( + "test_default_boolean" to true, + "test_default_double" to 42.0, + "test_default_long" to 42L, + "test_default_string" to "Hello World" + ) + + private val remoteConfig: FirebaseRemoteConfig by lazy { Firebase.remoteConfig(app) } + + @After + fun tearDown(): Unit = runTest { + remoteConfig.reset().await() + } + + @Test + fun testGettingValues(): Unit = runTest { + remoteConfig.setDefaultsAsync(defaults).await() + + assertEquals(true, remoteConfig.getBoolean("test_default_boolean")) + assertEquals(42.0, remoteConfig.getDouble("test_default_double"), 0.0) + assertEquals(42L, remoteConfig.getLong("test_default_long")) + assertEquals("Hello World", remoteConfig.getString("test_default_string")) + assertEquals("Hello World", remoteConfig.getString("test_default_string")) + + val value: FirebaseRemoteConfigValue = remoteConfig["test_default_string"] + assertEquals("Hello World", value.asString()) + assertEquals(FirebaseRemoteConfig.VALUE_SOURCE_DEFAULT, value.source) + assertEquals("Hello World", value.asByteArray().decodeToString()) + } + + @Test + fun testNamedApp(): Unit = runTest { + // Android keys Remote Config's local storage by app ID, so the named app needs its own + val options = FirebaseOptions.Builder(app.options).setApplicationId("1:341458593155:web:bf8e1aa37efe01f32d42b7").build() + val namedApp = Firebase.initialize(Application(), options, "named") + val namedRemoteConfig = Firebase.remoteConfig(namedApp) + namedRemoteConfig.setDefaultsAsync(mapOf("named_app_only" to "named")).await() + + assertEquals("named", namedRemoteConfig.getValue("named_app_only").asString()) + assertFalse(remoteConfig.all.containsKey("named_app_only")) + namedRemoteConfig.reset().await() + } + + @Test + fun testGetAll(): Unit = runTest { + remoteConfig.setDefaultsAsync(defaults).await() + val all = remoteConfig.all + assertEquals(true, all["test_default_boolean"]?.asBoolean()) + assertEquals(42.0, all["test_default_double"]?.asDouble()) + assertEquals(42L, all["test_default_long"]?.asLong()) + assertEquals("Hello World", all["test_default_string"]?.asString()) + assertEquals("Hello World", all["test_default_string"]?.asByteArray()?.decodeToString()) + } + + @Test + fun testGetKeysByPrefix(): Unit = runTest { + remoteConfig.setDefaultsAsync(defaults).await() + val keys = remoteConfig.getKeysByPrefix("test_default") + assertEquals( + setOf( + "test_default_boolean", + "test_default_double", + "test_default_long", + "test_default_string" + ), + keys + ) + } + + @Test + fun testGetInfo(): Unit = runTest { + val info = remoteConfig.info + val defaultSettings = FirebaseRemoteConfigSettings.Builder().build() + assertEquals(defaultSettings.fetchTimeoutInSeconds, info.configSettings.fetchTimeoutInSeconds) + assertEquals(defaultSettings.minimumFetchIntervalInSeconds, info.configSettings.minimumFetchIntervalInSeconds) + assertEquals(-1L, info.fetchTimeMillis) + assertEquals(FirebaseRemoteConfig.LAST_FETCH_STATUS_NO_FETCH_YET, info.lastFetchStatus) + } + + @Test + fun testSetConfigSettings(): Unit = runTest { + remoteConfig.setConfigSettingsAsync( + FirebaseRemoteConfigSettings.Builder() + .setFetchTimeoutInSeconds(42) + .setMinimumFetchIntervalInSeconds(42) + .build() + ).await() + val info = remoteConfig.info + assertEquals(42L, info.configSettings.fetchTimeoutInSeconds) + assertEquals(42L, info.configSettings.minimumFetchIntervalInSeconds) + } + + // Unfortunately Firebase Remote Config is not implemented by Firebase emulator so it may be + // tested against a real project only. Add "test_remote_string": "Hello from remote!" config + // value in Firebase console for enabling this test case. + @Test + @Ignore + fun testFetch(): Unit = runTest { + remoteConfig.setConfigSettingsAsync( + FirebaseRemoteConfigSettings.Builder().setMinimumFetchIntervalInSeconds(60).build() + ).await() + + remoteConfig.fetch().await() + remoteConfig.activate().await() + + val value: FirebaseRemoteConfigValue = remoteConfig["test_remote_string"] + assertEquals("Hello from remote!", value.asString()) + assertEquals(FirebaseRemoteConfig.VALUE_SOURCE_REMOTE, value.source) + } + + @Test + @Ignore + fun testFetchAndActivate(): Unit = runTest { + remoteConfig.setConfigSettingsAsync( + FirebaseRemoteConfigSettings.Builder().setMinimumFetchIntervalInSeconds(60).build() + ).await() + + remoteConfig.fetchAndActivate().await() + + val value: FirebaseRemoteConfigValue = remoteConfig["test_remote_string"] + assertEquals("Hello from remote!", value.asString()) + assertEquals(FirebaseRemoteConfig.VALUE_SOURCE_REMOTE, value.source) + } +} +``` + +- [ ] **Step 3: Run the tests and confirm they fail** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'FirebaseRemoteConfigTest'` +Then: `grep -A8 ' 0) { + return String.format("%d:%02d:%02d", hours, minutes, seconds); + } + return String.format("%02d:%02d", minutes, seconds); + } +} +``` + +- [ ] **Step 10: Run the tests and confirm they pass** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'DateUtilsTest' --tests 'FirebaseRemoteConfigTest' ktlintCheck` +Expected: 2 + 7 pass, 2 skipped; ktlint clean. + +- [ ] **Step 11: Commit** + +```bash +git add src/main/java/android/net/TrafficStats.java src/main/java/android/content/res/Configuration.java src/main/java/android/content/res/Resources.java src/main/java/android/text/format/DateUtils.java src/test/kotlin/FirebaseRemoteConfigTest.kt src/test/kotlin/DateUtilsTest.kt +git commit -m "Add shims for Remote Config fetch through Firebase Installations" +``` + +--- + +### Task 6: Documentation and full verification + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Document `getFilesDir`** + +In `README.md`, after the paragraph ending "This is used by Firestore to support [offline data persistence](...)." insert: + +````markdown +#### Customizing file storage location + +The `FirebasePlatform` interface also includes a `getFilesDir` method for you to override if the following default implementation is not suitable: + +```kotlin + open fun getFilesDir(): File = File("${System.getProperty("java.io.tmpdir")}${File.separatorChar}firebase-files") +``` + +This is used by Remote Config and Installations to persist fetched configs and the installation ID. +```` + +- [ ] **Step 2: Update the project status table** + +Replace the two struck-through rows and footnote 2: + +```markdown +| [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` | +``` + +```markdown +[^2]: Realtime updates (`addOnConfigUpdateListener`) and defaults from XML resources are not supported. +``` + +- [ ] **Step 3: Run the full suite the way CI does** + +Run: +```bash +JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr firebase emulators:exec --project my-firebase-project --import=src/test/resources/firebase_data './gradlew cleanTest build' +grep -hoE 'testsuite name="[^"]*" tests="[0-9]+" skipped="[0-9]+" failures="[0-9]+" errors="[0-9]+"' build/test-results/test/*.xml +``` +Expected: `BUILD SUCCESSFUL`. 12 baseline tests plus 7 `ContextFilesTest`, 8 `PlatformSharedPreferencesTest`, 1 `JsonReaderTest`, 2 `DateUtilsTest`, and 9 `FirebaseRemoteConfigTest` (2 skipped). 0 failures, 0 errors. + +- [ ] **Step 4: Check test output is clean** + +Run: `grep -lE 'Exception|ERROR|WARN' build/test-results/test/*.xml` and read every ``/`` block in the matching files. +Expected: no exceptions or warnings from Remote Config, Installations, or the shims. Report any you find to Angelos with the exact text; do not suppress them. + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "Document Remote Config and Installations support" +``` From 078e5870e737d27be7220afb92562b7cc70227a3 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:33:38 +0300 Subject: [PATCH 03/33] Add file storage APIs to Context backed by FirebasePlatform.getFilesDir --- src/main/java/android/content/Context.kt | 21 +++++ .../com/google/firebase/FirebasePlatform.kt | 2 + src/test/kotlin/ContextFilesTest.kt | 80 +++++++++++++++++++ src/test/kotlin/fakes/FakeFirebasePlatform.kt | 7 +- 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/ContextFilesTest.kt diff --git a/src/main/java/android/content/Context.kt b/src/main/java/android/content/Context.kt index ef07fd9..d360c58 100644 --- a/src/main/java/android/content/Context.kt +++ b/src/main/java/android/content/Context.kt @@ -10,6 +10,9 @@ import android.os.PowerManager import android.os.UserManager import com.google.firebase.FirebasePlatform import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.net.URLEncoder open class Context { @@ -132,8 +135,26 @@ open class Context { fun getDatabasePath(name: String): File = FirebasePlatform.firebasePlatform.getDatabasePath(name) + val filesDir: File + get() = FirebasePlatform.firebasePlatform.getFilesDir().apply { mkdirs() } + + fun openFileInput(name: String): FileInputStream = FileInputStream(fileStreamPath(name)) + + fun openFileOutput(name: String, mode: Int): FileOutputStream = + FileOutputStream(fileStreamPath(name), mode and MODE_APPEND != 0) + + fun deleteFile(name: String): Boolean = fileStreamPath(name).delete() + + // URL-encodes the name so characters such as ':' in Firebase app IDs are valid on every OS + private fun fileStreamPath(name: String): File { + require('/' !in name && File.separatorChar !in name) { "File $name contains a path separator" } + return File(filesDir, URLEncoder.encode(name, Charsets.UTF_8)) + } + companion object { @JvmStatic val CONNECTIVITY_SERVICE = "connectivity" + + const val MODE_APPEND = 0x8000 } } diff --git a/src/main/java/com/google/firebase/FirebasePlatform.kt b/src/main/java/com/google/firebase/FirebasePlatform.kt index 4db9c84..5a41280 100644 --- a/src/main/java/com/google/firebase/FirebasePlatform.kt +++ b/src/main/java/com/google/firebase/FirebasePlatform.kt @@ -24,4 +24,6 @@ abstract class FirebasePlatform { abstract fun log(msg: String) open fun getDatabasePath(name: String): File = File("${System.getProperty("java.io.tmpdir")}${File.separatorChar}$name") + + open fun getFilesDir(): File = File("${System.getProperty("java.io.tmpdir")}${File.separatorChar}firebase-files") } diff --git a/src/test/kotlin/ContextFilesTest.kt b/src/test/kotlin/ContextFilesTest.kt new file mode 100644 index 0000000..87cf9b6 --- /dev/null +++ b/src/test/kotlin/ContextFilesTest.kt @@ -0,0 +1,80 @@ +/* + * Tests for the file APIs on the android.content.Context shim, which Remote Config and + * Installations use to persist configs and installation data in FirebasePlatform.getFilesDir(). + */ +import android.app.Application +import android.content.Context +import com.google.firebase.FirebasePlatform +import fakes.FakeFirebasePlatform +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.FileNotFoundException + +class ContextFilesTest { + + @get:Rule + val folder = TemporaryFolder() + + private lateinit var filesDir: File + + private val context = Application() + + @Before + fun setUp() { + filesDir = File(folder.root, "files") + FirebasePlatform.initializeFirebasePlatform(FakeFirebasePlatform(filesFolderPath = filesDir.path)) + } + + @Test + fun `filesDir is created when missing`() { + assertFalse(filesDir.exists()) + assertEquals(filesDir, context.filesDir) + assertTrue(filesDir.isDirectory) + } + + @Test + fun `written file can be read back`() { + context.openFileOutput("config.json", 0).use { it.write("hello".toByteArray()) } + assertEquals("hello", context.openFileInput("config.json").use { String(it.readBytes()) }) + } + + @Test + fun `opening a missing file throws FileNotFoundException`() { + assertThrows(FileNotFoundException::class.java) { context.openFileInput("missing.json") } + } + + @Test + fun `deleteFile removes the file`() { + context.openFileOutput("config.json", 0).use { it.write("hello".toByteArray()) } + assertTrue(context.deleteFile("config.json")) + assertThrows(FileNotFoundException::class.java) { context.openFileInput("config.json") } + assertFalse(context.deleteFile("config.json")) + } + + @Test + fun `append mode appends`() { + context.openFileOutput("log.txt", 0).use { it.write("a".toByteArray()) } + context.openFileOutput("log.txt", Context.MODE_APPEND).use { it.write("b".toByteArray()) } + assertEquals("ab", context.openFileInput("log.txt").use { String(it.readBytes()) }) + } + + @Test + fun `file names with colons are stored without colons`() { + val name = "frc_1:341458593155:web:bf8e1aa37efe01f32d42b6_firebase_fetch.json" + context.openFileOutput(name, 0).use { it.write("{}".toByteArray()) } + assertEquals("{}", context.openFileInput(name).use { String(it.readBytes()) }) + assertTrue(filesDir.list()!!.none { ':' in it }) + } + + @Test + fun `file names with path separators are rejected`() { + assertThrows(IllegalArgumentException::class.java) { context.openFileOutput("../escape.json", 0) } + } +} diff --git a/src/test/kotlin/fakes/FakeFirebasePlatform.kt b/src/test/kotlin/fakes/FakeFirebasePlatform.kt index 6074ad9..a244d73 100644 --- a/src/test/kotlin/fakes/FakeFirebasePlatform.kt +++ b/src/test/kotlin/fakes/FakeFirebasePlatform.kt @@ -9,11 +9,14 @@ import java.io.File */ class FakeFirebasePlatform( val storage: MutableMap = mutableMapOf(), - databaseFolderPath: String = "./build/database/" + databaseFolderPath: String = "./build/database/", + filesFolderPath: String = "./build/files/" ) : FirebasePlatform() { private val databaseFolder = File(databaseFolderPath) + private val filesFolder = File(filesFolderPath) + override fun store(key: String, value: String) { storage[key] = value } override fun retrieve(key: String) = storage[key] @@ -23,4 +26,6 @@ class FakeFirebasePlatform( override fun log(msg: String) = println(msg) override fun getDatabasePath(name: String) = File(databaseFolder, name) + + override fun getFilesDir() = filesFolder } From eafa96b8b72a1914e3ef6bcb4de7e0360722971e Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:34:20 +0300 Subject: [PATCH 04/33] Persist Remote Config and Installations preferences through FirebasePlatform --- src/main/java/android/content/Context.kt | 15 +++ .../content/PlatformSharedPreferences.kt | 86 +++++++++++++ .../android/content/SharedPreferences.java | 3 + .../kotlin/PlatformSharedPreferencesTest.kt | 113 ++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 src/main/java/android/content/PlatformSharedPreferences.kt create mode 100644 src/test/kotlin/PlatformSharedPreferencesTest.kt diff --git a/src/main/java/android/content/Context.kt b/src/main/java/android/content/Context.kt index d360c58..e8b4c6d 100644 --- a/src/main/java/android/content/Context.kt +++ b/src/main/java/android/content/Context.kt @@ -46,6 +46,9 @@ open class Context { val applicationInfo: ApplicationInfo = ApplicationInfo() fun getSharedPreferences(name: String, mode: Int): SharedPreferences { + if (name.startsWith("frc_") || name == "com.google.android.gms.appid") { + return PlatformSharedPreferences(name) + } return object : SharedPreferences { override fun contains(key: String): Boolean { when (key) { @@ -75,6 +78,10 @@ open class Context { throw IllegalArgumentException(key) } + override fun getInt(key: String?, defaultValue: Int): Int { + throw IllegalArgumentException(key) + } + override fun getAll(): Map { return emptyMap() } @@ -101,6 +108,14 @@ open class Context { return this } + override fun putInt(key: String?, value: Int): Editor { + throw IllegalArgumentException(key) + } + + override fun clear(): Editor { + throw IllegalArgumentException(name) + } + override fun commit(): Boolean { // Don't need to commit as changes are committed in the put method return true diff --git a/src/main/java/android/content/PlatformSharedPreferences.kt b/src/main/java/android/content/PlatformSharedPreferences.kt new file mode 100644 index 0000000..ee43151 --- /dev/null +++ b/src/main/java/android/content/PlatformSharedPreferences.kt @@ -0,0 +1,86 @@ +/* + * SharedPreferences persisted through FirebasePlatform, returned by Context.getSharedPreferences + * for the preference files of Remote Config and Installations. Each key is stored as + * "|", and the file's key set is stored under "|__keys" so clear() can find them. + * Puts are written immediately, so commit() and apply() have nothing left to do. + */ +package android.content + +import com.google.firebase.FirebasePlatform +import kotlinx.serialization.builtins.SetSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json + +internal class PlatformSharedPreferences(private val name: String) : SharedPreferences { + + private val platform: FirebasePlatform + get() = FirebasePlatform.firebasePlatform + + override fun contains(key: String): Boolean = platform.retrieve(storageKey(key)) != null + + override fun getString(key: String, defaultValue: String?): String? = platform.retrieve(storageKey(key)) ?: defaultValue + + override fun getLong(key: String, defValue: Long): Long = platform.retrieve(storageKey(key))?.toLong() ?: defValue + + override fun getInt(key: String, defValue: Int): Int = platform.retrieve(storageKey(key))?.toInt() ?: defValue + + override fun getAll(): Map = synchronized(lock) { + keys().mapNotNull { key -> platform.retrieve(storageKey(key))?.let { key to it } }.toMap() + } + + override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor { + override fun putString(key: String, value: String?): SharedPreferences.Editor { + put(key, value) + return this + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor { + put(key, value.toString()) + return this + } + + override fun putInt(key: String, value: Int): SharedPreferences.Editor { + put(key, value.toString()) + return this + } + + override fun clear(): SharedPreferences.Editor { + clearAll() + return this + } + + override fun commit(): Boolean = true + + override fun apply() { + } + } + + private fun put(key: String, value: String?) = synchronized(lock) { + if (value == null) { + platform.clear(storageKey(key)) + writeKeys(keys() - key) + } else { + platform.store(storageKey(key), value) + writeKeys(keys() + key) + } + } + + private fun clearAll() = synchronized(lock) { + keys().forEach { platform.clear(storageKey(it)) } + platform.clear(keysKey) + } + + private fun keys(): Set = platform.retrieve(keysKey)?.let { Json.decodeFromString(keySetSerializer, it) } ?: emptySet() + + private fun writeKeys(keys: Set) = platform.store(keysKey, Json.encodeToString(keySetSerializer, keys)) + + private fun storageKey(key: String) = "$name|$key" + + private val keysKey: String + get() = "$name|__keys" + + companion object { + private val lock = Any() + private val keySetSerializer = SetSerializer(String.serializer()) + } +} diff --git a/src/main/java/android/content/SharedPreferences.java b/src/main/java/android/content/SharedPreferences.java index d6e72c2..900dbe4 100644 --- a/src/main/java/android/content/SharedPreferences.java +++ b/src/main/java/android/content/SharedPreferences.java @@ -8,12 +8,15 @@ public interface SharedPreferences { String getString(String key, String defaultValue); Map getAll(); long getLong(String key, long defValue); + int getInt(String key, int defValue); Editor edit(); interface Editor { Editor putLong(String key, long value); Editor putString(String key, String value); + Editor putInt(String key, int value); + Editor clear(); boolean commit(); void apply(); } diff --git a/src/test/kotlin/PlatformSharedPreferencesTest.kt b/src/test/kotlin/PlatformSharedPreferencesTest.kt new file mode 100644 index 0000000..bb4f768 --- /dev/null +++ b/src/test/kotlin/PlatformSharedPreferencesTest.kt @@ -0,0 +1,113 @@ +/* + * Tests for the SharedPreferences that Context returns for Remote Config ("frc_*") and + * Installations ("com.google.android.gms.appid"), persisted through FirebasePlatform. + */ +import android.app.Application +import com.google.firebase.FirebasePlatform +import fakes.FakeFirebasePlatform +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.concurrent.thread + +class PlatformSharedPreferencesTest { + + private val platform = FakeFirebasePlatform() + + private val context = Application() + + @Before + fun setUp() { + FirebasePlatform.initializeFirebasePlatform(platform) + } + + @Test + fun `values round-trip by type`() { + val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) + prefs.edit() + .putString("last_fetch_etag", "abc") + .putLong("last_fetch_time_in_millis", 42L) + .putInt("last_fetch_status", -1) + .commit() + + assertEquals("abc", prefs.getString("last_fetch_etag", null)) + assertEquals(42L, prefs.getLong("last_fetch_time_in_millis", 0L)) + assertEquals(-1, prefs.getInt("last_fetch_status", 0)) + assertEquals(7, prefs.getInt("missing", 7)) + assertTrue(prefs.contains("last_fetch_etag")) + assertFalse(prefs.contains("missing")) + } + + @Test + fun `putString null removes the key`() { + val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) + prefs.edit().putString("last_fetch_etag", "abc").commit() + prefs.edit().putString("last_fetch_etag", null).commit() + + assertFalse(prefs.contains("last_fetch_etag")) + assertTrue(prefs.all.isEmpty()) + } + + @Test + fun `files do not see each other's keys`() { + val first = context.getSharedPreferences("frc_first_firebase_settings", 0) + val second = context.getSharedPreferences("frc_second_firebase_settings", 0) + first.edit().putString("key", "first").commit() + + assertNull(second.getString("key", null)) + } + + @Test + fun `clear removes only this file's keys`() { + val first = context.getSharedPreferences("frc_first_firebase_settings", 0) + val second = context.getSharedPreferences("frc_second_firebase_settings", 0) + first.edit().putString("key", "first").commit() + second.edit().putString("key", "second").commit() + + first.edit().clear().commit() + + assertNull(first.getString("key", null)) + assertTrue(first.all.isEmpty()) + assertEquals("second", second.getString("key", null)) + assertTrue(platform.storage.keys.none { it.startsWith("frc_first_firebase_settings|") }) + } + + @Test + fun `values persist across instances`() { + context.getSharedPreferences("frc_app_firebase_settings", 0).edit().putLong("num_failed_fetches", 3L).commit() + + assertEquals(3L, Application().getSharedPreferences("frc_app_firebase_settings", 0).getLong("num_failed_fetches", 0L)) + } + + @Test + fun `installations preferences are platform-backed`() { + val prefs = context.getSharedPreferences("com.google.android.gms.appid", 0) + + assertNull(prefs.getString("|S|id", null)) + } + + @Test + fun `concurrent writes are all cleared`() { + val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) + (0 until 8).map { i -> + thread { repeat(50) { j -> prefs.edit().putInt("key-$i-$j", j).apply() } } + }.forEach { it.join() } + + assertEquals(400, prefs.all.size) + prefs.edit().clear().commit() + assertTrue(platform.storage.keys.none { it.startsWith("frc_app_firebase_settings|") }) + } + + @Test + fun `other preference files still reject unknown keys`() { + val prefs = context.getSharedPreferences("FirebaseHeartBeat", 0) + + assertThrows(IllegalArgumentException::class.java) { prefs.getInt("unknown", 0) } + assertThrows(IllegalArgumentException::class.java) { prefs.edit().putInt("unknown", 0) } + assertThrows(IllegalArgumentException::class.java) { prefs.edit().clear() } + } +} From e11bf6277b2a1ef1028e5ed263ff1a2c6c11de01 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:34:55 +0300 Subject: [PATCH 05/33] Port android.util.JsonReader from AOSP for Firebase Installations --- src/main/java/android/util/JsonReader.java | 1173 +++++++++++++++++ src/main/java/android/util/JsonScope.java | 68 + src/main/java/android/util/JsonToken.java | 82 ++ .../android/util/MalformedJsonException.java | 31 + .../com/android/internal/util/StringPool.java | 77 ++ src/test/kotlin/JsonReaderTest.kt | 44 + 6 files changed, 1475 insertions(+) create mode 100644 src/main/java/android/util/JsonReader.java create mode 100644 src/main/java/android/util/JsonScope.java create mode 100644 src/main/java/android/util/JsonToken.java create mode 100644 src/main/java/android/util/MalformedJsonException.java create mode 100644 src/main/java/com/android/internal/util/StringPool.java create mode 100644 src/test/kotlin/JsonReaderTest.kt diff --git a/src/main/java/android/util/JsonReader.java b/src/main/java/android/util/JsonReader.java new file mode 100644 index 0000000..c75e238 --- /dev/null +++ b/src/main/java/android/util/JsonReader.java @@ -0,0 +1,1173 @@ +/* + * Copyright (C) 2010 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 android.util; + +import com.android.internal.util.StringPool; + +import java.io.Closeable; +import java.io.EOFException; +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; + + +/** + * Reads a JSON (RFC 4627) + * encoded value as a stream of tokens. This stream includes both literal + * values (strings, numbers, booleans, and nulls) as well as the begin and + * end delimiters of objects and arrays. The tokens are traversed in + * depth-first order, the same order that they appear in the JSON document. + * Within JSON objects, name/value pairs are represented by a single token. + * + *

Parsing JSON

+ * To create a recursive descent parser for your own JSON streams, first create + * an entry point method that creates a {@code JsonReader}. + * + *

Next, create handler methods for each structure in your JSON text. You'll + * need a method for each object type and for each array type. + *

    + *
  • Within array handling methods, first call {@link + * #beginArray} to consume the array's opening bracket. Then create a + * while loop that accumulates values, terminating when {@link #hasNext} + * is false. Finally, read the array's closing bracket by calling {@link + * #endArray}. + *
  • Within object handling methods, first call {@link + * #beginObject} to consume the object's opening brace. Then create a + * while loop that assigns values to local variables based on their name. + * This loop should terminate when {@link #hasNext} is false. Finally, + * read the object's closing brace by calling {@link #endObject}. + *
+ *

When a nested object or array is encountered, delegate to the + * corresponding handler method. + * + *

When an unknown name is encountered, strict parsers should fail with an + * exception. Lenient parsers should call {@link #skipValue()} to recursively + * skip the value's nested tokens, which may otherwise conflict. + * + *

If a value may be null, you should first check using {@link #peek()}. + * Null literals can be consumed using either {@link #nextNull()} or {@link + * #skipValue()}. + * + *

Example

+ * Suppose we'd like to parse a stream of messages such as the following:
 {@code
+ * [
+ *   {
+ *     "id": 912345678901,
+ *     "text": "How do I read JSON on Android?",
+ *     "geo": null,
+ *     "user": {
+ *       "name": "android_newb",
+ *       "followers_count": 41
+ *      }
+ *   },
+ *   {
+ *     "id": 912345678902,
+ *     "text": "@android_newb just use android.util.JsonReader!",
+ *     "geo": [50.454722, -104.606667],
+ *     "user": {
+ *       "name": "jesse",
+ *       "followers_count": 2
+ *     }
+ *   }
+ * ]}
+ * This code implements the parser for the above structure:
   {@code
+ *
+ *   public List readJsonStream(InputStream in) throws IOException {
+ *     JsonReader reader = new JsonReader(new InputStreamReader(in, "UTF-8"));
+ *     try {
+ *       return readMessagesArray(reader);
+ *     } finally {
+ *       reader.close();
+ *     }
+ *   }
+ *
+ *   public List readMessagesArray(JsonReader reader) throws IOException {
+ *     List messages = new ArrayList();
+ *
+ *     reader.beginArray();
+ *     while (reader.hasNext()) {
+ *       messages.add(readMessage(reader));
+ *     }
+ *     reader.endArray();
+ *     return messages;
+ *   }
+ *
+ *   public Message readMessage(JsonReader reader) throws IOException {
+ *     long id = -1;
+ *     String text = null;
+ *     User user = null;
+ *     List geo = null;
+ *
+ *     reader.beginObject();
+ *     while (reader.hasNext()) {
+ *       String name = reader.nextName();
+ *       if (name.equals("id")) {
+ *         id = reader.nextLong();
+ *       } else if (name.equals("text")) {
+ *         text = reader.nextString();
+ *       } else if (name.equals("geo") && reader.peek() != JsonToken.NULL) {
+ *         geo = readDoublesArray(reader);
+ *       } else if (name.equals("user")) {
+ *         user = readUser(reader);
+ *       } else {
+ *         reader.skipValue();
+ *       }
+ *     }
+ *     reader.endObject();
+ *     return new Message(id, text, user, geo);
+ *   }
+ *
+ *   public List readDoublesArray(JsonReader reader) throws IOException {
+ *     List doubles = new ArrayList();
+ *
+ *     reader.beginArray();
+ *     while (reader.hasNext()) {
+ *       doubles.add(reader.nextDouble());
+ *     }
+ *     reader.endArray();
+ *     return doubles;
+ *   }
+ *
+ *   public User readUser(JsonReader reader) throws IOException {
+ *     String username = null;
+ *     int followersCount = -1;
+ *
+ *     reader.beginObject();
+ *     while (reader.hasNext()) {
+ *       String name = reader.nextName();
+ *       if (name.equals("name")) {
+ *         username = reader.nextString();
+ *       } else if (name.equals("followers_count")) {
+ *         followersCount = reader.nextInt();
+ *       } else {
+ *         reader.skipValue();
+ *       }
+ *     }
+ *     reader.endObject();
+ *     return new User(username, followersCount);
+ *   }}
+ * + *

Number Handling

+ * This reader permits numeric values to be read as strings and string values to + * be read as numbers. For example, both elements of the JSON array {@code + * [1, "1"]} may be read using either {@link #nextInt} or {@link #nextString}. + * This behavior is intended to prevent lossy numeric conversions: double is + * JavaScript's only numeric type and very large values like {@code + * 9007199254740993} cannot be represented exactly on that platform. To minimize + * precision loss, extremely large values should be written and read as strings + * in JSON. + * + *

Each {@code JsonReader} may be used to read a single JSON stream. Instances + * of this class are not thread safe. + */ +public final class JsonReader implements Closeable { + + private static final String TRUE = "true"; + private static final String FALSE = "false"; + + private final StringPool stringPool = new StringPool(); + + /** The input JSON. */ + private final Reader in; + + /** True to accept non-spec compliant JSON */ + private boolean lenient = false; + + /** + * Use a manual buffer to easily read and unread upcoming characters, and + * also so we can create strings without an intermediate StringBuilder. + * We decode literals directly out of this buffer, so it must be at least as + * long as the longest token that can be reported as a number. + */ + private final char[] buffer = new char[1024]; + private int pos = 0; + private int limit = 0; + + /* + * The offset of the first character in the buffer. + */ + private int bufferStartLine = 1; + private int bufferStartColumn = 1; + + private final List stack = new ArrayList(); + { + push(JsonScope.EMPTY_DOCUMENT); + } + + /** + * The type of the next token to be returned by {@link #peek} and {@link + * #advance}. If null, peek() will assign a value. + */ + private JsonToken token; + + /** The text of the next name. */ + private String name; + + /* + * For the next literal value, we may have the text value, or the position + * and length in the buffer. + */ + private String value; + private int valuePos; + private int valueLength; + + /** True if we're currently handling a skipValue() call. */ + private boolean skipping = false; + + /** + * Creates a new instance that reads a JSON-encoded stream from {@code in}. + */ + public JsonReader(Reader in) { + if (in == null) { + throw new NullPointerException("in == null"); + } + this.in = in; + } + + /** + * Configure this parser to be be liberal in what it accepts. By default, + * this parser is strict and only accepts JSON as specified by RFC 4627. Setting the + * parser to lenient causes it to ignore the following syntax errors: + * + *

    + *
  • End of line comments starting with {@code //} or {@code #} and + * ending with a newline character. + *
  • C-style comments starting with {@code /*} and ending with + * {@code *}{@code /}. Such comments may not be nested. + *
  • Names that are unquoted or {@code 'single quoted'}. + *
  • Strings that are unquoted or {@code 'single quoted'}. + *
  • Array elements separated by {@code ;} instead of {@code ,}. + *
  • Unnecessary array separators. These are interpreted as if null + * was the omitted value. + *
  • Names and values separated by {@code =} or {@code =>} instead of + * {@code :}. + *
  • Name/value pairs separated by {@code ;} instead of {@code ,}. + *
+ */ + public void setLenient(boolean lenient) { + this.lenient = lenient; + } + + /** + * Returns true if this parser is liberal in what it accepts. + */ + public boolean isLenient() { + return lenient; + } + + /** + * Consumes the next token from the JSON stream and asserts that it is the + * beginning of a new array. + */ + public void beginArray() throws IOException { + expect(JsonToken.BEGIN_ARRAY); + } + + /** + * Consumes the next token from the JSON stream and asserts that it is the + * end of the current array. + */ + public void endArray() throws IOException { + expect(JsonToken.END_ARRAY); + } + + /** + * Consumes the next token from the JSON stream and asserts that it is the + * beginning of a new object. + */ + public void beginObject() throws IOException { + expect(JsonToken.BEGIN_OBJECT); + } + + /** + * Consumes the next token from the JSON stream and asserts that it is the + * end of the current object. + */ + public void endObject() throws IOException { + expect(JsonToken.END_OBJECT); + } + + /** + * Consumes {@code expected}. + */ + private void expect(JsonToken expected) throws IOException { + peek(); + if (token != expected) { + throw new IllegalStateException("Expected " + expected + " but was " + peek()); + } + advance(); + } + + /** + * Returns true if the current array or object has another element. + */ + public boolean hasNext() throws IOException { + peek(); + return token != JsonToken.END_OBJECT && token != JsonToken.END_ARRAY; + } + + /** + * Returns the type of the next token without consuming it. + */ + public JsonToken peek() throws IOException { + if (token != null) { + return token; + } + + switch (peekStack()) { + case EMPTY_DOCUMENT: + replaceTop(JsonScope.NONEMPTY_DOCUMENT); + JsonToken firstToken = nextValue(); + if (!lenient && token != JsonToken.BEGIN_ARRAY && token != JsonToken.BEGIN_OBJECT) { + throw new IOException( + "Expected JSON document to start with '[' or '{' but was " + token); + } + return firstToken; + case EMPTY_ARRAY: + return nextInArray(true); + case NONEMPTY_ARRAY: + return nextInArray(false); + case EMPTY_OBJECT: + return nextInObject(true); + case DANGLING_NAME: + return objectValue(); + case NONEMPTY_OBJECT: + return nextInObject(false); + case NONEMPTY_DOCUMENT: + try { + JsonToken token = nextValue(); + if (lenient) { + return token; + } + throw syntaxError("Expected EOF"); + } catch (EOFException e) { + return token = JsonToken.END_DOCUMENT; // TODO: avoid throwing here? + } + case CLOSED: + throw new IllegalStateException("JsonReader is closed"); + default: + throw new AssertionError(); + } + } + + /** + * Advances the cursor in the JSON stream to the next token. + */ + private JsonToken advance() throws IOException { + peek(); + + JsonToken result = token; + token = null; + value = null; + name = null; + return result; + } + + /** + * Returns the next token, a {@link JsonToken#NAME property name}, and + * consumes it. + * + * @throws IOException if the next token in the stream is not a property + * name. + */ + public String nextName() throws IOException { + peek(); + if (token != JsonToken.NAME) { + throw new IllegalStateException("Expected a name but was " + peek()); + } + String result = name; + advance(); + return result; + } + + /** + * Returns the {@link JsonToken#STRING string} value of the next token, + * consuming it. If the next token is a number, this method will return its + * string form. + * + * @throws IllegalStateException if the next token is not a string or if + * this reader is closed. + */ + public String nextString() throws IOException { + peek(); + if (token != JsonToken.STRING && token != JsonToken.NUMBER) { + throw new IllegalStateException("Expected a string but was " + peek()); + } + + String result = value; + advance(); + return result; + } + + /** + * Returns the {@link JsonToken#BOOLEAN boolean} value of the next token, + * consuming it. + * + * @throws IllegalStateException if the next token is not a boolean or if + * this reader is closed. + */ + public boolean nextBoolean() throws IOException { + peek(); + if (token != JsonToken.BOOLEAN) { + throw new IllegalStateException("Expected a boolean but was " + token); + } + + boolean result = (value == TRUE); + advance(); + return result; + } + + /** + * Consumes the next token from the JSON stream and asserts that it is a + * literal null. + * + * @throws IllegalStateException if the next token is not null or if this + * reader is closed. + */ + public void nextNull() throws IOException { + peek(); + if (token != JsonToken.NULL) { + throw new IllegalStateException("Expected null but was " + token); + } + + advance(); + } + + /** + * Returns the {@link JsonToken#NUMBER double} value of the next token, + * consuming it. If the next token is a string, this method will attempt to + * parse it as a double using {@link Double#parseDouble(String)}. + * + * @throws IllegalStateException if the next token is not a literal value. + */ + public double nextDouble() throws IOException { + peek(); + if (token != JsonToken.STRING && token != JsonToken.NUMBER) { + throw new IllegalStateException("Expected a double but was " + token); + } + + double result = Double.parseDouble(value); + advance(); + return result; + } + + /** + * Returns the {@link JsonToken#NUMBER long} value of the next token, + * consuming it. If the next token is a string, this method will attempt to + * parse it as a long. If the next token's numeric value cannot be exactly + * represented by a Java {@code long}, this method throws. + * + * @throws IllegalStateException if the next token is not a literal value. + * @throws NumberFormatException if the next literal value cannot be parsed + * as a number, or exactly represented as a long. + */ + public long nextLong() throws IOException { + peek(); + if (token != JsonToken.STRING && token != JsonToken.NUMBER) { + throw new IllegalStateException("Expected a long but was " + token); + } + + long result; + try { + result = Long.parseLong(value); + } catch (NumberFormatException ignored) { + double asDouble = Double.parseDouble(value); // don't catch this NumberFormatException + result = (long) asDouble; + if ((double) result != asDouble) { + throw new NumberFormatException(value); + } + } + + advance(); + return result; + } + + /** + * Returns the {@link JsonToken#NUMBER int} value of the next token, + * consuming it. If the next token is a string, this method will attempt to + * parse it as an int. If the next token's numeric value cannot be exactly + * represented by a Java {@code int}, this method throws. + * + * @throws IllegalStateException if the next token is not a literal value. + * @throws NumberFormatException if the next literal value cannot be parsed + * as a number, or exactly represented as an int. + */ + public int nextInt() throws IOException { + peek(); + if (token != JsonToken.STRING && token != JsonToken.NUMBER) { + throw new IllegalStateException("Expected an int but was " + token); + } + + int result; + try { + result = Integer.parseInt(value); + } catch (NumberFormatException ignored) { + double asDouble = Double.parseDouble(value); // don't catch this NumberFormatException + result = (int) asDouble; + if ((double) result != asDouble) { + throw new NumberFormatException(value); + } + } + + advance(); + return result; + } + + /** + * Closes this JSON reader and the underlying {@link Reader}. + */ + public void close() throws IOException { + value = null; + token = null; + stack.clear(); + stack.add(JsonScope.CLOSED); + in.close(); + } + + /** + * Skips the next value recursively. If it is an object or array, all nested + * elements are skipped. This method is intended for use when the JSON token + * stream contains unrecognized or unhandled values. + */ + public void skipValue() throws IOException { + skipping = true; + try { + if (!hasNext() || peek() == JsonToken.END_DOCUMENT) { + throw new IllegalStateException("No element left to skip"); + } + int count = 0; + do { + JsonToken token = advance(); + if (token == JsonToken.BEGIN_ARRAY || token == JsonToken.BEGIN_OBJECT) { + count++; + } else if (token == JsonToken.END_ARRAY || token == JsonToken.END_OBJECT) { + count--; + } + } while (count != 0); + } finally { + skipping = false; + } + } + + private JsonScope peekStack() { + return stack.get(stack.size() - 1); + } + + private JsonScope pop() { + return stack.remove(stack.size() - 1); + } + + private void push(JsonScope newTop) { + stack.add(newTop); + } + + /** + * Replace the value on the top of the stack with the given value. + */ + private void replaceTop(JsonScope newTop) { + stack.set(stack.size() - 1, newTop); + } + + private JsonToken nextInArray(boolean firstElement) throws IOException { + if (firstElement) { + replaceTop(JsonScope.NONEMPTY_ARRAY); + } else { + /* Look for a comma before each element after the first element. */ + switch (nextNonWhitespace()) { + case ']': + pop(); + return token = JsonToken.END_ARRAY; + case ';': + checkLenient(); // fall-through + case ',': + break; + default: + throw syntaxError("Unterminated array"); + } + } + + switch (nextNonWhitespace()) { + case ']': + if (firstElement) { + pop(); + return token = JsonToken.END_ARRAY; + } + // fall-through to handle ",]" + case ';': + case ',': + /* In lenient mode, a 0-length literal means 'null' */ + checkLenient(); + pos--; + value = "null"; + return token = JsonToken.NULL; + default: + pos--; + return nextValue(); + } + } + + private JsonToken nextInObject(boolean firstElement) throws IOException { + /* + * Read delimiters. Either a comma/semicolon separating this and the + * previous name-value pair, or a close brace to denote the end of the + * object. + */ + if (firstElement) { + /* Peek to see if this is the empty object. */ + switch (nextNonWhitespace()) { + case '}': + pop(); + return token = JsonToken.END_OBJECT; + default: + pos--; + } + } else { + switch (nextNonWhitespace()) { + case '}': + pop(); + return token = JsonToken.END_OBJECT; + case ';': + case ',': + break; + default: + throw syntaxError("Unterminated object"); + } + } + + /* Read the name. */ + int quote = nextNonWhitespace(); + switch (quote) { + case '\'': + checkLenient(); // fall-through + case '"': + name = nextString((char) quote); + break; + default: + checkLenient(); + pos--; + name = nextLiteral(false); + if (name.isEmpty()) { + throw syntaxError("Expected name"); + } + } + + replaceTop(JsonScope.DANGLING_NAME); + return token = JsonToken.NAME; + } + + private JsonToken objectValue() throws IOException { + /* + * Read the name/value separator. Usually a colon ':'. In lenient mode + * we also accept an equals sign '=', or an arrow "=>". + */ + switch (nextNonWhitespace()) { + case ':': + break; + case '=': + checkLenient(); + if ((pos < limit || fillBuffer(1)) && buffer[pos] == '>') { + pos++; + } + break; + default: + throw syntaxError("Expected ':'"); + } + + replaceTop(JsonScope.NONEMPTY_OBJECT); + return nextValue(); + } + + private JsonToken nextValue() throws IOException { + int c = nextNonWhitespace(); + switch (c) { + case '{': + push(JsonScope.EMPTY_OBJECT); + return token = JsonToken.BEGIN_OBJECT; + + case '[': + push(JsonScope.EMPTY_ARRAY); + return token = JsonToken.BEGIN_ARRAY; + + case '\'': + checkLenient(); // fall-through + case '"': + value = nextString((char) c); + return token = JsonToken.STRING; + + default: + pos--; + return readLiteral(); + } + } + + /** + * Returns true once {@code limit - pos >= minimum}. If the data is + * exhausted before that many characters are available, this returns + * false. + */ + private boolean fillBuffer(int minimum) throws IOException { + // Before clobbering the old characters, update where buffer starts + for (int i = 0; i < pos; i++) { + if (buffer[i] == '\n') { + bufferStartLine++; + bufferStartColumn = 1; + } else { + bufferStartColumn++; + } + } + + if (limit != pos) { + limit -= pos; + System.arraycopy(buffer, pos, buffer, 0, limit); + } else { + limit = 0; + } + + pos = 0; + int total; + while ((total = in.read(buffer, limit, buffer.length - limit)) != -1) { + limit += total; + + // if this is the first read, consume an optional byte order mark (BOM) if it exists + if (bufferStartLine == 1 && bufferStartColumn == 1 + && limit > 0 && buffer[0] == '\ufeff') { + pos++; + bufferStartColumn--; + } + + if (limit >= minimum) { + return true; + } + } + return false; + } + + private int getLineNumber() { + int result = bufferStartLine; + for (int i = 0; i < pos; i++) { + if (buffer[i] == '\n') { + result++; + } + } + return result; + } + + private int getColumnNumber() { + int result = bufferStartColumn; + for (int i = 0; i < pos; i++) { + if (buffer[i] == '\n') { + result = 1; + } else { + result++; + } + } + return result; + } + + private int nextNonWhitespace() throws IOException { + while (pos < limit || fillBuffer(1)) { + int c = buffer[pos++]; + switch (c) { + case '\t': + case ' ': + case '\n': + case '\r': + continue; + + case '/': + if (pos == limit && !fillBuffer(1)) { + return c; + } + + checkLenient(); + char peek = buffer[pos]; + switch (peek) { + case '*': + // skip a /* c-style comment */ + pos++; + if (!skipTo("*/")) { + throw syntaxError("Unterminated comment"); + } + pos += 2; + continue; + + case '/': + // skip a // end-of-line comment + pos++; + skipToEndOfLine(); + continue; + + default: + return c; + } + + case '#': + /* + * Skip a # hash end-of-line comment. The JSON RFC doesn't + * specify this behaviour, but it's required to parse + * existing documents. See http://b/2571423. + */ + checkLenient(); + skipToEndOfLine(); + continue; + + default: + return c; + } + } + + throw new EOFException("End of input"); + } + + private void checkLenient() throws IOException { + if (!lenient) { + throw syntaxError("Use JsonReader.setLenient(true) to accept malformed JSON"); + } + } + + /** + * Advances the position until after the next newline character. If the line + * is terminated by "\r\n", the '\n' must be consumed as whitespace by the + * caller. + */ + private void skipToEndOfLine() throws IOException { + while (pos < limit || fillBuffer(1)) { + char c = buffer[pos++]; + if (c == '\r' || c == '\n') { + break; + } + } + } + + private boolean skipTo(String toFind) throws IOException { + outer: + for (; pos + toFind.length() <= limit || fillBuffer(toFind.length()); pos++) { + for (int c = 0; c < toFind.length(); c++) { + if (buffer[pos + c] != toFind.charAt(c)) { + continue outer; + } + } + return true; + } + return false; + } + + /** + * Returns the string up to but not including {@code quote}, unescaping any + * character escape sequences encountered along the way. The opening quote + * should have already been read. This consumes the closing quote, but does + * not include it in the returned string. + * + * @param quote either ' or ". + * @throws NumberFormatException if any unicode escape sequences are + * malformed. + */ + private String nextString(char quote) throws IOException { + StringBuilder builder = null; + do { + /* the index of the first character not yet appended to the builder. */ + int start = pos; + while (pos < limit) { + int c = buffer[pos++]; + + if (c == quote) { + if (skipping) { + return "skipped!"; + } else if (builder == null) { + return stringPool.get(buffer, start, pos - start - 1); + } else { + builder.append(buffer, start, pos - start - 1); + return builder.toString(); + } + + } else if (c == '\\') { + if (builder == null) { + builder = new StringBuilder(); + } + builder.append(buffer, start, pos - start - 1); + builder.append(readEscapeCharacter()); + start = pos; + } + } + + if (builder == null) { + builder = new StringBuilder(); + } + builder.append(buffer, start, pos - start); + } while (fillBuffer(1)); + + throw syntaxError("Unterminated string"); + } + + /** + * Reads the value up to but not including any delimiter characters. This + * does not consume the delimiter character. + * + * @param assignOffsetsOnly true for this method to only set the valuePos + * and valueLength fields and return a null result. This only works if + * the literal is short; a string is returned otherwise. + */ + private String nextLiteral(boolean assignOffsetsOnly) throws IOException { + StringBuilder builder = null; + valuePos = -1; + valueLength = 0; + int i = 0; + + findNonLiteralCharacter: + while (true) { + for (; pos + i < limit; i++) { + switch (buffer[pos + i]) { + case '/': + case '\\': + case ';': + case '#': + case '=': + checkLenient(); // fall-through + case '{': + case '}': + case '[': + case ']': + case ':': + case ',': + case ' ': + case '\t': + case '\f': + case '\r': + case '\n': + break findNonLiteralCharacter; + } + } + + /* + * Attempt to load the entire literal into the buffer at once. If + * we run out of input, add a non-literal character at the end so + * that decoding doesn't need to do bounds checks. + */ + if (i < buffer.length) { + if (fillBuffer(i + 1)) { + continue; + } else { + buffer[limit] = '\0'; + break; + } + } + + // use a StringBuilder when the value is too long. It must be an unquoted string. + if (builder == null) { + builder = new StringBuilder(); + } + builder.append(buffer, pos, i); + valueLength += i; + pos += i; + i = 0; + if (!fillBuffer(1)) { + break; + } + } + + String result; + if (assignOffsetsOnly && builder == null) { + valuePos = pos; + result = null; + } else if (skipping) { + result = "skipped!"; + } else if (builder == null) { + result = stringPool.get(buffer, pos, i); + } else { + builder.append(buffer, pos, i); + result = builder.toString(); + } + valueLength += i; + pos += i; + return result; + } + + @Override public String toString() { + return getClass().getSimpleName() + " near " + getSnippet(); + } + + /** + * Unescapes the character identified by the character or characters that + * immediately follow a backslash. The backslash '\' should have already + * been read. This supports both unicode escapes "u000A" and two-character + * escapes "\n". + * + * @throws NumberFormatException if any unicode escape sequences are + * malformed. + */ + private char readEscapeCharacter() throws IOException { + if (pos == limit && !fillBuffer(1)) { + throw syntaxError("Unterminated escape sequence"); + } + + char escaped = buffer[pos++]; + switch (escaped) { + case 'u': + if (pos + 4 > limit && !fillBuffer(4)) { + throw syntaxError("Unterminated escape sequence"); + } + String hex = stringPool.get(buffer, pos, 4); + pos += 4; + return (char) Integer.parseInt(hex, 16); + + case 't': + return '\t'; + + case 'b': + return '\b'; + + case 'n': + return '\n'; + + case 'r': + return '\r'; + + case 'f': + return '\f'; + + case '\'': + case '"': + case '\\': + default: + return escaped; + } + } + + /** + * Reads a null, boolean, numeric or unquoted string literal value. + */ + private JsonToken readLiteral() throws IOException { + value = nextLiteral(true); + if (valueLength == 0) { + throw syntaxError("Expected literal value"); + } + token = decodeLiteral(); + if (token == JsonToken.STRING) { + checkLenient(); + } + return token; + } + + /** + * Assigns {@code nextToken} based on the value of {@code nextValue}. + */ + private JsonToken decodeLiteral() throws IOException { + if (valuePos == -1) { + // it was too long to fit in the buffer so it can only be a string + return JsonToken.STRING; + } else if (valueLength == 4 + && ('n' == buffer[valuePos ] || 'N' == buffer[valuePos ]) + && ('u' == buffer[valuePos + 1] || 'U' == buffer[valuePos + 1]) + && ('l' == buffer[valuePos + 2] || 'L' == buffer[valuePos + 2]) + && ('l' == buffer[valuePos + 3] || 'L' == buffer[valuePos + 3])) { + value = "null"; + return JsonToken.NULL; + } else if (valueLength == 4 + && ('t' == buffer[valuePos ] || 'T' == buffer[valuePos ]) + && ('r' == buffer[valuePos + 1] || 'R' == buffer[valuePos + 1]) + && ('u' == buffer[valuePos + 2] || 'U' == buffer[valuePos + 2]) + && ('e' == buffer[valuePos + 3] || 'E' == buffer[valuePos + 3])) { + value = TRUE; + return JsonToken.BOOLEAN; + } else if (valueLength == 5 + && ('f' == buffer[valuePos ] || 'F' == buffer[valuePos ]) + && ('a' == buffer[valuePos + 1] || 'A' == buffer[valuePos + 1]) + && ('l' == buffer[valuePos + 2] || 'L' == buffer[valuePos + 2]) + && ('s' == buffer[valuePos + 3] || 'S' == buffer[valuePos + 3]) + && ('e' == buffer[valuePos + 4] || 'E' == buffer[valuePos + 4])) { + value = FALSE; + return JsonToken.BOOLEAN; + } else { + value = stringPool.get(buffer, valuePos, valueLength); + return decodeNumber(buffer, valuePos, valueLength); + } + } + + /** + * Determine whether the characters is a JSON number. Numbers are of the + * form -12.34e+56. Fractional and exponential parts are optional. Leading + * zeroes are not allowed in the value or exponential part, but are allowed + * in the fraction. + */ + private JsonToken decodeNumber(char[] chars, int offset, int length) { + int i = offset; + int c = chars[i]; + + if (c == '-') { + c = chars[++i]; + } + + if (c == '0') { + c = chars[++i]; + } else if (c >= '1' && c <= '9') { + c = chars[++i]; + while (c >= '0' && c <= '9') { + c = chars[++i]; + } + } else { + return JsonToken.STRING; + } + + if (c == '.') { + c = chars[++i]; + while (c >= '0' && c <= '9') { + c = chars[++i]; + } + } + + if (c == 'e' || c == 'E') { + c = chars[++i]; + if (c == '+' || c == '-') { + c = chars[++i]; + } + if (c >= '0' && c <= '9') { + c = chars[++i]; + while (c >= '0' && c <= '9') { + c = chars[++i]; + } + } else { + return JsonToken.STRING; + } + } + + if (i == offset + length) { + return JsonToken.NUMBER; + } else { + return JsonToken.STRING; + } + } + + /** + * Throws a new IO exception with the given message and a context snippet + * with this reader's content. + */ + private IOException syntaxError(String message) throws IOException { + throw new MalformedJsonException(message + + " at line " + getLineNumber() + " column " + getColumnNumber()); + } + + private CharSequence getSnippet() { + StringBuilder snippet = new StringBuilder(); + int beforePos = Math.min(pos, 20); + snippet.append(buffer, pos - beforePos, beforePos); + int afterPos = Math.min(limit - pos, 20); + snippet.append(buffer, pos, afterPos); + return snippet; + } +} diff --git a/src/main/java/android/util/JsonScope.java b/src/main/java/android/util/JsonScope.java new file mode 100644 index 0000000..ca534e9 --- /dev/null +++ b/src/main/java/android/util/JsonScope.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2010 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 android.util; + +/** + * Lexical scoping elements within a JSON reader or writer. + */ +enum JsonScope { + + /** + * An array with no elements requires no separators or newlines before + * it is closed. + */ + EMPTY_ARRAY, + + /** + * A array with at least one value requires a comma and newline before + * the next element. + */ + NONEMPTY_ARRAY, + + /** + * An object with no name/value pairs requires no separators or newlines + * before it is closed. + */ + EMPTY_OBJECT, + + /** + * An object whose most recent element is a key. The next element must + * be a value. + */ + DANGLING_NAME, + + /** + * An object with at least one name/value pair requires a comma and + * newline before the next element. + */ + NONEMPTY_OBJECT, + + /** + * No object or array has been started. + */ + EMPTY_DOCUMENT, + + /** + * A document with at an array or object. + */ + NONEMPTY_DOCUMENT, + + /** + * A document that's been closed and cannot be accessed. + */ + CLOSED, +} diff --git a/src/main/java/android/util/JsonToken.java b/src/main/java/android/util/JsonToken.java new file mode 100644 index 0000000..45bc6ca --- /dev/null +++ b/src/main/java/android/util/JsonToken.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2010 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 android.util; + +/** + * A structure, name or value type in a JSON-encoded string. + */ +public enum JsonToken { + + /** + * The opening of a JSON array. Written using {@link JsonWriter#beginObject} + * and read using {@link JsonReader#beginObject}. + */ + BEGIN_ARRAY, + + /** + * The closing of a JSON array. Written using {@link JsonWriter#endArray} + * and read using {@link JsonReader#endArray}. + */ + END_ARRAY, + + /** + * The opening of a JSON object. Written using {@link JsonWriter#beginObject} + * and read using {@link JsonReader#beginObject}. + */ + BEGIN_OBJECT, + + /** + * The closing of a JSON object. Written using {@link JsonWriter#endObject} + * and read using {@link JsonReader#endObject}. + */ + END_OBJECT, + + /** + * A JSON property name. Within objects, tokens alternate between names and + * their values. Written using {@link JsonWriter#name} and read using {@link + * JsonReader#nextName} + */ + NAME, + + /** + * A JSON string. + */ + STRING, + + /** + * A JSON number represented in this API by a Java {@code double}, {@code + * long}, or {@code int}. + */ + NUMBER, + + /** + * A JSON {@code true} or {@code false}. + */ + BOOLEAN, + + /** + * A JSON {@code null}. + */ + NULL, + + /** + * The end of the JSON stream. This sentinel value is returned by {@link + * JsonReader#peek()} to signal that the JSON-encoded value has no more + * tokens. + */ + END_DOCUMENT +} diff --git a/src/main/java/android/util/MalformedJsonException.java b/src/main/java/android/util/MalformedJsonException.java new file mode 100644 index 0000000..63c19ff --- /dev/null +++ b/src/main/java/android/util/MalformedJsonException.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2011 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 android.util; + +import java.io.IOException; + +/** + * Thrown when a reader encounters malformed JSON. Some syntax errors can be + * ignored by calling {@link JsonReader#setLenient(boolean)}. + */ +public final class MalformedJsonException extends IOException { + private static final long serialVersionUID = 1L; + + public MalformedJsonException(String message) { + super(message); + } +} diff --git a/src/main/java/com/android/internal/util/StringPool.java b/src/main/java/com/android/internal/util/StringPool.java new file mode 100644 index 0000000..c5180a3 --- /dev/null +++ b/src/main/java/com/android/internal/util/StringPool.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2010 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 com.android.internal.util; + +/** + * A pool of string instances. Unlike the {@link String#intern() VM's + * interned strings}, this pool provides no guarantee of reference equality. + * It is intended only to save allocations. This class is not thread safe. + * + * @hide + */ +public final class StringPool { + + private final String[] mPool = new String[512]; + + /** + * Constructs string pool. + */ + public StringPool() { + } + + private static boolean contentEquals(String s, char[] chars, int start, int length) { + if (s.length() != length) { + return false; + } + for (int i = 0; i < length; i++) { + if (chars[start + i] != s.charAt(i)) { + return false; + } + } + return true; + } + + /** + * Returns a string equal to {@code new String(array, start, length)}. + * + * @param array buffer containing string chars + * @param start offset in {@code array} where string starts + * @param length length of string + * @return string equal to {@code new String(array, start, length)} + */ + public String get(char[] array, int start, int length) { + // Compute an arbitrary hash of the content + int hashCode = 0; + for (int i = start; i < start + length; i++) { + hashCode = (hashCode * 31) + array[i]; + } + + // Pick a bucket using Doug Lea's supplemental secondaryHash function (from HashMap) + hashCode ^= (hashCode >>> 20) ^ (hashCode >>> 12); + hashCode ^= (hashCode >>> 7) ^ (hashCode >>> 4); + int index = hashCode & (mPool.length - 1); + + String pooled = mPool[index]; + if (pooled != null && contentEquals(pooled, array, start, length)) { + return pooled; + } + + String result = new String(array, start, length); + mPool[index] = result; + return result; + } +} diff --git a/src/test/kotlin/JsonReaderTest.kt b/src/test/kotlin/JsonReaderTest.kt new file mode 100644 index 0000000..7f83a73 --- /dev/null +++ b/src/test/kotlin/JsonReaderTest.kt @@ -0,0 +1,44 @@ +/* + * Tests the android.util.JsonReader port on the response shape Firebase Installations parses. + */ +import android.util.JsonReader +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.StringReader + +class JsonReaderTest { + + @Test + fun `parses an installations response`() { + val json = """{"name":"projects/1/installations/fid-123","fid":"fid-123","refreshToken":"refresh","authToken":{"token":"auth","expiresIn":"604800s"},"unknown":[1,{"a":true}]}""" + val values = mutableMapOf() + + JsonReader(StringReader(json)).use { reader -> + reader.beginObject() + while (reader.hasNext()) { + when (val name = reader.nextName()) { + "fid", "refreshToken" -> values[name] = reader.nextString() + "authToken" -> { + reader.beginObject() + while (reader.hasNext()) { + values["authToken." + reader.nextName()] = reader.nextString() + } + reader.endObject() + } + else -> reader.skipValue() + } + } + reader.endObject() + } + + assertEquals( + mapOf( + "fid" to "fid-123", + "refreshToken" to "refresh", + "authToken.token" to "auth", + "authToken.expiresIn" to "604800s" + ), + values + ) + } +} From 4d4af3d65bd497a3ca0bf7c970d6ae0735bd9d20 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:35:43 +0300 Subject: [PATCH 06/33] Register Remote Config and port firebase-kotlin-sdk Remote Config tests --- .../android/content/pm/PackageManager.java | 2 + src/test/kotlin/FirebaseRemoteConfigTest.kt | 148 ++++++++++++++++++ src/test/kotlin/FirebaseTest.kt | 2 + 3 files changed, 152 insertions(+) create mode 100644 src/test/kotlin/FirebaseRemoteConfigTest.kt diff --git a/src/main/java/android/content/pm/PackageManager.java b/src/main/java/android/content/pm/PackageManager.java index b94704d..7417b61 100644 --- a/src/main/java/android/content/pm/PackageManager.java +++ b/src/main/java/android/content/pm/PackageManager.java @@ -40,6 +40,8 @@ public ServiceInfo getServiceInfo(ComponentName component, int flags) throws Nam data.put("com.google.firebase.components:com.google.firebase.installations.FirebaseInstallationsRegistrar", "com.google.firebase.components.ComponentRegistrar"); data.put("com.google.firebase.components:com.google.firebase.iid.Registrar", "com.google.firebase.components.ComponentRegistrar"); data.put("com.google.firebase.components:com.google.firebase.storage.StorageRegistrar", "com.google.firebase.components.ComponentRegistrar"); + data.put("com.google.firebase.components:com.google.firebase.remoteconfig.RemoteConfigRegistrar", "com.google.firebase.components.ComponentRegistrar"); + data.put("com.google.firebase.components:com.google.firebase.abt.component.AbtRegistrar", "com.google.firebase.components.ComponentRegistrar"); return new ServiceInfo(data); } throw new IllegalArgumentException(component.cls); diff --git a/src/test/kotlin/FirebaseRemoteConfigTest.kt b/src/test/kotlin/FirebaseRemoteConfigTest.kt new file mode 100644 index 0000000..d04722e --- /dev/null +++ b/src/test/kotlin/FirebaseRemoteConfigTest.kt @@ -0,0 +1,148 @@ +/* + * Remote Config tests ported one-to-one from firebase-kotlin-sdk's common tests + * (firebase-config/src/commonTest/.../FirebaseRemoteConfig.kt) onto the Android API, + * so the Kotlin SDK's JVM target behaves the same when it runs on this SDK. + */ +import android.app.Application +import com.google.firebase.Firebase +import com.google.firebase.FirebaseOptions +import com.google.firebase.initialize +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings +import com.google.firebase.remoteconfig.FirebaseRemoteConfigValue +import com.google.firebase.remoteconfig.get +import com.google.firebase.remoteconfig.remoteConfig +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Ignore +import org.junit.Test + +class FirebaseRemoteConfigTest : FirebaseTest() { + + private val defaults = mapOf( + "test_default_boolean" to true, + "test_default_double" to 42.0, + "test_default_long" to 42L, + "test_default_string" to "Hello World" + ) + + private val remoteConfig: FirebaseRemoteConfig by lazy { Firebase.remoteConfig(app) } + + @After + fun tearDown(): Unit = runTest { + remoteConfig.reset().await() + } + + @Test + fun testGettingValues(): Unit = runTest { + remoteConfig.setDefaultsAsync(defaults).await() + + assertEquals(true, remoteConfig.getBoolean("test_default_boolean")) + assertEquals(42.0, remoteConfig.getDouble("test_default_double"), 0.0) + assertEquals(42L, remoteConfig.getLong("test_default_long")) + assertEquals("Hello World", remoteConfig.getString("test_default_string")) + assertEquals("Hello World", remoteConfig.getString("test_default_string")) + + val value: FirebaseRemoteConfigValue = remoteConfig["test_default_string"] + assertEquals("Hello World", value.asString()) + assertEquals(FirebaseRemoteConfig.VALUE_SOURCE_DEFAULT, value.source) + assertEquals("Hello World", value.asByteArray().decodeToString()) + } + + @Test + fun testNamedApp(): Unit = runTest { + // Android keys Remote Config's local storage by app ID, so the named app needs its own + val options = FirebaseOptions.Builder(app.options).setApplicationId("1:341458593155:web:bf8e1aa37efe01f32d42b7").build() + val namedApp = Firebase.initialize(Application(), options, "named") + val namedRemoteConfig = Firebase.remoteConfig(namedApp) + namedRemoteConfig.setDefaultsAsync(mapOf("named_app_only" to "named")).await() + + assertEquals("named", namedRemoteConfig.getValue("named_app_only").asString()) + assertFalse(remoteConfig.all.containsKey("named_app_only")) + namedRemoteConfig.reset().await() + } + + @Test + fun testGetAll(): Unit = runTest { + remoteConfig.setDefaultsAsync(defaults).await() + val all = remoteConfig.all + assertEquals(true, all["test_default_boolean"]?.asBoolean()) + assertEquals(42.0, all["test_default_double"]?.asDouble()) + assertEquals(42L, all["test_default_long"]?.asLong()) + assertEquals("Hello World", all["test_default_string"]?.asString()) + assertEquals("Hello World", all["test_default_string"]?.asByteArray()?.decodeToString()) + } + + @Test + fun testGetKeysByPrefix(): Unit = runTest { + remoteConfig.setDefaultsAsync(defaults).await() + val keys = remoteConfig.getKeysByPrefix("test_default") + assertEquals( + setOf( + "test_default_boolean", + "test_default_double", + "test_default_long", + "test_default_string" + ), + keys + ) + } + + @Test + fun testGetInfo(): Unit = runTest { + val info = remoteConfig.info + val defaultSettings = FirebaseRemoteConfigSettings.Builder().build() + assertEquals(defaultSettings.fetchTimeoutInSeconds, info.configSettings.fetchTimeoutInSeconds) + assertEquals(defaultSettings.minimumFetchIntervalInSeconds, info.configSettings.minimumFetchIntervalInSeconds) + assertEquals(-1L, info.fetchTimeMillis) + assertEquals(FirebaseRemoteConfig.LAST_FETCH_STATUS_NO_FETCH_YET, info.lastFetchStatus) + } + + @Test + fun testSetConfigSettings(): Unit = runTest { + remoteConfig.setConfigSettingsAsync( + FirebaseRemoteConfigSettings.Builder() + .setFetchTimeoutInSeconds(42) + .setMinimumFetchIntervalInSeconds(42) + .build() + ).await() + val info = remoteConfig.info + assertEquals(42L, info.configSettings.fetchTimeoutInSeconds) + assertEquals(42L, info.configSettings.minimumFetchIntervalInSeconds) + } + + // Unfortunately Firebase Remote Config is not implemented by Firebase emulator so it may be + // tested against a real project only. Add "test_remote_string": "Hello from remote!" config + // value in Firebase console for enabling this test case. + @Test + @Ignore + fun testFetch(): Unit = runTest { + remoteConfig.setConfigSettingsAsync( + FirebaseRemoteConfigSettings.Builder().setMinimumFetchIntervalInSeconds(60).build() + ).await() + + remoteConfig.fetch().await() + remoteConfig.activate().await() + + val value: FirebaseRemoteConfigValue = remoteConfig["test_remote_string"] + assertEquals("Hello from remote!", value.asString()) + assertEquals(FirebaseRemoteConfig.VALUE_SOURCE_REMOTE, value.source) + } + + @Test + @Ignore + fun testFetchAndActivate(): Unit = runTest { + remoteConfig.setConfigSettingsAsync( + FirebaseRemoteConfigSettings.Builder().setMinimumFetchIntervalInSeconds(60).build() + ).await() + + remoteConfig.fetchAndActivate().await() + + val value: FirebaseRemoteConfigValue = remoteConfig["test_remote_string"] + assertEquals("Hello from remote!", value.asString()) + assertEquals(FirebaseRemoteConfig.VALUE_SOURCE_REMOTE, value.source) + } +} diff --git a/src/test/kotlin/FirebaseTest.kt b/src/test/kotlin/FirebaseTest.kt index 36d91b0..d5b9dd7 100644 --- a/src/test/kotlin/FirebaseTest.kt +++ b/src/test/kotlin/FirebaseTest.kt @@ -45,6 +45,8 @@ abstract class FirebaseTest { override fun log(msg: String) = println(msg) override fun getDatabasePath(name: String) = File("./build/$name") + + override fun getFilesDir() = File("./build/files") } ) } From f3fd6f6f9d7406f8c9468954c26f10cc30c499ad Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:39:30 +0300 Subject: [PATCH 07/33] Add shims for Remote Config fetch through Firebase Installations --- .../java/android/content/res/Configuration.java | 11 +++++++++++ src/main/java/android/content/res/Resources.java | 4 ++++ src/main/java/android/net/TrafficStats.java | 14 ++++++++++++++ src/test/kotlin/FirebaseRemoteConfigTest.kt | 7 +++++++ 4 files changed, 36 insertions(+) create mode 100644 src/main/java/android/content/res/Configuration.java create mode 100644 src/main/java/android/net/TrafficStats.java diff --git a/src/main/java/android/content/res/Configuration.java b/src/main/java/android/content/res/Configuration.java new file mode 100644 index 0000000..f82ee7c --- /dev/null +++ b/src/main/java/android/content/res/Configuration.java @@ -0,0 +1,11 @@ +/* + * Shim for android.content.res.Configuration. Remote Config reads the locale from it + * and sends it with each fetch request. + */ +package android.content.res; + +import java.util.Locale; + +public class Configuration { + public Locale locale = Locale.getDefault(); +} diff --git a/src/main/java/android/content/res/Resources.java b/src/main/java/android/content/res/Resources.java index 1ec92f9..4cee805 100644 --- a/src/main/java/android/content/res/Resources.java +++ b/src/main/java/android/content/res/Resources.java @@ -1,6 +1,10 @@ package android.content.res; public class Resources { + public Configuration getConfiguration() { + return new Configuration(); + } + public static class NotFoundException extends RuntimeException { } } diff --git a/src/main/java/android/net/TrafficStats.java b/src/main/java/android/net/TrafficStats.java new file mode 100644 index 0000000..562a35b --- /dev/null +++ b/src/main/java/android/net/TrafficStats.java @@ -0,0 +1,14 @@ +/* + * No-op shim for android.net.TrafficStats. Firebase Installations tags its network thread + * for Android's per-app traffic accounting, which has no JVM equivalent. + */ +package android.net; + +public class TrafficStats { + + public static void setThreadStatsTag(int tag) { + } + + public static void clearThreadStatsTag() { + } +} diff --git a/src/test/kotlin/FirebaseRemoteConfigTest.kt b/src/test/kotlin/FirebaseRemoteConfigTest.kt index d04722e..69183f4 100644 --- a/src/test/kotlin/FirebaseRemoteConfigTest.kt +++ b/src/test/kotlin/FirebaseRemoteConfigTest.kt @@ -114,6 +114,13 @@ class FirebaseRemoteConfigTest : FirebaseTest() { assertEquals(42L, info.configSettings.minimumFetchIntervalInSeconds) } + @Test + fun `fetchAndActivate succeeds`(): Unit = runTest { + remoteConfig.fetchAndActivate().await() + + assertEquals(FirebaseRemoteConfig.LAST_FETCH_STATUS_SUCCESS, remoteConfig.info.lastFetchStatus) + } + // Unfortunately Firebase Remote Config is not implemented by Firebase emulator so it may be // tested against a real project only. Add "test_remote_string": "Hello from remote!" config // value in Firebase console for enabling this test case. From be056592bcdde80fff3b84b906753bcfaa8f9b93 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:41:29 +0300 Subject: [PATCH 08/33] Add DateUtils.formatElapsedTime shim for Remote Config throttling messages --- .../java/android/text/format/DateUtils.java | 18 ++++++++++++++++ src/test/kotlin/DateUtilsTest.kt | 21 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 src/main/java/android/text/format/DateUtils.java create mode 100644 src/test/kotlin/DateUtilsTest.kt diff --git a/src/main/java/android/text/format/DateUtils.java b/src/main/java/android/text/format/DateUtils.java new file mode 100644 index 0000000..8b6f3aa --- /dev/null +++ b/src/main/java/android/text/format/DateUtils.java @@ -0,0 +1,18 @@ +/* + * Shim for android.text.format.DateUtils with the elapsed-time formatting Remote Config + * uses in its throttling messages: "MM:SS", or "H:MM:SS" when hours are present. + */ +package android.text.format; + +public class DateUtils { + + public static String formatElapsedTime(long elapsedSeconds) { + long hours = elapsedSeconds / 3600; + long minutes = (elapsedSeconds % 3600) / 60; + long seconds = elapsedSeconds % 60; + if (hours > 0) { + return String.format("%d:%02d:%02d", hours, minutes, seconds); + } + return String.format("%02d:%02d", minutes, seconds); + } +} diff --git a/src/test/kotlin/DateUtilsTest.kt b/src/test/kotlin/DateUtilsTest.kt new file mode 100644 index 0000000..1b2d548 --- /dev/null +++ b/src/test/kotlin/DateUtilsTest.kt @@ -0,0 +1,21 @@ +/* + * Tests the android.text.format.DateUtils shim, used by Remote Config to format + * the remaining throttle time in fetch errors. + */ +import android.text.format.DateUtils +import org.junit.Assert.assertEquals +import org.junit.Test + +class DateUtilsTest { + + @Test + fun `formats minutes and seconds`() { + assertEquals("00:00", DateUtils.formatElapsedTime(0)) + assertEquals("01:15", DateUtils.formatElapsedTime(75)) + } + + @Test + fun `formats hours when present`() { + assertEquals("1:02:05", DateUtils.formatElapsedTime(3725)) + } +} From 44a91b3e18c89b30295128c67dfee82d224578a6 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:43:05 +0300 Subject: [PATCH 09/33] Add heartbeat storage and log capture to Remote Config spec and plan --- .../plans/2026-10-07-remote-config.md | 155 ++++++++++++++++++ .../specs/2026-10-07-remote-config-design.md | 21 +++ 2 files changed, 176 insertions(+) diff --git a/docs/superpowers/plans/2026-10-07-remote-config.md b/docs/superpowers/plans/2026-10-07-remote-config.md index 6cc68c4..56f40bb 100644 --- a/docs/superpowers/plans/2026-10-07-remote-config.md +++ b/docs/superpowers/plans/2026-10-07-remote-config.md @@ -1018,3 +1018,158 @@ Expected: no exceptions or warnings from Remote Config, Installations, or the sh git add README.md git commit -m "Document Remote Config and Installations support" ``` + +--- + +### Task 7: Platform-backed heartbeat preferences + +Added during execution (spec: "Changes agreed during implementation", item 1). Runs before Task 6. + +**Files:** +- Modify: `src/main/java/android/content/SharedPreferences.java` +- Modify: `src/main/java/android/content/PlatformSharedPreferences.kt` +- Modify: `src/main/java/android/content/Context.kt` +- Test: `src/test/kotlin/PlatformSharedPreferencesTest.kt`, `src/test/kotlin/HeartBeatTest.kt` (create) + +**Interfaces:** +- Produces: `Map getAll()`; `Set getStringSet(String, Set)`; `Editor putStringSet(String, Set)`; `Editor remove(String)`. `Context.getSharedPreferences` returns `PlatformSharedPreferences` for names starting with `FirebaseHeartBeat`. Key index `"|__keys"` becomes a JSON object mapping each key to whether its value is a string set. + +- [ ] **Step 1: Write the failing tests** + +Append to `PlatformSharedPreferencesTest`: + +```kotlin + @Test + fun `string sets round-trip`() { + val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) + prefs.edit().putStringSet("fire-core/20.4.2", setOf("2026-10-07")).commit() + + assertEquals(setOf("2026-10-07"), prefs.getStringSet("fire-core/20.4.2", null)) + assertEquals(setOf("default"), prefs.getStringSet("missing", setOf("default"))) + } + + @Test + fun `getAll returns string sets as sets`() { + val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) + prefs.edit() + .putString("last-used-date", "2026-10-07") + .putStringSet("fire-core/20.4.2", setOf("2026-10-06", "2026-10-07")) + .commit() + + assertEquals( + mapOf("last-used-date" to "2026-10-07", "fire-core/20.4.2" to setOf("2026-10-06", "2026-10-07")), + prefs.all + ) + } + + @Test + fun `remove deletes the key`() { + val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) + prefs.edit().putStringSet("fire-core/20.4.2", setOf("2026-10-07")).commit() + prefs.edit().remove("fire-core/20.4.2").commit() + + assertFalse(prefs.contains("fire-core/20.4.2")) + assertTrue(prefs.all.isEmpty()) + } +``` + +In `other preference files still reject unknown keys`, change the file name to `"com.google.firebase.common.prefs:W0RFRkFVTFRd"` (heartbeat files are now platform-backed) and add: + +```kotlin + assertThrows(IllegalArgumentException::class.java) { prefs.getStringSet("unknown", null) } + assertThrows(IllegalArgumentException::class.java) { prefs.edit().putStringSet("unknown", null) } + assertThrows(IllegalArgumentException::class.java) { prefs.edit().remove("unknown") } +``` + +Create `src/test/kotlin/HeartBeatTest.kt`: + +```kotlin +/* + * Tests that Firebase's heartbeat reporting (firebase-common), which Installations attaches to + * its requests, can store and read heartbeats through the platform-backed preferences. + */ +import com.google.firebase.heartbeatinfo.DefaultHeartBeatController +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class HeartBeatTest : FirebaseTest() { + + @Test + fun `registered heartbeat is reported in the header`(): Unit = runTest { + val controller = app.get(DefaultHeartBeatController::class.java) + + controller.registerHeartBeat().await() + + assertTrue(controller.heartBeatsHeader.await().isNotEmpty()) + } +} +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'PlatformSharedPreferencesTest' --tests 'HeartBeatTest'` +Expected: test compilation fails with unresolved `putStringSet`, `getStringSet`, and `remove`. + +- [ ] **Step 3: Extend the interface** + +In `SharedPreferences.java`, change `Map getAll();` to `Map getAll();`, add `Set getStringSet(String key, Set defValues);` (import `java.util.Set`), and in `Editor` add `Editor putStringSet(String key, Set values);` and `Editor remove(String key);`. + +- [ ] **Step 4: Strict preferences reject the new methods** + +In `Context.kt`'s anonymous `SharedPreferences`, add `getStringSet` throwing `IllegalArgumentException(key)`; in its `Editor`, add `putStringSet` and `remove` throwing `IllegalArgumentException(key)`. + +- [ ] **Step 5: String sets in `PlatformSharedPreferences`** + +Replace the key set with an index mapping each key to whether its value is a string set; store string sets JSON-encoded; `getAll()` decodes them; `putStringSet(key, null)` and `remove(key)` delete the key. Update the header comment to describe the index. Route names starting with `FirebaseHeartBeat` to `PlatformSharedPreferences` in `Context.getSharedPreferences`. + +- [ ] **Step 6: Run the tests and confirm they pass** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'PlatformSharedPreferencesTest' --tests 'ContextFilesTest' --tests 'HeartBeatTest' --tests 'FirebaseRemoteConfigTest' ktlintCheck` +Expected: all pass (2 skipped); ktlint clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/android/content/SharedPreferences.java src/main/java/android/content/PlatformSharedPreferences.kt src/main/java/android/content/Context.kt src/test/kotlin/PlatformSharedPreferencesTest.kt src/test/kotlin/HeartBeatTest.kt +git commit -m "Persist Firebase heartbeat preferences through FirebasePlatform" +``` + +--- + +### Task 8: Capture and assert expected Remote Config warnings + +Added during execution (spec: "Changes agreed during implementation", item 2). Runs after Task 7, before Task 6. + +**Files:** +- Modify: `src/test/kotlin/FirebaseTest.kt` +- Modify: `src/test/kotlin/FirebaseRemoteConfigTest.kt` + +**Interfaces:** +- Produces: `FirebaseTest.log(message: String)`, `protected open`, default `println(message)`; the test platform's `log` delegates to it. + +- [ ] **Step 1: Write the failing test** + +In `FirebaseRemoteConfigTest`, add a synchronized `logs` list, override `log` to append to it, and extend `fetchAndActivate succeeds` to assert that every captured log starts with one of: `FirebaseApp Device unlocked`, `FirebaseRemoteConfig No such package: app.teamhub.TeamHub`, `ContentValues No such package: app.teamhub.TeamHub` (Installations, fresh installs only), `FirebaseRemoteConfig Could not update ABT experiments.`; and that the Remote Config `No such package` and ABT messages each appear. + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'FirebaseRemoteConfigTest'` +Expected: test compilation fails: `log` overrides nothing. + +- [ ] **Step 3: Add the hook to `FirebaseTest`** + +Add `protected open fun log(message: String) = println(message)` and make the platform's `log(msg)` call `this@FirebaseTest.log(msg)`. + +- [ ] **Step 4: Run it and confirm it passes with clean output** + +Run: same as Step 2, plus `ktlintCheck`. +Expected: 7 pass, 2 skipped; the `` of `TEST-FirebaseRemoteConfigTest.xml` is empty. + +- [ ] **Step 5: Commit** + +```bash +git add src/test/kotlin/FirebaseTest.kt src/test/kotlin/FirebaseRemoteConfigTest.kt +git commit -m "Capture and assert expected Remote Config warnings in tests" +``` diff --git a/docs/superpowers/specs/2026-10-07-remote-config-design.md b/docs/superpowers/specs/2026-10-07-remote-config-design.md index cddb7c5..dda3866 100644 --- a/docs/superpowers/specs/2026-10-07-remote-config-design.md +++ b/docs/superpowers/specs/2026-10-07-remote-config-design.md @@ -150,3 +150,24 @@ JDK 25 version string. The full suite must pass with clean output. Publishing `test_remote_string` would let the two ignored fetch tests run. A fixture could do this with the Admin SDK, but it needs service-account credentials in the test environment, so it stays out of this work. + +## Changes agreed during implementation + +The live fetch test surfaced two issues the bytecode scan missed. Angelos +decided both on 2026-10-07: + +1. **Heartbeat storage.** `firebase-common`'s `HeartBeatInfoStorage` uses + `getStringSet`, `putStringSet`, `remove`, and `getAll` (checking values with + `instanceof Set`) on the `FirebaseHeartBeat` preference + file. The strict whitelist supports none of these, so heartbeats failed for + every product; Installations logged "Failed to get heartbeats header". These + files become platform-backed too. `PlatformSharedPreferences` gains string + sets and `remove`, and its key index records which keys hold string sets so + `getAll()` returns them as `Set`. `SharedPreferences.getAll()` takes + Android's `Map` signature. +2. **Expected warnings in test output.** Each fetch logs a + `NameNotFoundException` (from `getPackageInfo`), and each activate logs + `AbtException: The Analytics SDK is not available`. Both are expected on the + JVM. `FirebaseTest` routes platform logs through an overridable `log`, and + `FirebaseRemoteConfigTest` captures them and asserts the live fetch logs + exactly these expected messages. From 35f42fdf063b8ec1dc5b3bfa915c4c9d34a96143 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:45:08 +0300 Subject: [PATCH 10/33] Persist Firebase heartbeat preferences through FirebasePlatform --- src/main/java/android/content/Context.kt | 14 +++++- .../content/PlatformSharedPreferences.kt | 48 +++++++++++++------ .../android/content/SharedPreferences.java | 6 ++- .../android/util/Base64DataException.java | 30 ++++++++++++ src/test/kotlin/HeartBeatTest.kt | 21 ++++++++ .../kotlin/PlatformSharedPreferencesTest.kt | 38 ++++++++++++++- 6 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 src/main/java/android/util/Base64DataException.java create mode 100644 src/test/kotlin/HeartBeatTest.kt diff --git a/src/main/java/android/content/Context.kt b/src/main/java/android/content/Context.kt index e8b4c6d..4c8879d 100644 --- a/src/main/java/android/content/Context.kt +++ b/src/main/java/android/content/Context.kt @@ -46,7 +46,7 @@ open class Context { val applicationInfo: ApplicationInfo = ApplicationInfo() fun getSharedPreferences(name: String, mode: Int): SharedPreferences { - if (name.startsWith("frc_") || name == "com.google.android.gms.appid") { + if (name.startsWith("frc_") || name == "com.google.android.gms.appid" || name.startsWith("FirebaseHeartBeat")) { return PlatformSharedPreferences(name) } return object : SharedPreferences { @@ -82,6 +82,10 @@ open class Context { throw IllegalArgumentException(key) } + override fun getStringSet(key: String?, defaultValues: Set?): Set? { + throw IllegalArgumentException(key) + } + override fun getAll(): Map { return emptyMap() } @@ -112,6 +116,14 @@ open class Context { throw IllegalArgumentException(key) } + override fun putStringSet(key: String?, values: Set?): Editor { + throw IllegalArgumentException(key) + } + + override fun remove(key: String?): Editor { + throw IllegalArgumentException(key) + } + override fun clear(): Editor { throw IllegalArgumentException(name) } diff --git a/src/main/java/android/content/PlatformSharedPreferences.kt b/src/main/java/android/content/PlatformSharedPreferences.kt index ee43151..dab9978 100644 --- a/src/main/java/android/content/PlatformSharedPreferences.kt +++ b/src/main/java/android/content/PlatformSharedPreferences.kt @@ -1,12 +1,14 @@ /* * SharedPreferences persisted through FirebasePlatform, returned by Context.getSharedPreferences - * for the preference files of Remote Config and Installations. Each key is stored as - * "|", and the file's key set is stored under "|__keys" so clear() can find them. - * Puts are written immediately, so commit() and apply() have nothing left to do. + * for the preference files of Remote Config, Installations, and Firebase heartbeats. Each key is + * stored as "|", with string sets JSON-encoded. An index under "|__keys" maps each + * key to whether its value is a string set, so getAll() can restore the type and clear() can find + * every key. Puts are written immediately, so commit() and apply() have nothing left to do. */ package android.content import com.google.firebase.FirebasePlatform +import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.SetSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json @@ -24,8 +26,15 @@ internal class PlatformSharedPreferences(private val name: String) : SharedPrefe override fun getInt(key: String, defValue: Int): Int = platform.retrieve(storageKey(key))?.toInt() ?: defValue - override fun getAll(): Map = synchronized(lock) { - keys().mapNotNull { key -> platform.retrieve(storageKey(key))?.let { key to it } }.toMap() + override fun getStringSet(key: String, defValues: Set?): Set? = + platform.retrieve(storageKey(key))?.let { Json.decodeFromString(stringSetSerializer, it) } ?: defValues + + override fun getAll(): Map = synchronized(lock) { + index().mapNotNull { (key, isStringSet) -> + platform.retrieve(storageKey(key))?.let { value -> + key to if (isStringSet) Json.decodeFromString(stringSetSerializer, value) else value + } + }.toMap() } override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor { @@ -44,6 +53,16 @@ internal class PlatformSharedPreferences(private val name: String) : SharedPrefe return this } + override fun putStringSet(key: String, values: Set?): SharedPreferences.Editor { + put(key, values?.let { Json.encodeToString(stringSetSerializer, it) }, isStringSet = true) + return this + } + + override fun remove(key: String): SharedPreferences.Editor { + put(key, null) + return this + } + override fun clear(): SharedPreferences.Editor { clearAll() return this @@ -55,32 +74,33 @@ internal class PlatformSharedPreferences(private val name: String) : SharedPrefe } } - private fun put(key: String, value: String?) = synchronized(lock) { + private fun put(key: String, value: String?, isStringSet: Boolean = false) = synchronized(lock) { if (value == null) { platform.clear(storageKey(key)) - writeKeys(keys() - key) + writeIndex(index() - key) } else { platform.store(storageKey(key), value) - writeKeys(keys() + key) + writeIndex(index() + (key to isStringSet)) } } private fun clearAll() = synchronized(lock) { - keys().forEach { platform.clear(storageKey(it)) } - platform.clear(keysKey) + index().keys.forEach { platform.clear(storageKey(it)) } + platform.clear(indexKey) } - private fun keys(): Set = platform.retrieve(keysKey)?.let { Json.decodeFromString(keySetSerializer, it) } ?: emptySet() + private fun index(): Map = platform.retrieve(indexKey)?.let { Json.decodeFromString(indexSerializer, it) } ?: emptyMap() - private fun writeKeys(keys: Set) = platform.store(keysKey, Json.encodeToString(keySetSerializer, keys)) + private fun writeIndex(index: Map) = platform.store(indexKey, Json.encodeToString(indexSerializer, index)) private fun storageKey(key: String) = "$name|$key" - private val keysKey: String + private val indexKey: String get() = "$name|__keys" companion object { private val lock = Any() - private val keySetSerializer = SetSerializer(String.serializer()) + private val stringSetSerializer = SetSerializer(String.serializer()) + private val indexSerializer = MapSerializer(String.serializer(), Boolean.serializer()) } } diff --git a/src/main/java/android/content/SharedPreferences.java b/src/main/java/android/content/SharedPreferences.java index 900dbe4..596c297 100644 --- a/src/main/java/android/content/SharedPreferences.java +++ b/src/main/java/android/content/SharedPreferences.java @@ -1,14 +1,16 @@ package android.content; import java.util.Map; +import java.util.Set; public interface SharedPreferences { boolean contains(String key); String getString(String key, String defaultValue); - Map getAll(); + Map getAll(); long getLong(String key, long defValue); int getInt(String key, int defValue); + Set getStringSet(String key, Set defValues); Editor edit(); @@ -16,6 +18,8 @@ interface Editor { Editor putLong(String key, long value); Editor putString(String key, String value); Editor putInt(String key, int value); + Editor putStringSet(String key, Set values); + Editor remove(String key); Editor clear(); boolean commit(); void apply(); diff --git a/src/main/java/android/util/Base64DataException.java b/src/main/java/android/util/Base64DataException.java new file mode 100644 index 0000000..de12ee1 --- /dev/null +++ b/src/main/java/android/util/Base64DataException.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2011 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 android.util; + +import java.io.IOException; + +/** + * This exception is thrown by {@link Base64InputStream} or {@link Base64OutputStream} + * when an error is detected in the data being decoded. This allows problems with the base64 data + * to be disambiguated from errors in the underlying streams (e.g. actual connection errors.) + */ +public class Base64DataException extends IOException { + public Base64DataException(String detailMessage) { + super(detailMessage); + } +} diff --git a/src/test/kotlin/HeartBeatTest.kt b/src/test/kotlin/HeartBeatTest.kt new file mode 100644 index 0000000..9df4638 --- /dev/null +++ b/src/test/kotlin/HeartBeatTest.kt @@ -0,0 +1,21 @@ +/* + * Tests that Firebase's heartbeat reporting (firebase-common), which Installations attaches to + * its requests, can store and read heartbeats through the platform-backed preferences. + */ +import com.google.firebase.heartbeatinfo.DefaultHeartBeatController +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class HeartBeatTest : FirebaseTest() { + + @Test + fun `registered heartbeat is reported in the header`(): Unit = runTest { + val controller = app.get(DefaultHeartBeatController::class.java) + + controller.registerHeartBeat().await() + + assertTrue(controller.heartBeatsHeader.await().isNotEmpty()) + } +} diff --git a/src/test/kotlin/PlatformSharedPreferencesTest.kt b/src/test/kotlin/PlatformSharedPreferencesTest.kt index bb4f768..0d90fab 100644 --- a/src/test/kotlin/PlatformSharedPreferencesTest.kt +++ b/src/test/kotlin/PlatformSharedPreferencesTest.kt @@ -102,12 +102,48 @@ class PlatformSharedPreferencesTest { assertTrue(platform.storage.keys.none { it.startsWith("frc_app_firebase_settings|") }) } + @Test + fun `string sets round-trip`() { + val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) + prefs.edit().putStringSet("fire-core/20.4.2", setOf("2026-10-07")).commit() + + assertEquals(setOf("2026-10-07"), prefs.getStringSet("fire-core/20.4.2", null)) + assertEquals(setOf("default"), prefs.getStringSet("missing", setOf("default"))) + } + + @Test + fun `getAll returns string sets as sets`() { + val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) + prefs.edit() + .putString("last-used-date", "2026-10-07") + .putStringSet("fire-core/20.4.2", setOf("2026-10-06", "2026-10-07")) + .commit() + + assertEquals( + mapOf("last-used-date" to "2026-10-07", "fire-core/20.4.2" to setOf("2026-10-06", "2026-10-07")), + prefs.all + ) + } + + @Test + fun `remove deletes the key`() { + val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) + prefs.edit().putStringSet("fire-core/20.4.2", setOf("2026-10-07")).commit() + prefs.edit().remove("fire-core/20.4.2").commit() + + assertFalse(prefs.contains("fire-core/20.4.2")) + assertTrue(prefs.all.isEmpty()) + } + @Test fun `other preference files still reject unknown keys`() { - val prefs = context.getSharedPreferences("FirebaseHeartBeat", 0) + val prefs = context.getSharedPreferences("com.google.firebase.common.prefs:W0RFRkFVTFRd", 0) assertThrows(IllegalArgumentException::class.java) { prefs.getInt("unknown", 0) } assertThrows(IllegalArgumentException::class.java) { prefs.edit().putInt("unknown", 0) } assertThrows(IllegalArgumentException::class.java) { prefs.edit().clear() } + assertThrows(IllegalArgumentException::class.java) { prefs.getStringSet("unknown", null) } + assertThrows(IllegalArgumentException::class.java) { prefs.edit().putStringSet("unknown", null) } + assertThrows(IllegalArgumentException::class.java) { prefs.edit().remove("unknown") } } } From d7f985778432b1f6e9233b738f91a77f6419f72f Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:46:01 +0300 Subject: [PATCH 11/33] Capture and assert expected Remote Config warnings in tests --- src/test/kotlin/FirebaseRemoteConfigTest.kt | 24 +++++++++++++++++++++ src/test/kotlin/FirebaseTest.kt | 4 +++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/FirebaseRemoteConfigTest.kt b/src/test/kotlin/FirebaseRemoteConfigTest.kt index 69183f4..fad4bb3 100644 --- a/src/test/kotlin/FirebaseRemoteConfigTest.kt +++ b/src/test/kotlin/FirebaseRemoteConfigTest.kt @@ -17,11 +17,20 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Ignore import org.junit.Test +import java.util.Collections class FirebaseRemoteConfigTest : FirebaseTest() { + // Firebase logs from background threads, so the list is synchronized + private val logs: MutableList = Collections.synchronizedList(mutableListOf()) + + override fun log(message: String) { + logs.add(message) + } + private val defaults = mapOf( "test_default_boolean" to true, "test_default_double" to 42.0, @@ -119,6 +128,21 @@ class FirebaseRemoteConfigTest : FirebaseTest() { remoteConfig.fetchAndActivate().await() assertEquals(FirebaseRemoteConfig.LAST_FETCH_STATUS_SUCCESS, remoteConfig.info.lastFetchStatus) + + // On the JVM there is no Android package to fingerprint and no Analytics SDK for A/B testing + val packageNotFound = "FirebaseRemoteConfig No such package: app.teamhub.TeamHub" + val analyticsMissing = "FirebaseRemoteConfig Could not update ABT experiments." + val expectedLogs = listOf( + "FirebaseApp Device unlocked", + packageNotFound, + analyticsMissing, + // Logged by Installations only when it registers a new installation + "ContentValues No such package: app.teamhub.TeamHub" + ) + val capturedLogs = logs.toList() + assertEquals(emptyList(), capturedLogs.filterNot { log -> expectedLogs.any { log.startsWith(it) } }) + assertTrue(capturedLogs.any { it.startsWith(packageNotFound) }) + assertTrue(capturedLogs.any { it.startsWith(analyticsMissing) }) } // Unfortunately Firebase Remote Config is not implemented by Firebase emulator so it may be diff --git a/src/test/kotlin/FirebaseTest.kt b/src/test/kotlin/FirebaseTest.kt index d5b9dd7..bc085b4 100644 --- a/src/test/kotlin/FirebaseTest.kt +++ b/src/test/kotlin/FirebaseTest.kt @@ -25,6 +25,8 @@ abstract class FirebaseTest { Firebase.initialize(Application(), options) } + protected open fun log(message: String) = println(message) + @Before fun beforeEach() { FirebasePlatform.initializeFirebasePlatform( @@ -42,7 +44,7 @@ abstract class FirebaseTest { storage.remove(key) } - override fun log(msg: String) = println(msg) + override fun log(msg: String) = this@FirebaseTest.log(msg) override fun getDatabasePath(name: String) = File("./build/$name") From 30153e641d0df9c28a5d3be75498434177cebba9 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:47:28 +0300 Subject: [PATCH 12/33] Document Remote Config and Installations support --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 470a5b8..53cb2ac 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,16 @@ The `FirebasePlatform` interface also includes a `getDatabasePath` method for yo This is used by Firestore to support [offline data persistence](https://firebase.google.com/docs/firestore/manage-data/enable-offline). +#### Customizing file storage location + +The `FirebasePlatform` interface also includes a `getFilesDir` method for you to override if the following default implementation is not suitable: + +```kotlin + open fun getFilesDir(): File = File("${System.getProperty("java.io.tmpdir")}${File.separatorChar}firebase-files") +``` + +This is used by Remote Config and Installations to persist fetched configs and the installation ID. + #### Initialize the Firebase application It is also up to you to initialize the Firebase application object manually (unlike the Android SDK which is normally initialized via @@ -111,11 +121,11 @@ The following libraries are available for the various Firebase products. | [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] | +| [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` | [^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 +[^2]: Realtime updates (`addOnConfigUpdateListener`) and defaults from XML resources are not supported. Is the Firebase library or API you need missing? [Create an issue](https://github.com/GitLiveApp/firebase-java-sdk/issues/new?labels=API+coverage&template=increase-api-coverage.md&title=Add+%5Bclass+name%5D.%5Bfunction+name%5D+to+%5Blibrary+name) to request additional API coverage or be awesome and [submit a PR](https://github.com/GitLiveApp/firebase-java-sdk/fork). From f45bf820a455439a5681c45c24fff1e460c8dec9 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:59:27 +0300 Subject: [PATCH 13/33] Check Remote Config test logs after every test --- src/test/kotlin/FirebaseRemoteConfigTest.kt | 26 ++++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/test/kotlin/FirebaseRemoteConfigTest.kt b/src/test/kotlin/FirebaseRemoteConfigTest.kt index fad4bb3..7b52367 100644 --- a/src/test/kotlin/FirebaseRemoteConfigTest.kt +++ b/src/test/kotlin/FirebaseRemoteConfigTest.kt @@ -31,6 +31,19 @@ class FirebaseRemoteConfigTest : FirebaseTest() { logs.add(message) } + // On the JVM there is no Android package to fingerprint and no Analytics SDK for A/B testing + private val packageNotFound = "FirebaseRemoteConfig No such package: app.teamhub.TeamHub" + private val analyticsMissing = "FirebaseRemoteConfig Could not update ABT experiments." + + // Every test fails in tearDown if it logs anything else + private val expectedLogs = listOf( + "FirebaseApp Device unlocked", + packageNotFound, + analyticsMissing, + // Logged by Installations only when it registers a new installation + "ContentValues No such package: app.teamhub.TeamHub" + ) + private val defaults = mapOf( "test_default_boolean" to true, "test_default_double" to 42.0, @@ -43,6 +56,8 @@ class FirebaseRemoteConfigTest : FirebaseTest() { @After fun tearDown(): Unit = runTest { remoteConfig.reset().await() + + assertEquals(emptyList(), logs.toList().filterNot { log -> expectedLogs.any { log.startsWith(it) } }) } @Test @@ -129,18 +144,7 @@ class FirebaseRemoteConfigTest : FirebaseTest() { assertEquals(FirebaseRemoteConfig.LAST_FETCH_STATUS_SUCCESS, remoteConfig.info.lastFetchStatus) - // On the JVM there is no Android package to fingerprint and no Analytics SDK for A/B testing - val packageNotFound = "FirebaseRemoteConfig No such package: app.teamhub.TeamHub" - val analyticsMissing = "FirebaseRemoteConfig Could not update ABT experiments." - val expectedLogs = listOf( - "FirebaseApp Device unlocked", - packageNotFound, - analyticsMissing, - // Logged by Installations only when it registers a new installation - "ContentValues No such package: app.teamhub.TeamHub" - ) val capturedLogs = logs.toList() - assertEquals(emptyList(), capturedLogs.filterNot { log -> expectedLogs.any { log.startsWith(it) } }) assertTrue(capturedLogs.any { it.startsWith(packageNotFound) }) assertTrue(capturedLogs.any { it.startsWith(analyticsMissing) }) } From 86448348cc5f754dcc9494cd9c6c6275d55ec80c Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:59:59 +0300 Subject: [PATCH 14/33] Document FirebasePlatform thread-safety and key format requirements --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 53cb2ac..4b87e37 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,16 @@ Before you can use the SDK you need to call the `FirebasePlatform.initializeFire logging, and persistent storage for simple key value pairs. This is used by the various Firebase products, for example, to persist the signed-in user in Firebase Auth. +The Firebase products call these methods from background threads, so your implementation must be thread-safe. The keys are +arbitrary strings that can be several hundred characters long and contain characters such as spaces, `/`, `|` and `:`, so a +storage backend with restrictions on keys (for example a file per key, or `java.util.prefs.Preferences`) needs to map them first, +for example by hashing them. + Here's a simple example implementation in Kotlin that only persists in-memory: ```kotlin FirebasePlatform.initializeFirebasePlatform(object : FirebasePlatform() { - val storage = mutableMapOf() + val storage = ConcurrentHashMap() override fun store(key: String, value: String) = storage.set(key, value) override fun retrieve(key: String) = storage[key] override fun clear(key: String) { storage.remove(key) } From ddd7d93931928f8db51cbe3f2536c93f6859b73a Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 21:02:35 +0300 Subject: [PATCH 15/33] Remove Remote Config design notes from the branch --- .../plans/2026-10-07-remote-config.md | 1175 ----------------- .../specs/2026-10-07-remote-config-design.md | 173 --- 2 files changed, 1348 deletions(-) delete mode 100644 docs/superpowers/plans/2026-10-07-remote-config.md delete mode 100644 docs/superpowers/specs/2026-10-07-remote-config-design.md diff --git a/docs/superpowers/plans/2026-10-07-remote-config.md b/docs/superpowers/plans/2026-10-07-remote-config.md deleted file mode 100644 index 56f40bb..0000000 --- a/docs/superpowers/plans/2026-10-07-remote-config.md +++ /dev/null @@ -1,1175 +0,0 @@ -# Remote Config Support Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the bundled Android Remote Config 21.6.0 and Installations 17.2.0 libraries work on the JVM by filling the Android shims they call. - -**Architecture:** The SDK runs unmodified Android Firebase code against hand-written `android.*` shims in `src/main/java`. This plan adds file APIs to `Context`, platform-backed `SharedPreferences` for two preference files, ports `JsonReader` from AOSP, adds three small shims, and registers the Remote Config and ABT component registrars. No Remote Config logic is written here. - -**Tech Stack:** Kotlin 2.0.20 / Java 17, Gradle 8.13, JUnit 4, kotlinx-coroutines-test (`runTest`), kotlinx-coroutines-play-services (`await`), kotlinx-serialization-json (already an `implementation` dependency). - -**Spec:** `docs/superpowers/specs/2026-10-07-remote-config-design.md` - -## Global Constraints - -- Run every Gradle command with `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr`. The default JDK 25 fails at configuration with only `> 25.0.4.1`. -- Run the full suite inside the Auth emulator, as CI does: `firebase emulators:exec --project my-firebase-project --import=src/test/resources/firebase_data ''`. Without it, the 6 `FirebaseAuthTest` tests fail with `ConnectException ... localhost:9099`. Baseline on `master`: 12/12 pass and `ktlintCheck` is clean. -- Single test classes may run without the emulator: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests ''`. Read failures with `grep -A8 '.xml`. -- `ktlintCheck` must pass (ktlint 0.47.1). Do not write trailing commas. If it fails on formatting only, run `./gradlew ktlintFormat` and do not hand-edit whitespace. -- Every new hand-written file starts with a brief header comment stating its purpose and how it fits the project. Verbatim AOSP ports keep the AOSP license header and class javadoc unchanged; those serve as the header. -- Match the style of the file you edit. Add no dependencies. -- Commit messages carry no AI attribution (no `Co-Authored-By`, no session links). -- If a test fails for a reason this plan does not predict, stop. Find the root cause, report it, and wait for Angelos before changing course. Do not add workarounds. -- Out of scope: realtime updates (`addOnConfigUpdateListener`), `setDefaultsAsync(@XmlRes int)`, real `PackageManager.getPackageInfo` data. - -## Review Focus - -Inputs the spec implies but its listed tests do not exercise, most likely first. Each has a test in the owning task. - -1. **App IDs contain `:`**, and Remote Config names its files `frc__firebase_{fetch,activate,defaults}.json`. Windows forbids `:` in file names, so these files must still read and write there. Task 1, `file names with colons are stored without colons`. -2. **Concurrent preference writes.** Remote Config writes metadata from background executors. A lost update to the key index would make `reset()` leave stale metadata behind. Task 2, `concurrent writes are all cleared`. -3. **App restart.** Values written through one preferences instance must be readable from a fresh instance, as after a process restart. Task 2, `values persist across instances`. -4. **First run on a clean machine.** `getFilesDir()` may not exist yet, and a missing config file must raise `FileNotFoundException`, which Remote Config reads as "no config yet". Task 1, `filesDir is created when missing` and `opening a missing file throws FileNotFoundException`. -5. **Other products keep failing loudly.** Preference files other than the two platform-backed ones must still throw on unknown keys, including the new `getInt`/`putInt`/`clear`. Task 2, `other preference files still reject unknown keys`. - ---- - -### Task 1: File storage on `Context` - -**Files:** -- Modify: `src/main/java/com/google/firebase/FirebasePlatform.kt` -- Modify: `src/main/java/android/content/Context.kt` -- Modify: `src/test/kotlin/fakes/FakeFirebasePlatform.kt` -- Test: `src/test/kotlin/ContextFilesTest.kt` (create) - -**Interfaces:** -- Produces: `FirebasePlatform.getFilesDir(): File` (open, default `/firebase-files`); `Context.filesDir: File` (Java `getFilesDir()`), `Context.openFileInput(name: String): FileInputStream`, `Context.openFileOutput(name: String, mode: Int): FileOutputStream`, `Context.deleteFile(name: String): Boolean`, `Context.MODE_APPEND = 0x8000`; `FakeFirebasePlatform(storage, databaseFolderPath, filesFolderPath)`. - -- [ ] **Step 1: Give `FakeFirebasePlatform` a files folder** - -In `src/test/kotlin/fakes/FakeFirebasePlatform.kt`, add a constructor parameter and override: - -```kotlin -class FakeFirebasePlatform( - val storage: MutableMap = mutableMapOf(), - databaseFolderPath: String = "./build/database/", - filesFolderPath: String = "./build/files/" -) : FirebasePlatform() { - - private val databaseFolder = File(databaseFolderPath) - - private val filesFolder = File(filesFolderPath) -``` - -and after `getDatabasePath`: - -```kotlin - override fun getFilesDir() = filesFolder -``` - -- [ ] **Step 2: Write the failing tests** - -Create `src/test/kotlin/ContextFilesTest.kt`: - -```kotlin -/* - * Tests for the file APIs on the android.content.Context shim, which Remote Config and - * Installations use to persist configs and installation data in FirebasePlatform.getFilesDir(). - */ -import android.app.Application -import android.content.Context -import com.google.firebase.FirebasePlatform -import fakes.FakeFirebasePlatform -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.File -import java.io.FileNotFoundException - -class ContextFilesTest { - - @get:Rule - val folder = TemporaryFolder() - - private lateinit var filesDir: File - - private val context = Application() - - @Before - fun setUp() { - filesDir = File(folder.root, "files") - FirebasePlatform.initializeFirebasePlatform(FakeFirebasePlatform(filesFolderPath = filesDir.path)) - } - - @Test - fun `filesDir is created when missing`() { - assertFalse(filesDir.exists()) - assertEquals(filesDir, context.filesDir) - assertTrue(filesDir.isDirectory) - } - - @Test - fun `written file can be read back`() { - context.openFileOutput("config.json", 0).use { it.write("hello".toByteArray()) } - assertEquals("hello", context.openFileInput("config.json").use { String(it.readBytes()) }) - } - - @Test - fun `opening a missing file throws FileNotFoundException`() { - assertThrows(FileNotFoundException::class.java) { context.openFileInput("missing.json") } - } - - @Test - fun `deleteFile removes the file`() { - context.openFileOutput("config.json", 0).use { it.write("hello".toByteArray()) } - assertTrue(context.deleteFile("config.json")) - assertThrows(FileNotFoundException::class.java) { context.openFileInput("config.json") } - assertFalse(context.deleteFile("config.json")) - } - - @Test - fun `append mode appends`() { - context.openFileOutput("log.txt", 0).use { it.write("a".toByteArray()) } - context.openFileOutput("log.txt", Context.MODE_APPEND).use { it.write("b".toByteArray()) } - assertEquals("ab", context.openFileInput("log.txt").use { String(it.readBytes()) }) - } - - @Test - fun `file names with colons are stored without colons`() { - val name = "frc_1:341458593155:web:bf8e1aa37efe01f32d42b6_firebase_fetch.json" - context.openFileOutput(name, 0).use { it.write("{}".toByteArray()) } - assertEquals("{}", context.openFileInput(name).use { String(it.readBytes()) }) - assertTrue(filesDir.list()!!.none { ':' in it }) - } - - @Test - fun `file names with path separators are rejected`() { - assertThrows(IllegalArgumentException::class.java) { context.openFileOutput("../escape.json", 0) } - } -} -``` - -- [ ] **Step 3: Run the tests and confirm they fail** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'ContextFilesTest'` -Expected: test compilation fails: `getFilesDir` overrides nothing in `FakeFirebasePlatform`, and `filesDir`, `openFileOutput`, `openFileInput`, `deleteFile`, `MODE_APPEND` are unresolved. - -- [ ] **Step 4: Add `getFilesDir` to `FirebasePlatform`** - -In `src/main/java/com/google/firebase/FirebasePlatform.kt`, after `getDatabasePath`: - -```kotlin - open fun getFilesDir(): File = File("${System.getProperty("java.io.tmpdir")}${File.separatorChar}firebase-files") -``` - -- [ ] **Step 5: Add the file APIs to `Context`** - -In `src/main/java/android/content/Context.kt`, add imports `java.io.FileInputStream`, `java.io.FileOutputStream`, `java.net.URLEncoder`. After `getDatabasePath`, add: - -```kotlin - val filesDir: File - get() = FirebasePlatform.firebasePlatform.getFilesDir().apply { mkdirs() } - - fun openFileInput(name: String): FileInputStream = FileInputStream(fileStreamPath(name)) - - fun openFileOutput(name: String, mode: Int): FileOutputStream = - FileOutputStream(fileStreamPath(name), mode and MODE_APPEND != 0) - - fun deleteFile(name: String): Boolean = fileStreamPath(name).delete() - - // URL-encodes the name so characters such as ':' in Firebase app IDs are valid on every OS - private fun fileStreamPath(name: String): File { - require('/' !in name && File.separatorChar !in name) { "File $name contains a path separator" } - return File(filesDir, URLEncoder.encode(name, Charsets.UTF_8)) - } -``` - -In the companion object, add: - -```kotlin - const val MODE_APPEND = 0x8000 -``` - -- [ ] **Step 6: Run the tests and confirm they pass** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'ContextFilesTest' ktlintCheck` -Expected: 7 tests pass; ktlint clean. - -- [ ] **Step 7: Commit** - -```bash -git add src/main/java/com/google/firebase/FirebasePlatform.kt src/main/java/android/content/Context.kt src/test/kotlin/fakes/FakeFirebasePlatform.kt src/test/kotlin/ContextFilesTest.kt -git commit -m "Add file storage APIs to Context backed by FirebasePlatform.getFilesDir" -``` - ---- - -### Task 2: Platform-backed preferences for Remote Config and Installations - -**Files:** -- Modify: `src/main/java/android/content/SharedPreferences.java` -- Create: `src/main/java/android/content/PlatformSharedPreferences.kt` -- Modify: `src/main/java/android/content/Context.kt` (`getSharedPreferences`) -- Test: `src/test/kotlin/PlatformSharedPreferencesTest.kt` (create) - -**Interfaces:** -- Consumes: `FakeFirebasePlatform(storage, ...)` from Task 1. -- Produces: `SharedPreferences.getInt(String, int)`, `Editor.putInt(String, int)`, `Editor.clear()`; `internal class PlatformSharedPreferences(name: String) : SharedPreferences`. `Context.getSharedPreferences` returns it for names starting with `frc_` and for `com.google.android.gms.appid`. Storage keys: `"|"`; key index: `"|__keys"` (JSON array). - -- [ ] **Step 1: Write the failing tests** - -Create `src/test/kotlin/PlatformSharedPreferencesTest.kt`: - -```kotlin -/* - * Tests for the SharedPreferences that Context returns for Remote Config ("frc_*") and - * Installations ("com.google.android.gms.appid"), persisted through FirebasePlatform. - */ -import android.app.Application -import com.google.firebase.FirebasePlatform -import fakes.FakeFirebasePlatform -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import kotlin.concurrent.thread - -class PlatformSharedPreferencesTest { - - private val platform = FakeFirebasePlatform() - - private val context = Application() - - @Before - fun setUp() { - FirebasePlatform.initializeFirebasePlatform(platform) - } - - @Test - fun `values round-trip by type`() { - val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) - prefs.edit() - .putString("last_fetch_etag", "abc") - .putLong("last_fetch_time_in_millis", 42L) - .putInt("last_fetch_status", -1) - .commit() - - assertEquals("abc", prefs.getString("last_fetch_etag", null)) - assertEquals(42L, prefs.getLong("last_fetch_time_in_millis", 0L)) - assertEquals(-1, prefs.getInt("last_fetch_status", 0)) - assertEquals(7, prefs.getInt("missing", 7)) - assertTrue(prefs.contains("last_fetch_etag")) - assertFalse(prefs.contains("missing")) - } - - @Test - fun `putString null removes the key`() { - val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) - prefs.edit().putString("last_fetch_etag", "abc").commit() - prefs.edit().putString("last_fetch_etag", null).commit() - - assertFalse(prefs.contains("last_fetch_etag")) - assertTrue(prefs.all.isEmpty()) - } - - @Test - fun `files do not see each other's keys`() { - val first = context.getSharedPreferences("frc_first_firebase_settings", 0) - val second = context.getSharedPreferences("frc_second_firebase_settings", 0) - first.edit().putString("key", "first").commit() - - assertNull(second.getString("key", null)) - } - - @Test - fun `clear removes only this file's keys`() { - val first = context.getSharedPreferences("frc_first_firebase_settings", 0) - val second = context.getSharedPreferences("frc_second_firebase_settings", 0) - first.edit().putString("key", "first").commit() - second.edit().putString("key", "second").commit() - - first.edit().clear().commit() - - assertNull(first.getString("key", null)) - assertTrue(first.all.isEmpty()) - assertEquals("second", second.getString("key", null)) - assertTrue(platform.storage.keys.none { it.startsWith("frc_first_firebase_settings|") }) - } - - @Test - fun `values persist across instances`() { - context.getSharedPreferences("frc_app_firebase_settings", 0).edit().putLong("num_failed_fetches", 3L).commit() - - assertEquals(3L, Application().getSharedPreferences("frc_app_firebase_settings", 0).getLong("num_failed_fetches", 0L)) - } - - @Test - fun `installations preferences are platform-backed`() { - val prefs = context.getSharedPreferences("com.google.android.gms.appid", 0) - - assertNull(prefs.getString("|S|id", null)) - } - - @Test - fun `concurrent writes are all cleared`() { - val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) - (0 until 8).map { i -> - thread { repeat(50) { j -> prefs.edit().putInt("key-$i-$j", j).apply() } } - }.forEach { it.join() } - - assertEquals(400, prefs.all.size) - prefs.edit().clear().commit() - assertTrue(platform.storage.keys.none { it.startsWith("frc_app_firebase_settings|") }) - } - - @Test - fun `other preference files still reject unknown keys`() { - val prefs = context.getSharedPreferences("FirebaseHeartBeat", 0) - - assertThrows(IllegalArgumentException::class.java) { prefs.getInt("unknown", 0) } - assertThrows(IllegalArgumentException::class.java) { prefs.edit().putInt("unknown", 0) } - assertThrows(IllegalArgumentException::class.java) { prefs.edit().clear() } - } -} -``` - -- [ ] **Step 2: Run the tests and confirm they fail** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'PlatformSharedPreferencesTest'` -Expected: test compilation fails with unresolved `putInt`, `getInt`, and `clear`. - -- [ ] **Step 3: Add the interface methods** - -In `src/main/java/android/content/SharedPreferences.java`, after `long getLong(String key, long defValue);` add: - -```java - int getInt(String key, int defValue); -``` - -and in `Editor`, after `Editor putString(String key, String value);` add: - -```java - Editor putInt(String key, int value); - Editor clear(); -``` - -- [ ] **Step 4: Implement the new methods in the strict preferences** - -In `src/main/java/android/content/Context.kt`, inside the anonymous `SharedPreferences` in `getSharedPreferences`, after `getLong`: - -```kotlin - override fun getInt(key: String?, defValue: Int): Int { - throw IllegalArgumentException(key) - } -``` - -and inside the anonymous `Editor`, after `putString`: - -```kotlin - override fun putInt(key: String?, value: Int): Editor { - throw IllegalArgumentException(key) - } - - override fun clear(): Editor { - throw IllegalArgumentException(name) - } -``` - -- [ ] **Step 5: Create `PlatformSharedPreferences`** - -Create `src/main/java/android/content/PlatformSharedPreferences.kt`: - -```kotlin -/* - * SharedPreferences persisted through FirebasePlatform, returned by Context.getSharedPreferences - * for the preference files of Remote Config and Installations. Each key is stored as - * "|", and the file's key set is stored under "|__keys" so clear() can find them. - * Puts are written immediately, so commit() and apply() have nothing left to do. - */ -package android.content - -import com.google.firebase.FirebasePlatform -import kotlinx.serialization.builtins.SetSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json - -internal class PlatformSharedPreferences(private val name: String) : SharedPreferences { - - private val platform: FirebasePlatform - get() = FirebasePlatform.firebasePlatform - - override fun contains(key: String): Boolean = platform.retrieve(storageKey(key)) != null - - override fun getString(key: String, defaultValue: String?): String? = platform.retrieve(storageKey(key)) ?: defaultValue - - override fun getLong(key: String, defValue: Long): Long = platform.retrieve(storageKey(key))?.toLong() ?: defValue - - override fun getInt(key: String, defValue: Int): Int = platform.retrieve(storageKey(key))?.toInt() ?: defValue - - override fun getAll(): Map = synchronized(lock) { - keys().mapNotNull { key -> platform.retrieve(storageKey(key))?.let { key to it } }.toMap() - } - - override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor { - override fun putString(key: String, value: String?): SharedPreferences.Editor { - put(key, value) - return this - } - - override fun putLong(key: String, value: Long): SharedPreferences.Editor { - put(key, value.toString()) - return this - } - - override fun putInt(key: String, value: Int): SharedPreferences.Editor { - put(key, value.toString()) - return this - } - - override fun clear(): SharedPreferences.Editor { - clearAll() - return this - } - - override fun commit(): Boolean = true - - override fun apply() { - } - } - - private fun put(key: String, value: String?) = synchronized(lock) { - if (value == null) { - platform.clear(storageKey(key)) - writeKeys(keys() - key) - } else { - platform.store(storageKey(key), value) - writeKeys(keys() + key) - } - } - - private fun clearAll() = synchronized(lock) { - keys().forEach { platform.clear(storageKey(it)) } - platform.clear(keysKey) - } - - private fun keys(): Set = platform.retrieve(keysKey)?.let { Json.decodeFromString(keySetSerializer, it) } ?: emptySet() - - private fun writeKeys(keys: Set) = platform.store(keysKey, Json.encodeToString(keySetSerializer, keys)) - - private fun storageKey(key: String) = "$name|$key" - - private val keysKey: String - get() = "$name|__keys" - - companion object { - private val lock = Any() - private val keySetSerializer = SetSerializer(String.serializer()) - } -} -``` - -- [ ] **Step 6: Route the two preference files to it** - -In `src/main/java/android/content/Context.kt`, make the first lines of `getSharedPreferences`: - -```kotlin - fun getSharedPreferences(name: String, mode: Int): SharedPreferences { - if (name.startsWith("frc_") || name == "com.google.android.gms.appid") { - return PlatformSharedPreferences(name) - } - return object : SharedPreferences { -``` - -- [ ] **Step 7: Run the tests and confirm they pass** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'PlatformSharedPreferencesTest' --tests 'ContextFilesTest' ktlintCheck` -Expected: 8 + 7 tests pass; ktlint clean. - -- [ ] **Step 8: Commit** - -```bash -git add src/main/java/android/content/SharedPreferences.java src/main/java/android/content/PlatformSharedPreferences.kt src/main/java/android/content/Context.kt src/test/kotlin/PlatformSharedPreferencesTest.kt -git commit -m "Persist Remote Config and Installations preferences through FirebasePlatform" -``` - ---- - -### Task 3: Port `android.util.JsonReader` from AOSP - -Installations parses its REST responses with `android.util.JsonReader`, which has no shim. - -**Files:** -- Create (verbatim AOSP ports): `src/main/java/android/util/JsonReader.java`, `src/main/java/android/util/JsonToken.java`, `src/main/java/android/util/JsonScope.java`, `src/main/java/android/util/MalformedJsonException.java`, `src/main/java/com/android/internal/util/StringPool.java` -- Test: `src/test/kotlin/JsonReaderTest.kt` (create) - -**Interfaces:** -- Produces: `android.util.JsonReader(Reader)` with the Android API (`beginObject`, `endObject`, `hasNext`, `nextName`, `nextString`, `skipValue`, `close`). - -- [ ] **Step 1: Write the failing test** - -Create `src/test/kotlin/JsonReaderTest.kt`: - -```kotlin -/* - * Tests the android.util.JsonReader port on the response shape Firebase Installations parses. - */ -import android.util.JsonReader -import org.junit.Assert.assertEquals -import org.junit.Test -import java.io.StringReader - -class JsonReaderTest { - - @Test - fun `parses an installations response`() { - val json = """{"name":"projects/1/installations/fid-123","fid":"fid-123","refreshToken":"refresh","authToken":{"token":"auth","expiresIn":"604800s"},"unknown":[1,{"a":true}]}""" - val values = mutableMapOf() - - JsonReader(StringReader(json)).use { reader -> - reader.beginObject() - while (reader.hasNext()) { - when (val name = reader.nextName()) { - "fid", "refreshToken" -> values[name] = reader.nextString() - "authToken" -> { - reader.beginObject() - while (reader.hasNext()) { - values["authToken." + reader.nextName()] = reader.nextString() - } - reader.endObject() - } - else -> reader.skipValue() - } - } - reader.endObject() - } - - assertEquals( - mapOf( - "fid" to "fid-123", - "refreshToken" to "refresh", - "authToken.token" to "auth", - "authToken.expiresIn" to "604800s" - ), - values - ) - } -} -``` - -- [ ] **Step 2: Run the test and confirm it fails** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'JsonReaderTest'` -Expected: test compilation fails with `Unresolved reference: JsonReader`. Robolectric's `android-all` is `compileOnly` for main and is not on the test classpath. - -- [ ] **Step 3: Copy the AOSP sources verbatim** - -```bash -for f in JsonReader JsonToken JsonScope MalformedJsonException; do - gh api "repos/aosp-mirror/platform_frameworks_base/contents/core/java/android/util/$f.java?ref=android14-release" \ - -H 'Accept: application/vnd.github.raw' > src/main/java/android/util/$f.java -done -gh api "repos/aosp-mirror/platform_frameworks_base/contents/core/java/com/android/internal/util/StringPool.java?ref=android14-release" \ - -H 'Accept: application/vnd.github.raw' > src/main/java/com/android/internal/util/StringPool.java -wc -l src/main/java/android/util/Json*.java src/main/java/android/util/MalformedJsonException.java src/main/java/com/android/internal/util/StringPool.java -``` - -Expected line counts: JsonReader 1173, JsonToken 82, JsonScope 68, MalformedJsonException 31, StringPool 77. Each file must start with the AOSP Apache 2.0 header. Do not edit the files. - -- [ ] **Step 4: Run the test and confirm it passes** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'JsonReaderTest' ktlintCheck` -Expected: PASS; ktlint clean (it does not check Java). - -- [ ] **Step 5: Commit** - -```bash -git add src/main/java/android/util/JsonReader.java src/main/java/android/util/JsonToken.java src/main/java/android/util/JsonScope.java src/main/java/android/util/MalformedJsonException.java src/main/java/com/android/internal/util/StringPool.java src/test/kotlin/JsonReaderTest.kt -git commit -m "Port android.util.JsonReader from AOSP for Firebase Installations" -``` - ---- - -### Task 4: Register Remote Config and port the offline tests - -**Files:** -- Modify: `src/main/java/android/content/pm/PackageManager.java` (`getServiceInfo`) -- Modify: `src/test/kotlin/FirebaseTest.kt` (platform `getFilesDir`) -- Test: `src/test/kotlin/FirebaseRemoteConfigTest.kt` (create) - -**Interfaces:** -- Consumes: everything from Tasks 1–3. -- Produces: `FirebaseRemoteConfigTest` with fields `defaults: Map` and `remoteConfig: FirebaseRemoteConfig`, which Task 5 extends. - -The tests port firebase-kotlin-sdk `firebase-config/src/commonTest/kotlin/dev/gitlive/firebase/remoteconfig/FirebaseRemoteConfig.kt` one-to-one onto the Android API: same names, keys, values, and assertions. - -- [ ] **Step 1: Point test files at `build/`** - -In `src/test/kotlin/FirebaseTest.kt`, inside the `FirebasePlatform` object after `getDatabasePath`: - -```kotlin - override fun getFilesDir() = File("./build/files") -``` - -- [ ] **Step 2: Write the failing tests** - -Create `src/test/kotlin/FirebaseRemoteConfigTest.kt`: - -```kotlin -/* - * Remote Config tests ported one-to-one from firebase-kotlin-sdk's common tests - * (firebase-config/src/commonTest/.../FirebaseRemoteConfig.kt) onto the Android API, - * so the Kotlin SDK's JVM target behaves the same when it runs on this SDK. - */ -import android.app.Application -import com.google.firebase.Firebase -import com.google.firebase.FirebaseOptions -import com.google.firebase.initialize -import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings -import com.google.firebase.remoteconfig.FirebaseRemoteConfigValue -import com.google.firebase.remoteconfig.get -import com.google.firebase.remoteconfig.remoteConfig -import kotlinx.coroutines.tasks.await -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Ignore -import org.junit.Test - -class FirebaseRemoteConfigTest : FirebaseTest() { - - private val defaults = mapOf( - "test_default_boolean" to true, - "test_default_double" to 42.0, - "test_default_long" to 42L, - "test_default_string" to "Hello World" - ) - - private val remoteConfig: FirebaseRemoteConfig by lazy { Firebase.remoteConfig(app) } - - @After - fun tearDown(): Unit = runTest { - remoteConfig.reset().await() - } - - @Test - fun testGettingValues(): Unit = runTest { - remoteConfig.setDefaultsAsync(defaults).await() - - assertEquals(true, remoteConfig.getBoolean("test_default_boolean")) - assertEquals(42.0, remoteConfig.getDouble("test_default_double"), 0.0) - assertEquals(42L, remoteConfig.getLong("test_default_long")) - assertEquals("Hello World", remoteConfig.getString("test_default_string")) - assertEquals("Hello World", remoteConfig.getString("test_default_string")) - - val value: FirebaseRemoteConfigValue = remoteConfig["test_default_string"] - assertEquals("Hello World", value.asString()) - assertEquals(FirebaseRemoteConfig.VALUE_SOURCE_DEFAULT, value.source) - assertEquals("Hello World", value.asByteArray().decodeToString()) - } - - @Test - fun testNamedApp(): Unit = runTest { - // Android keys Remote Config's local storage by app ID, so the named app needs its own - val options = FirebaseOptions.Builder(app.options).setApplicationId("1:341458593155:web:bf8e1aa37efe01f32d42b7").build() - val namedApp = Firebase.initialize(Application(), options, "named") - val namedRemoteConfig = Firebase.remoteConfig(namedApp) - namedRemoteConfig.setDefaultsAsync(mapOf("named_app_only" to "named")).await() - - assertEquals("named", namedRemoteConfig.getValue("named_app_only").asString()) - assertFalse(remoteConfig.all.containsKey("named_app_only")) - namedRemoteConfig.reset().await() - } - - @Test - fun testGetAll(): Unit = runTest { - remoteConfig.setDefaultsAsync(defaults).await() - val all = remoteConfig.all - assertEquals(true, all["test_default_boolean"]?.asBoolean()) - assertEquals(42.0, all["test_default_double"]?.asDouble()) - assertEquals(42L, all["test_default_long"]?.asLong()) - assertEquals("Hello World", all["test_default_string"]?.asString()) - assertEquals("Hello World", all["test_default_string"]?.asByteArray()?.decodeToString()) - } - - @Test - fun testGetKeysByPrefix(): Unit = runTest { - remoteConfig.setDefaultsAsync(defaults).await() - val keys = remoteConfig.getKeysByPrefix("test_default") - assertEquals( - setOf( - "test_default_boolean", - "test_default_double", - "test_default_long", - "test_default_string" - ), - keys - ) - } - - @Test - fun testGetInfo(): Unit = runTest { - val info = remoteConfig.info - val defaultSettings = FirebaseRemoteConfigSettings.Builder().build() - assertEquals(defaultSettings.fetchTimeoutInSeconds, info.configSettings.fetchTimeoutInSeconds) - assertEquals(defaultSettings.minimumFetchIntervalInSeconds, info.configSettings.minimumFetchIntervalInSeconds) - assertEquals(-1L, info.fetchTimeMillis) - assertEquals(FirebaseRemoteConfig.LAST_FETCH_STATUS_NO_FETCH_YET, info.lastFetchStatus) - } - - @Test - fun testSetConfigSettings(): Unit = runTest { - remoteConfig.setConfigSettingsAsync( - FirebaseRemoteConfigSettings.Builder() - .setFetchTimeoutInSeconds(42) - .setMinimumFetchIntervalInSeconds(42) - .build() - ).await() - val info = remoteConfig.info - assertEquals(42L, info.configSettings.fetchTimeoutInSeconds) - assertEquals(42L, info.configSettings.minimumFetchIntervalInSeconds) - } - - // Unfortunately Firebase Remote Config is not implemented by Firebase emulator so it may be - // tested against a real project only. Add "test_remote_string": "Hello from remote!" config - // value in Firebase console for enabling this test case. - @Test - @Ignore - fun testFetch(): Unit = runTest { - remoteConfig.setConfigSettingsAsync( - FirebaseRemoteConfigSettings.Builder().setMinimumFetchIntervalInSeconds(60).build() - ).await() - - remoteConfig.fetch().await() - remoteConfig.activate().await() - - val value: FirebaseRemoteConfigValue = remoteConfig["test_remote_string"] - assertEquals("Hello from remote!", value.asString()) - assertEquals(FirebaseRemoteConfig.VALUE_SOURCE_REMOTE, value.source) - } - - @Test - @Ignore - fun testFetchAndActivate(): Unit = runTest { - remoteConfig.setConfigSettingsAsync( - FirebaseRemoteConfigSettings.Builder().setMinimumFetchIntervalInSeconds(60).build() - ).await() - - remoteConfig.fetchAndActivate().await() - - val value: FirebaseRemoteConfigValue = remoteConfig["test_remote_string"] - assertEquals("Hello from remote!", value.asString()) - assertEquals(FirebaseRemoteConfig.VALUE_SOURCE_REMOTE, value.source) - } -} -``` - -- [ ] **Step 3: Run the tests and confirm they fail** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'FirebaseRemoteConfigTest'` -Then: `grep -A8 ' 0) { - return String.format("%d:%02d:%02d", hours, minutes, seconds); - } - return String.format("%02d:%02d", minutes, seconds); - } -} -``` - -- [ ] **Step 10: Run the tests and confirm they pass** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'DateUtilsTest' --tests 'FirebaseRemoteConfigTest' ktlintCheck` -Expected: 2 + 7 pass, 2 skipped; ktlint clean. - -- [ ] **Step 11: Commit** - -```bash -git add src/main/java/android/net/TrafficStats.java src/main/java/android/content/res/Configuration.java src/main/java/android/content/res/Resources.java src/main/java/android/text/format/DateUtils.java src/test/kotlin/FirebaseRemoteConfigTest.kt src/test/kotlin/DateUtilsTest.kt -git commit -m "Add shims for Remote Config fetch through Firebase Installations" -``` - ---- - -### Task 6: Documentation and full verification - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Document `getFilesDir`** - -In `README.md`, after the paragraph ending "This is used by Firestore to support [offline data persistence](...)." insert: - -````markdown -#### Customizing file storage location - -The `FirebasePlatform` interface also includes a `getFilesDir` method for you to override if the following default implementation is not suitable: - -```kotlin - open fun getFilesDir(): File = File("${System.getProperty("java.io.tmpdir")}${File.separatorChar}firebase-files") -``` - -This is used by Remote Config and Installations to persist fetched configs and the installation ID. -```` - -- [ ] **Step 2: Update the project status table** - -Replace the two struck-through rows and footnote 2: - -```markdown -| [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` | -``` - -```markdown -[^2]: Realtime updates (`addOnConfigUpdateListener`) and defaults from XML resources are not supported. -``` - -- [ ] **Step 3: Run the full suite the way CI does** - -Run: -```bash -JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr firebase emulators:exec --project my-firebase-project --import=src/test/resources/firebase_data './gradlew cleanTest build' -grep -hoE 'testsuite name="[^"]*" tests="[0-9]+" skipped="[0-9]+" failures="[0-9]+" errors="[0-9]+"' build/test-results/test/*.xml -``` -Expected: `BUILD SUCCESSFUL`. 12 baseline tests plus 7 `ContextFilesTest`, 8 `PlatformSharedPreferencesTest`, 1 `JsonReaderTest`, 2 `DateUtilsTest`, and 9 `FirebaseRemoteConfigTest` (2 skipped). 0 failures, 0 errors. - -- [ ] **Step 4: Check test output is clean** - -Run: `grep -lE 'Exception|ERROR|WARN' build/test-results/test/*.xml` and read every ``/`` block in the matching files. -Expected: no exceptions or warnings from Remote Config, Installations, or the shims. Report any you find to Angelos with the exact text; do not suppress them. - -- [ ] **Step 5: Commit** - -```bash -git add README.md -git commit -m "Document Remote Config and Installations support" -``` - ---- - -### Task 7: Platform-backed heartbeat preferences - -Added during execution (spec: "Changes agreed during implementation", item 1). Runs before Task 6. - -**Files:** -- Modify: `src/main/java/android/content/SharedPreferences.java` -- Modify: `src/main/java/android/content/PlatformSharedPreferences.kt` -- Modify: `src/main/java/android/content/Context.kt` -- Test: `src/test/kotlin/PlatformSharedPreferencesTest.kt`, `src/test/kotlin/HeartBeatTest.kt` (create) - -**Interfaces:** -- Produces: `Map getAll()`; `Set getStringSet(String, Set)`; `Editor putStringSet(String, Set)`; `Editor remove(String)`. `Context.getSharedPreferences` returns `PlatformSharedPreferences` for names starting with `FirebaseHeartBeat`. Key index `"|__keys"` becomes a JSON object mapping each key to whether its value is a string set. - -- [ ] **Step 1: Write the failing tests** - -Append to `PlatformSharedPreferencesTest`: - -```kotlin - @Test - fun `string sets round-trip`() { - val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) - prefs.edit().putStringSet("fire-core/20.4.2", setOf("2026-10-07")).commit() - - assertEquals(setOf("2026-10-07"), prefs.getStringSet("fire-core/20.4.2", null)) - assertEquals(setOf("default"), prefs.getStringSet("missing", setOf("default"))) - } - - @Test - fun `getAll returns string sets as sets`() { - val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) - prefs.edit() - .putString("last-used-date", "2026-10-07") - .putStringSet("fire-core/20.4.2", setOf("2026-10-06", "2026-10-07")) - .commit() - - assertEquals( - mapOf("last-used-date" to "2026-10-07", "fire-core/20.4.2" to setOf("2026-10-06", "2026-10-07")), - prefs.all - ) - } - - @Test - fun `remove deletes the key`() { - val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) - prefs.edit().putStringSet("fire-core/20.4.2", setOf("2026-10-07")).commit() - prefs.edit().remove("fire-core/20.4.2").commit() - - assertFalse(prefs.contains("fire-core/20.4.2")) - assertTrue(prefs.all.isEmpty()) - } -``` - -In `other preference files still reject unknown keys`, change the file name to `"com.google.firebase.common.prefs:W0RFRkFVTFRd"` (heartbeat files are now platform-backed) and add: - -```kotlin - assertThrows(IllegalArgumentException::class.java) { prefs.getStringSet("unknown", null) } - assertThrows(IllegalArgumentException::class.java) { prefs.edit().putStringSet("unknown", null) } - assertThrows(IllegalArgumentException::class.java) { prefs.edit().remove("unknown") } -``` - -Create `src/test/kotlin/HeartBeatTest.kt`: - -```kotlin -/* - * Tests that Firebase's heartbeat reporting (firebase-common), which Installations attaches to - * its requests, can store and read heartbeats through the platform-backed preferences. - */ -import com.google.firebase.heartbeatinfo.DefaultHeartBeatController -import kotlinx.coroutines.tasks.await -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertTrue -import org.junit.Test - -class HeartBeatTest : FirebaseTest() { - - @Test - fun `registered heartbeat is reported in the header`(): Unit = runTest { - val controller = app.get(DefaultHeartBeatController::class.java) - - controller.registerHeartBeat().await() - - assertTrue(controller.heartBeatsHeader.await().isNotEmpty()) - } -} -``` - -- [ ] **Step 2: Run the tests and confirm they fail** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'PlatformSharedPreferencesTest' --tests 'HeartBeatTest'` -Expected: test compilation fails with unresolved `putStringSet`, `getStringSet`, and `remove`. - -- [ ] **Step 3: Extend the interface** - -In `SharedPreferences.java`, change `Map getAll();` to `Map getAll();`, add `Set getStringSet(String key, Set defValues);` (import `java.util.Set`), and in `Editor` add `Editor putStringSet(String key, Set values);` and `Editor remove(String key);`. - -- [ ] **Step 4: Strict preferences reject the new methods** - -In `Context.kt`'s anonymous `SharedPreferences`, add `getStringSet` throwing `IllegalArgumentException(key)`; in its `Editor`, add `putStringSet` and `remove` throwing `IllegalArgumentException(key)`. - -- [ ] **Step 5: String sets in `PlatformSharedPreferences`** - -Replace the key set with an index mapping each key to whether its value is a string set; store string sets JSON-encoded; `getAll()` decodes them; `putStringSet(key, null)` and `remove(key)` delete the key. Update the header comment to describe the index. Route names starting with `FirebaseHeartBeat` to `PlatformSharedPreferences` in `Context.getSharedPreferences`. - -- [ ] **Step 6: Run the tests and confirm they pass** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'PlatformSharedPreferencesTest' --tests 'ContextFilesTest' --tests 'HeartBeatTest' --tests 'FirebaseRemoteConfigTest' ktlintCheck` -Expected: all pass (2 skipped); ktlint clean. - -- [ ] **Step 7: Commit** - -```bash -git add src/main/java/android/content/SharedPreferences.java src/main/java/android/content/PlatformSharedPreferences.kt src/main/java/android/content/Context.kt src/test/kotlin/PlatformSharedPreferencesTest.kt src/test/kotlin/HeartBeatTest.kt -git commit -m "Persist Firebase heartbeat preferences through FirebasePlatform" -``` - ---- - -### Task 8: Capture and assert expected Remote Config warnings - -Added during execution (spec: "Changes agreed during implementation", item 2). Runs after Task 7, before Task 6. - -**Files:** -- Modify: `src/test/kotlin/FirebaseTest.kt` -- Modify: `src/test/kotlin/FirebaseRemoteConfigTest.kt` - -**Interfaces:** -- Produces: `FirebaseTest.log(message: String)`, `protected open`, default `println(message)`; the test platform's `log` delegates to it. - -- [ ] **Step 1: Write the failing test** - -In `FirebaseRemoteConfigTest`, add a synchronized `logs` list, override `log` to append to it, and extend `fetchAndActivate succeeds` to assert that every captured log starts with one of: `FirebaseApp Device unlocked`, `FirebaseRemoteConfig No such package: app.teamhub.TeamHub`, `ContentValues No such package: app.teamhub.TeamHub` (Installations, fresh installs only), `FirebaseRemoteConfig Could not update ABT experiments.`; and that the Remote Config `No such package` and ABT messages each appear. - -- [ ] **Step 2: Run it and confirm it fails** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'FirebaseRemoteConfigTest'` -Expected: test compilation fails: `log` overrides nothing. - -- [ ] **Step 3: Add the hook to `FirebaseTest`** - -Add `protected open fun log(message: String) = println(message)` and make the platform's `log(msg)` call `this@FirebaseTest.log(msg)`. - -- [ ] **Step 4: Run it and confirm it passes with clean output** - -Run: same as Step 2, plus `ktlintCheck`. -Expected: 7 pass, 2 skipped; the `` of `TEST-FirebaseRemoteConfigTest.xml` is empty. - -- [ ] **Step 5: Commit** - -```bash -git add src/test/kotlin/FirebaseTest.kt src/test/kotlin/FirebaseRemoteConfigTest.kt -git commit -m "Capture and assert expected Remote Config warnings in tests" -``` diff --git a/docs/superpowers/specs/2026-10-07-remote-config-design.md b/docs/superpowers/specs/2026-10-07-remote-config-design.md deleted file mode 100644 index dda3866..0000000 --- a/docs/superpowers/specs/2026-10-07-remote-config-design.md +++ /dev/null @@ -1,173 +0,0 @@ -# Remote Config support — design - -Date: 2026-10-07 -Branch: `feature/remote-config` - -## Goal - -Make Firebase Remote Config usable on the JVM through this SDK. The Android -`firebase-config` 21.6.0 and `firebase-installations` 17.2.0 libraries already -ship in the jar (see `build.gradle.kts`), but the README marks both as not -functional. This work fills the Android shims they depend on so the unmodified -Android code runs. - -Success means `Firebase.remoteConfig(app)` supports `setDefaultsAsync(Map)`, -`fetch`, `activate`, `fetchAndActivate`, `get*` values, `getAll`, -`getKeysByPrefix`, `getInfo`, `setConfigSettingsAsync`, and `reset` against the -live `fir-java-sdk` project. The same behaviour lets the JVM target of -[firebase-kotlin-sdk](https://github.com/GitLiveApp/firebase-kotlin-sdk) pass its -`firebase-config` common tests. - -## Scope - -In scope: the APIs listed above. - -Out of scope: - -- Realtime updates (`addOnConfigUpdateListener`). -- `setDefaultsAsync(@XmlRes int)`, which needs Android XML resources. -- Real `PackageManager.getPackageInfo` data. Both libraries catch the - `NameNotFoundException` it throws today. - -## Approach - -Fill the shims; do not reimplement Remote Config. Firestore, Database, -Functions, and Storage already work this way. A REST-based rewrite like Auth's -would duplicate code Google ships and drift from it. Auth took that route only -because its Android source is closed. - -The Admin SDK ([firebase-admin-java](https://github.com/firebase/firebase-admin-java)) -is not a reference for this work. It manages templates -(`/v1/projects/{id}/remoteConfig`) and evaluates server templates -(`namespaces/firebase-server/serverRemoteConfig`) with service-account OAuth. -Client fetch uses `namespaces/firebase:fetch` with the API key and a Firebase -Installations token, which the bundled Android library already implements. - -## Gaps found - -Found by scanning the extracted jars with `javap` for Android API calls and -comparing against `src/main/java/android/**`: - -1. `PackageManager.getServiceInfo` does not list `RemoteConfigRegistrar` or - `AbtRegistrar`, so component discovery cannot create Remote Config. -2. `Context` lacks `getFilesDir`, `openFileInput`, `openFileOutput`, and - `deleteFile`. Remote Config stores fetched, activated, and default configs as - files; Installations stores `PersistedInstallation..json`. -3. `Context.getSharedPreferences` returns one object that accepts only - whitelisted keys and throws `IllegalArgumentException` otherwise. Remote - Config uses `frc__firebase_settings`; Installations reads - `com.google.android.gms.appid`. -4. The `SharedPreferences` interface lacks `getInt`, `Editor.putInt`, and - `Editor.clear`, all called by Remote Config. -5. Missing classes or members: `android.util.JsonReader`, - `android.net.TrafficStats`, `android.text.format.DateUtils.formatElapsedTime`, - `android.content.res.Configuration` (with `locale`), and - `Resources.getConfiguration()`. - -## Components - -| Change | File | Purpose | -|---|---|---| -| Register `com.google.firebase.remoteconfig.RemoteConfigRegistrar` and `com.google.firebase.abt.component.AbtRegistrar` | `android/content/pm/PackageManager.java` | Component discovery | -| `open fun getFilesDir(): File`, default `File(java.io.tmpdir, "firebase-files")` | `com/google/firebase/FirebasePlatform.kt` | User-configurable location for persisted files; non-breaking | -| `filesDir`, `openFileInput`, `openFileOutput`, `deleteFile` | `android/content/Context.kt` | Resolve names inside `FirebasePlatform.getFilesDir()` | -| `getInt`, `Editor.putInt`, `Editor.clear` | `android/content/SharedPreferences.java` | Interface methods Remote Config calls | -| Platform-backed preferences for files named `frc_*` and `com.google.android.gms.appid` | `android/content/PlatformSharedPreferences.kt` | Store each key as `"\|"` via `FirebasePlatform.store/retrieve/clear`; `clear()` removes only that file's keys | -| Route those two file names to `PlatformSharedPreferences` | `android/content/Context.kt` | All other files keep the strict whitelist, which also gains throwing `getInt`/`putInt`/`clear` | -| `JsonReader` and its `JsonToken`/`JsonScope` support | `android/util/` | Port from AOSP (Apache 2.0), like the other ported shims | -| `TrafficStats` with no-op `setThreadStatsTag`/`clearThreadStatsTag` | `android/net/TrafficStats.java` | Installations tags its network thread | -| `DateUtils.formatElapsedTime(long)` | `android/text/format/DateUtils.java` | Throttling messages in `ConfigFetchHandler` | -| `Configuration` with `locale = Locale.getDefault()`; `Resources.getConfiguration()` | `android/content/res/` | Locale sent in the fetch request | -| Remove strikethrough and footnote 2 for Remote Config and Installations; document `getFilesDir` beside `getDatabasePath` | `README.md` | User docs | - -`clear()` must enumerate a file's keys, but `FirebasePlatform` exposes no key -listing. `PlatformSharedPreferences` therefore stores the set of keys it wrote -under `"|__keys"` and clears each one. - -## Data flow: `fetchAndActivate()` - -1. `RemoteConfigRegistrar` builds the component. Fetch metadata lives in - `frc__firebase_settings` (platform-backed preferences). -2. Installations reads `com.google.android.gms.appid` (empty on the JVM), - generates a FID, registers it with `firebaseinstallations.googleapis.com`, - parses the response with `JsonReader`, and writes - `PersistedInstallation..json` to `getFilesDir()`. -3. `ConfigFetchHttpClient` posts to `firebaseremoteconfig.googleapis.com` - with the FID, its auth token, and the default locale. -4. Remote Config writes the fetched, activated, and default configs to - `getFilesDir()` through `openFileOutput` and reads them with - `openFileInput`. - -## Error handling - -No new error handling. Failures reach callers as they do on Android: -`FirebaseRemoteConfigFetchException` or `FirebaseInstallationsException` on the -returned `Task`. Preference files outside the two platform-backed ones still -throw on unknown keys, so new usages fail loudly. If a runtime gap appears that -the bytecode scan missed, work stops and the gap is reported before any fix. - -## Testing - -TDD: each test is written and seen failing for the expected reason before its -shim exists. - -`src/test/kotlin/FirebaseRemoteConfigTest.kt` extends `FirebaseTest` and ports -the firebase-kotlin-sdk common tests -(`firebase-config/src/commonTest/.../FirebaseRemoteConfig.kt`) one-to-one onto -the Android API, keeping names, keys, values, and assertions: - -| Test | Android API used | -|---|---| -| `testGettingValues` | `setDefaultsAsync(map)`, `getBoolean`/`getDouble`/`getLong`/`getString`, `getValue(..).source == VALUE_SOURCE_DEFAULT`, `asByteArray()` | -| `testNamedApp` | Second app `"named"` with a different application ID; its defaults stay out of the default app's `all` | -| `testGetAll` | `all[..]?.asBoolean()` etc. | -| `testGetKeysByPrefix` | Four `test_default_*` keys | -| `testGetInfo` | `fetchTimeMillis == -1`, `lastFetchStatus == LAST_FETCH_STATUS_NO_FETCH_YET`, default settings | -| `testSetConfigSettings` | 42 s timeout and minimum interval round-trip through `info.configSettings` | -| `testFetch`, `testFetchAndActivate` | `@Ignore`, as upstream: they need `test_remote_string = "Hello from remote!"` published in the console | - -Teardown calls `reset().await()` before `FirebaseTest` clears the apps. - -One added live test, `fetchAndActivate succeeds`, asserts -`info.lastFetchStatus == LAST_FETCH_STATUS_SUCCESS` against `fir-java-sdk`. It -exercises the Installations and fetch network path without console changes. It -does not prove remote values are applied; the ignored tests cover that once a -parameter is published. - -Shim unit tests: - -- `PlatformSharedPreferences`: round-trip of string, int, and long; isolation - between two files; `clear()` removes only its own file's keys. -- `Context` file APIs: write, read, and delete inside a temporary - `getFilesDir()`. -- `JsonReader`: parses an Installations-shaped response. - -Gradle runs with `JAVA_HOME` set to JDK 17; Kotlin 2.0.20 fails to parse the -JDK 25 version string. The full suite must pass with clean output. - -## Possible follow-up - -Publishing `test_remote_string` would let the two ignored fetch tests run. A -fixture could do this with the Admin SDK, but it needs service-account -credentials in the test environment, so it stays out of this work. - -## Changes agreed during implementation - -The live fetch test surfaced two issues the bytecode scan missed. Angelos -decided both on 2026-10-07: - -1. **Heartbeat storage.** `firebase-common`'s `HeartBeatInfoStorage` uses - `getStringSet`, `putStringSet`, `remove`, and `getAll` (checking values with - `instanceof Set`) on the `FirebaseHeartBeat` preference - file. The strict whitelist supports none of these, so heartbeats failed for - every product; Installations logged "Failed to get heartbeats header". These - files become platform-backed too. `PlatformSharedPreferences` gains string - sets and `remove`, and its key index records which keys hold string sets so - `getAll()` returns them as `Set`. `SharedPreferences.getAll()` takes - Android's `Map` signature. -2. **Expected warnings in test output.** Each fetch logs a - `NameNotFoundException` (from `getPackageInfo`), and each activate logs - `AbtException: The Analytics SDK is not available`. Both are expected on the - JVM. `FirebaseTest` routes platform logs through an overridable `log`, and - `FirebaseRemoteConfigTest` captures them and asserts the live fetch logs - exactly these expected messages. From 726154189b5c072811bd1d95fc7a0617ca1a3717 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 23:58:11 +0300 Subject: [PATCH 16/33] Add preferences storage design spec --- .../2026-10-07-preferences-storage-design.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-07-preferences-storage-design.md diff --git a/docs/superpowers/specs/2026-10-07-preferences-storage-design.md b/docs/superpowers/specs/2026-10-07-preferences-storage-design.md new file mode 100644 index 0000000..aa2fe67 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-preferences-storage-design.md @@ -0,0 +1,99 @@ +# Preferences stored as files — design + +Date: 2026-10-07 +Branch: `feature/remote-config` (PR #70) + +## Goal + +Fix four findings from the `/code-review xhigh` of PR #70: + +1. **Remote Config stuck on defaults.** Remote Config keeps its fetch + metadata, including the ETag, in preferences stored through + `FirebasePlatform`, and its config JSON in `getFilesDir()`, which defaults + to the temp folder. When the OS cleans the temp folder, the next fetch + sends the stale ETag, the server answers `NO_CHANGE`, nothing is cached, + and the app serves defaults until the template changes. +2. **Flaky tests.** The test stores are plain maps that background threads now + write to, and logs from work that outlives a test can fail the next one. +3. **AOSP ports lack the header comment** that every new file needs. +4. **Firebase-internal preferences burden `FirebasePlatform` implementers** + with long keys and background writes, and preference files are routed by + hard-coded name prefixes on top of a strict whitelist. + +## Approach + +Store each preferences file as one JSON file under +`getFilesDir()/shared_prefs/`, as Android does. Preferences, Remote Config's +config files and the Installations file then share one folder. They are kept +or lost together, so they cannot drift apart (finding 1). Firebase-internal +keys never reach `FirebasePlatform` (finding 4). Every preferences name +uses this storage, and the strict whitelist is deleted. + +Angelos approved replacing `PlatformSharedPreferences` (written on this +branch, unreleased) and deleting the strict whitelist. + +Auth is unaffected: it writes the signed-in user directly with +`FirebasePlatform.store` (`FirebaseAuth.kt`), not through preferences. + +## Components + +| Change | File | Purpose | +|---|---|---| +| `PreferencesFile` replaces `PlatformSharedPreferences` | `src/main/java/android/content/PreferencesFile.kt` | One instance per file path, shared through a `ConcurrentHashMap`. Loads `getFilesDir()/shared_prefs/.json` on first use, keeps the values in memory, and guards them with a per-file lock. | +| Typed values | same | The JSON records each value's type (string, int, long, boolean, string set), so `getAll()` returns the stored types. | +| Batched `Editor` | same | Edits collect until `commit()` or `apply()`. A requested `clear()` applies first, then puts and removes, as on Android. The file is written to a temp file in the same folder and moved over the target with `Files.move(ATOMIC_MOVE, REPLACE_EXISTING)`. `apply()` writes synchronously. | +| Missing or corrupt file | same | A missing file reads as empty. A corrupt file is logged and read as empty; the next commit overwrites it. | +| `getSharedPreferences` | `src/main/java/android/content/Context.kt` | Returns the `PreferencesFile` for every name. The strict whitelist object is deleted, including its unreachable `FIREBASE_USER`, `fire-global`, `last-used-date`, and `\|T\|` branches. | +| `getBoolean` / `putBoolean` | `src/main/java/android/content/SharedPreferences.java` | `firebase-common`'s data-collection setting calls them. No `getFloat`: nothing calls it. | +| README | `README.md` | Drop the long-key warning. Keep "must be thread-safe" and the `ConcurrentHashMap` example, since Auth's token refresh writes from network threads. State that preferences live under `getFilesDir()`. | +| AOSP headers | `JsonReader.java`, `JsonScope.java`, `JsonToken.java`, `MalformedJsonException.java`, `Base64DataException.java`, `com/android/internal/util/StringPool.java` | A short comment above the license header: a verbatim AOSP port, and which library needs it. | + +Unchanged: `getFilesDir()`'s default (temp folder, like `getDatabasePath`) and +the URL-encoding of file names. + +Heartbeat values that earlier versions wrote to users' stores under the bare +keys `fire-global` and `last-used-date` stay there, unused. No migration: +nothing else was persisted through these preference files before this branch. + +## Testing + +TDD throughout. `PreferencesFileTest` replaces `PlatformSharedPreferencesTest` +and uses a fresh temp folder per test: + +- Values round-trip and `getAll()` returns them typed (string, int, long, + boolean, string set). +- Two files do not see each other's keys; `clear()` affects only its own file. +- Batching: `edit().putString(k, v).clear().commit()` keeps `k`. +- `commit()` writes valid JSON to `shared_prefs/.json` and leaves no + temp files. +- A file already on disk is loaded when first opened. With the previous test, + this covers persistence across a restart without test hooks in production + code. +- Eight threads committing concurrently all land in the file. +- A missing file reads as empty; a corrupt file is logged, reads as empty, and + is overwritten by the next commit. + +`HeartBeatTest`, `ContextFilesTest`, and `FirebaseRemoteConfigTest` stay. + +Flaky-test fixes: + +- `FirebaseTest` gives each test its own temp `getFilesDir()`, since + preferences are now on disk. `FirebaseTest` and `FakeFirebasePlatform` use a + `ConcurrentHashMap` store. +- `FirebaseStorageTest` waits for its `downloadUrl` task to finish without + asserting the outcome, so its logs stay within that test. + +Verification: the full suite inside the Auth emulator, plus `ktlintCheck`, +with clean output. + +## Out of scope + +- A persistent per-user default for `getFilesDir()`. +- Windows `File.renameTo` in Installations' own `PersistedInstallation` + (documented in the PR). +- Realtime updates; they get their own spec. + +## Design documents + +This spec and its plan are committed on the branch while work is in progress +and removed in one commit before pushing, so the PR stays code-only. From 1ee8b75616250f6465dee181cbd7b0e3b2aee3fa Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 00:03:55 +0300 Subject: [PATCH 17/33] Add preferences storage implementation plan --- .../plans/2026-10-08-preferences-storage.md | 669 ++++++++++++++++++ 1 file changed, 669 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-08-preferences-storage.md diff --git a/docs/superpowers/plans/2026-10-08-preferences-storage.md b/docs/superpowers/plans/2026-10-08-preferences-storage.md new file mode 100644 index 0000000..7fb6683 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-preferences-storage.md @@ -0,0 +1,669 @@ +# Preferences Stored as Files Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Store every `SharedPreferences` file as a JSON file under `getFilesDir()/shared_prefs`, replacing the `FirebasePlatform`-backed preferences and the strict whitelist, and fix the test hygiene and documentation findings from the PR #70 review. + +**Architecture:** `PreferencesFile` (new) implements `SharedPreferences` with one shared instance per file path, values held in memory and written atomically on commit. `Context.getSharedPreferences` maps a name to `getFilesDir()/shared_prefs/.json` and returns that instance for every name. Auth keeps writing straight to `FirebasePlatform.store`. + +**Tech Stack:** Kotlin 2.0.20 / Java 17, Gradle 8.13, JUnit 4, kotlinx-serialization-json 1.7.2 (already an `implementation` dependency), kotlinx-coroutines-test. + +**Spec:** `docs/superpowers/specs/2026-10-07-preferences-storage-design.md` + +## Global Constraints + +- Run every Gradle command with `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr`. +- Run the full suite inside the Auth emulator: `firebase emulators:exec --project my-firebase-project --import=src/test/resources/firebase_data ''`. Single test classes may run without it: `./gradlew test --tests ''`; read failures with `grep -A8 '.xml`. +- `ktlintCheck` must pass (ktlint 0.47.1). No trailing commas. Formatting-only failures: run `./gradlew ktlintFormat`; never hand-edit whitespace. +- Every new file starts with a brief header comment stating its purpose and how it fits the project. +- Match the style of the file you edit. Add no dependencies. +- Commit messages carry no AI attribution. +- Auth is unchanged: it writes the signed-in user with `FirebasePlatform.store`. +- `getFilesDir()`'s default and the URL-encoding of file names are unchanged. +- No migration of the bare `fire-global` / `last-used-date` keys earlier versions left in users' stores. +- If a test fails for a reason this plan does not predict, stop, find the root cause, report it, and wait for Angelos. +- Design docs (`docs/superpowers/`) are committed during the work and removed in one commit before pushing. + +## Review Focus + +1. **A write fails** (full disk, read-only folder): `commit()` returns `false`, the previous values stay readable, and the failure is logged. Task 1, `failed commit keeps previous values and returns false`. +2. **Concurrent commits** from Remote Config, Installations and heartbeat executors must all reach the file. Task 1, `concurrent commits all land in the file`. +3. **A corrupt or foreign file** on disk must not crash Firebase: it is logged, read as empty, and replaced on the next commit. Task 1, `corrupt file is logged, read as empty and overwritten`. +4. **Callers that mutate a returned string set** (heartbeat code edits sets it reads) must not change stored values without a commit. Task 1, `changing a returned string set does not change the stored value`. +5. **Restart:** values written before a restart are read back from disk with their types. Task 1, `existing file is loaded when first opened`. + +--- + +### Task 1: `PreferencesFile` becomes the preferences for every name + +**Files:** +- Create: `src/main/java/android/content/PreferencesFile.kt` +- Modify: `src/main/java/android/content/SharedPreferences.java` +- Modify: `src/main/java/android/content/Context.kt` (`getSharedPreferences`, `fileStreamPath`, imports) +- Delete: `src/main/java/android/content/PlatformSharedPreferences.kt`, `src/test/kotlin/PlatformSharedPreferencesTest.kt` +- Modify: `src/test/kotlin/fakes/FakeFirebasePlatform.kt`, `src/test/kotlin/FirebaseTest.kt`, `src/test/kotlin/ContextFilesTest.kt` +- Test: `src/test/kotlin/PreferencesFileTest.kt` (create) + +**Interfaces:** +- Produces: `internal class PreferencesFile : SharedPreferences` with `companion fun at(file: File): PreferencesFile` (one instance per absolute path); `SharedPreferences.getBoolean(String, boolean)`, `Editor.putBoolean(String, boolean)`; `FakeFirebasePlatform.logs: MutableList` (captured, not printed), `FakeFirebasePlatform.storage` defaults to a `ConcurrentHashMap`; `FirebaseTest.folder: TemporaryFolder` with `getFilesDir() = File(folder.root, "files")`. +- On-disk format: a JSON object mapping each key to a one-entry object naming its type: `{"string": "…"}`, `{"int": 3}`, `{"long": 42}`, `{"boolean": true}`, `{"stringSet": ["…"]}`. + +- [ ] **Step 1: Capture logs and use a thread-safe store in `FakeFirebasePlatform`** + +Replace `src/test/kotlin/fakes/FakeFirebasePlatform.kt` body so it reads: + +```kotlin +package fakes + +import com.google.firebase.FirebasePlatform +import java.io.File +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +/** + * Fake used to store firebase data during testing. The [storage] is made purposefully public to allow for direct + * access and modification if needed. + */ +class FakeFirebasePlatform( + val storage: MutableMap = ConcurrentHashMap(), + databaseFolderPath: String = "./build/database/", + filesFolderPath: String = "./build/files/" +) : FirebasePlatform() { + + private val databaseFolder = File(databaseFolderPath) + + private val filesFolder = File(filesFolderPath) + + // Captured instead of printed, so tests can assert on expected log output + val logs: MutableList = Collections.synchronizedList(mutableListOf()) + + override fun store(key: String, value: String) { storage[key] = value } + + override fun retrieve(key: String) = storage[key] + + override fun clear(key: String) { storage.remove(key) } + + override fun log(msg: String) { logs.add(msg) } + + override fun getDatabasePath(name: String) = File(databaseFolder, name) + + override fun getFilesDir() = filesFolder +} +``` + +- [ ] **Step 2: Write the failing `PreferencesFile` tests** + +Create `src/test/kotlin/PreferencesFileTest.kt`: + +```kotlin +/* + * Tests for PreferencesFile, the SharedPreferences that stores each preferences name as a JSON + * file under FirebasePlatform.getFilesDir()/shared_prefs. + */ +import android.content.PreferencesFile +import com.google.firebase.FirebasePlatform +import fakes.FakeFirebasePlatform +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.concurrent.thread + +class PreferencesFileTest { + + @get:Rule + val folder = TemporaryFolder() + + private val platform = FakeFirebasePlatform() + + private fun file(name: String) = File(folder.root, "shared_prefs/$name.json") + + private fun prefs(name: String = "frc_app_firebase_settings") = PreferencesFile.at(file(name)) + + @Before + fun setUp() { + FirebasePlatform.initializeFirebasePlatform(platform) + } + + @Test + fun `values round-trip with their types`() { + val prefs = prefs() + prefs.edit() + .putString("string", "abc") + .putInt("int", -1) + .putLong("long", 42L) + .putBoolean("boolean", true) + .putStringSet("stringSet", setOf("x", "y")) + .commit() + + assertEquals("abc", prefs.getString("string", null)) + assertEquals(-1, prefs.getInt("int", 0)) + assertEquals(42L, prefs.getLong("long", 0L)) + assertTrue(prefs.getBoolean("boolean", false)) + assertEquals(setOf("x", "y"), prefs.getStringSet("stringSet", null)) + assertEquals( + mapOf("string" to "abc", "int" to -1, "long" to 42L, "boolean" to true, "stringSet" to setOf("x", "y")), + prefs.all + ) + } + + @Test + fun `missing keys return defaults`() { + val prefs = prefs() + + assertEquals("default", prefs.getString("missing", "default")) + assertEquals(7, prefs.getInt("missing", 7)) + assertEquals(7L, prefs.getLong("missing", 7L)) + assertTrue(prefs.getBoolean("missing", true)) + assertEquals(setOf("default"), prefs.getStringSet("missing", setOf("default"))) + assertFalse(prefs.contains("missing")) + } + + @Test + fun `remove and putString null delete the key`() { + val prefs = prefs() + prefs.edit().putString("first", "a").putString("second", "b").commit() + + prefs.edit().remove("first").putString("second", null).commit() + + assertTrue(prefs.all.isEmpty()) + } + + @Test + fun `files do not see each other's keys`() { + prefs("first").edit().putString("key", "first").commit() + + assertNull(prefs("second").getString("key", null)) + } + + @Test + fun `clear affects only its own file`() { + prefs("first").edit().putString("key", "first").commit() + prefs("second").edit().putString("key", "second").commit() + + prefs("first").edit().clear().commit() + + assertTrue(prefs("first").all.isEmpty()) + assertEquals("second", prefs("second").getString("key", null)) + } + + @Test + fun `clear applies before the edits it is batched with`() { + val prefs = prefs() + prefs.edit().putString("old", "value").commit() + + prefs.edit().putString("kept", "value").clear().commit() + + assertEquals(mapOf("kept" to "value"), prefs.all) + } + + @Test + fun `edits are not visible before commit`() { + val prefs = prefs() + val edit = prefs.edit().putString("key", "value") + + assertFalse(prefs.contains("key")) + edit.commit() + assertTrue(prefs.contains("key")) + } + + @Test + fun `commit writes json and leaves no temporary files`() { + prefs().edit().putInt("num_failed_fetches", 3).commit() + + val stored = Json.parseToJsonElement(file("frc_app_firebase_settings").readText()).jsonObject + assertEquals(setOf("num_failed_fetches"), stored.keys) + assertEquals(listOf("frc_app_firebase_settings.json"), file("frc_app_firebase_settings").parentFile.list()!!.toList()) + } + + @Test + fun `existing file is loaded when first opened`() { + file("restored").parentFile.mkdirs() + file("restored").writeText( + """{"num_failed_fetches":{"int":3},"last_fetch_time_in_millis":{"long":42},"fire-core":{"stringSet":["2026-10-08"]}}""" + ) + + val prefs = prefs("restored") + + assertEquals(3, prefs.getInt("num_failed_fetches", 0)) + assertEquals(42L, prefs.getLong("last_fetch_time_in_millis", 0L)) + assertEquals(setOf("2026-10-08"), prefs.getStringSet("fire-core", null)) + } + + @Test + fun `missing file reads as empty`() { + val prefs = prefs("absent") + + assertTrue(prefs.all.isEmpty()) + assertFalse(file("absent").exists()) + } + + @Test + fun `corrupt file is logged, read as empty and overwritten`() { + file("corrupt").parentFile.mkdirs() + file("corrupt").writeText("not json") + + val prefs = prefs("corrupt") + + assertTrue(prefs.all.isEmpty()) + assertTrue(platform.logs.any { it.startsWith("PreferencesFile Ignoring unreadable preferences file") }) + prefs.edit().putString("key", "value").commit() + assertEquals(setOf("key"), Json.parseToJsonElement(file("corrupt").readText()).jsonObject.keys) + } + + @Test + fun `failed commit keeps previous values and returns false`() { + val prefs = prefs() + prefs.edit().putString("key", "before").commit() + val sharedPrefs = file("frc_app_firebase_settings").parentFile + sharedPrefs.setWritable(false) + try { + assertFalse(prefs.edit().putString("key", "after").commit()) + assertEquals("before", prefs.getString("key", null)) + assertTrue(platform.logs.any { it.startsWith("PreferencesFile Failed to write preferences file") }) + } finally { + sharedPrefs.setWritable(true) + } + } + + @Test + fun `concurrent commits all land in the file`() { + val prefs = prefs() + (0 until 8).map { i -> + thread { repeat(50) { j -> prefs.edit().putInt("key-$i-$j", j).commit() } } + }.forEach { it.join() } + + assertEquals(400, prefs.all.size) + assertEquals(400, Json.parseToJsonElement(file("frc_app_firebase_settings").readText()).jsonObject.size) + } + + @Test + fun `changing a returned string set does not change the stored value`() { + val prefs = prefs() + prefs.edit().putStringSet("dates", setOf("2026-10-08")).commit() + + (prefs.getStringSet("dates", null) as MutableSet).add("2026-10-09") + (prefs.all["dates"] as MutableSet).add("2026-10-10") + + assertEquals(setOf("2026-10-08"), prefs.getStringSet("dates", null)) + } + + @Test + fun `same path returns the same instance`() { + assertSame(prefs("shared"), prefs("shared")) + } +} +``` + +- [ ] **Step 3: Write the failing `Context` tests** + +Append to `ContextFilesTest` (before its closing brace): + +```kotlin + @Test + fun `preferences are stored as files in filesDir`() { + context.getSharedPreferences("frc_1:341458593155:web:bf8e1aa37efe01f32d42b6_firebase_settings", 0) + .edit().putLong("last_fetch_time_in_millis", 1L).commit() + + val stored = File(filesDir, "shared_prefs").list()!!.single() + assertTrue(stored.endsWith(".json")) + assertTrue(':' !in stored) + } + + @Test + fun `any preferences name is accepted`() { + val prefs = context.getSharedPreferences("com.google.firebase.common.prefs:W0RFRkFVTFRd", 0) + + assertTrue(prefs.getBoolean("firebase_data_collection_default_enabled", true)) + } +``` + +- [ ] **Step 4: Run the tests and confirm they fail** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'PreferencesFileTest' --tests 'ContextFilesTest'` +Expected: test compilation fails with unresolved `PreferencesFile`, `putBoolean`, and `getBoolean`. + +- [ ] **Step 5: Add the boolean methods to the interface** + +In `src/main/java/android/content/SharedPreferences.java`, after `int getInt(String key, int defValue);` add `boolean getBoolean(String key, boolean defValue);`, and in `Editor` after `Editor putInt(String key, int value);` add `Editor putBoolean(String key, boolean value);`. + +- [ ] **Step 6: Create `PreferencesFile`** + +Create `src/main/java/android/content/PreferencesFile.kt`: + +```kotlin +/* + * SharedPreferences stored as one JSON file per preferences name under + * FirebasePlatform.getFilesDir()/shared_prefs, as Android stores them in the app's data folder. + * Keeping preferences beside the other files Firebase writes to getFilesDir() means they are kept + * or lost together. Every caller of a file path shares one instance, which holds the values in + * memory and replaces the whole file atomically on commit. + */ +package android.content + +import android.util.Log +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.concurrent.ConcurrentHashMap + +internal class PreferencesFile private constructor(private val file: File) : SharedPreferences { + + // Loaded from the file on first use; read and replaced only while holding this instance's lock + private var loadedValues: Map? = null + + override fun contains(key: String): Boolean = values().containsKey(key) + + override fun getString(key: String, defaultValue: String?): String? = values()[key] as String? ?: defaultValue + + override fun getInt(key: String, defValue: Int): Int = values()[key] as Int? ?: defValue + + override fun getLong(key: String, defValue: Long): Long = values()[key] as Long? ?: defValue + + override fun getBoolean(key: String, defValue: Boolean): Boolean = values()[key] as Boolean? ?: defValue + + // Returns a copy, so callers cannot change the stored set without an edit + override fun getStringSet(key: String, defValues: Set?): Set? = + (values()[key] as Set<*>?)?.mapTo(HashSet()) { it as String } ?: defValues + + override fun getAll(): Map = + values().mapValues { (_, value) -> if (value is Set<*>) HashSet(value) else value } + + override fun edit(): SharedPreferences.Editor = Edit() + + private fun values(): Map = synchronized(this) { + loadedValues ?: read().also { loadedValues = it } + } + + private fun read(): Map { + if (!file.exists()) return emptyMap() + return try { + Json.parseToJsonElement(file.readText()).jsonObject.mapValues { (_, value) -> decode(value) } + } catch (e: Exception) { + // Unreadable content is discarded; the next commit replaces the file + Log.w(TAG, "Ignoring unreadable preferences file $file", e) + emptyMap() + } + } + + private fun save(changes: Map, clear: Boolean): Boolean { + synchronized(this) { + val updated = if (clear) mutableMapOf() else values().toMutableMap() + changes.forEach { (key, value) -> if (value == null) updated.remove(key) else updated[key] = value } + try { + write(updated) + } catch (e: IOException) { + Log.e(TAG, "Failed to write preferences file $file", e) + return false + } + loadedValues = updated + return true + } + } + + // Writes a temporary file and moves it over the target, so a failed write leaves the previous file intact + private fun write(values: Map) { + val folder = file.parentFile + if (!folder.isDirectory && !folder.mkdirs()) throw IOException("Cannot create $folder") + val temporary = File.createTempFile(file.name, ".tmp", folder) + try { + temporary.writeText(JsonObject(values.mapValues { (_, value) -> encode(value) }).toString()) + Files.move(temporary.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } finally { + temporary.delete() + } + } + + // Collects changes until commit() or apply(), as Android's editor does + private inner class Edit : SharedPreferences.Editor { + + // A null value removes the key + private val changes = mutableMapOf() + + private var clearRequested = false + + override fun putString(key: String, value: String?) = change(key, value) + + override fun putInt(key: String, value: Int) = change(key, value) + + override fun putLong(key: String, value: Long) = change(key, value) + + override fun putBoolean(key: String, value: Boolean) = change(key, value) + + override fun putStringSet(key: String, values: Set?) = change(key, values?.toHashSet()) + + override fun remove(key: String) = change(key, null) + + override fun clear(): SharedPreferences.Editor = synchronized(this) { + clearRequested = true + this + } + + override fun commit(): Boolean = synchronized(this) { + save(changes.toMap(), clearRequested).also { + changes.clear() + clearRequested = false + } + } + + override fun apply() { + commit() + } + + private fun change(key: String, value: Any?): SharedPreferences.Editor = synchronized(this) { + changes[key] = value + this + } + } + + companion object { + private const val TAG = "PreferencesFile" + + private val instances = ConcurrentHashMap() + + fun at(file: File): PreferencesFile = instances.computeIfAbsent(file.absoluteFile) { PreferencesFile(it) } + + // Each value is a one-entry object naming its type, e.g. {"int": 3}, so reads restore the type + private fun encode(value: Any): JsonElement = buildJsonObject { + when (value) { + is String -> put("string", value) + is Int -> put("int", value) + is Long -> put("long", value) + is Boolean -> put("boolean", value) + is Set<*> -> putJsonArray("stringSet") { value.forEach { add(it as String) } } + else -> throw IllegalArgumentException("Unsupported preference value $value") + } + } + + private fun decode(element: JsonElement): Any { + val (type, value) = element.jsonObject.entries.single() + return when (type) { + "string" -> value.jsonPrimitive.content + "int" -> value.jsonPrimitive.int + "long" -> value.jsonPrimitive.long + "boolean" -> value.jsonPrimitive.boolean + "stringSet" -> value.jsonArray.mapTo(HashSet()) { it.jsonPrimitive.content } + else -> throw IllegalArgumentException("Unknown preference type $type") + } + } + } +} +``` + +- [ ] **Step 7: Return `PreferencesFile` for every name and delete the strict whitelist** + +In `src/main/java/android/content/Context.kt`: + +- Replace the whole `getSharedPreferences` function (the routing `if` and the anonymous strict `SharedPreferences`) with: + +```kotlin + fun getSharedPreferences(name: String, mode: Int): SharedPreferences = + PreferencesFile.at(File(File(filesDir, "shared_prefs"), "${encodeFileName(name)}.json")) +``` + +- Replace `fileStreamPath` and its comment with: + +```kotlin + private fun fileStreamPath(name: String): File { + require('/' !in name && File.separatorChar !in name) { "File $name contains a path separator" } + return File(filesDir, encodeFileName(name)) + } + + // URL-encodes the name so characters such as ':' in Firebase app IDs are valid on every OS + private fun encodeFileName(name: String): String = URLEncoder.encode(name, Charsets.UTF_8) +``` + +- Remove the now-unused import `android.content.SharedPreferences.Editor`. + +- [ ] **Step 8: Delete the `FirebasePlatform`-backed preferences** + +```bash +git rm src/main/java/android/content/PlatformSharedPreferences.kt src/test/kotlin/PlatformSharedPreferencesTest.kt +``` + +- [ ] **Step 9: Isolate files per test in `FirebaseTest`** + +In `src/test/kotlin/FirebaseTest.kt`: add imports `org.junit.Rule`, `org.junit.rules.TemporaryFolder`, `java.util.concurrent.ConcurrentHashMap`; add before `log`: + +```kotlin + // Preferences and config files are written under getFilesDir(), so each test gets its own + @get:Rule + val folder = TemporaryFolder() +``` + +change `val storage = mutableMapOf()` to `val storage = ConcurrentHashMap()`, and change `override fun getFilesDir() = File("./build/files")` to `override fun getFilesDir() = File(folder.root, "files")`. + +- [ ] **Step 10: Run the tests and confirm they pass** + +Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew cleanTest test --tests 'PreferencesFileTest' --tests 'ContextFilesTest' --tests 'HeartBeatTest' --tests 'FirebaseRemoteConfigTest' --tests 'JsonReaderTest' --tests 'DateUtilsTest' ktlintCheck` +Expected: PreferencesFileTest 15, ContextFilesTest 9, HeartBeatTest 1, FirebaseRemoteConfigTest 9 (2 skipped), JsonReaderTest 1, DateUtilsTest 2 pass; ktlint clean; `` of `TEST-PreferencesFileTest.xml`, `TEST-ContextFilesTest.xml` and `TEST-FirebaseRemoteConfigTest.xml` empty. + +- [ ] **Step 11: Commit** + +```bash +git add src/main/java/android/content/PreferencesFile.kt src/main/java/android/content/SharedPreferences.java src/main/java/android/content/Context.kt src/test/kotlin/fakes/FakeFirebasePlatform.kt src/test/kotlin/FirebaseTest.kt src/test/kotlin/ContextFilesTest.kt src/test/kotlin/PreferencesFileTest.kt +git commit -m "Store preferences as JSON files in getFilesDir" +``` + +--- + +### Task 2: Documentation, AOSP headers, and the Storage test + +**Files:** +- Modify: `README.md` +- Modify: `src/main/java/android/util/JsonReader.java`, `JsonScope.java`, `JsonToken.java`, `MalformedJsonException.java`, `Base64DataException.java`, `src/main/java/com/android/internal/util/StringPool.java` +- Modify: `src/test/kotlin/FirebaseStorageTest.kt` + +**Interfaces:** +- Consumes: Task 1's file-backed preferences (the README describes them). + +- [ ] **Step 1: Add header comments to the AOSP ports** + +Insert as the first lines of each file, above the existing license comment: + +`JsonReader.java`: +```java +/* + * Ported unchanged from AOSP (android14-release). Firebase Installations parses its REST + * responses with android.util.JsonReader. + */ +``` + +`JsonScope.java`, `JsonToken.java`, `MalformedJsonException.java`: +```java +/* + * Ported unchanged from AOSP (android14-release). Part of the android.util.JsonReader port, + * which Firebase Installations uses to parse its REST responses. + */ +``` + +`com/android/internal/util/StringPool.java`: +```java +/* + * Ported unchanged from AOSP (android14-release). Used by the android.util.JsonReader port. + */ +``` + +`Base64DataException.java`: +```java +/* + * Ported unchanged from AOSP (android14-release). Thrown by the android.util.Base64OutputStream + * shim, which firebase-common uses to encode heartbeat headers. + */ +``` + +- [ ] **Step 2: Update the README** + +Replace the paragraph starting "The Firebase products call these methods from background threads" with: + +```markdown +Firebase Auth calls these methods from background threads, so your implementation must be thread-safe. The other Firebase +products keep their state in files under `getFilesDir()`, described below. +``` + +Replace "This is used by Remote Config and Installations to persist fetched configs and the installation ID." with: + +```markdown +This is used by Remote Config and Installations to persist fetched configs and the installation ID, and by all Firebase +products to store their preferences, as JSON files in a `shared_prefs` subfolder. +``` + +- [ ] **Step 3: Make the Storage test wait for its request** + +In `src/test/kotlin/FirebaseStorageTest.kt`, add imports `kotlinx.coroutines.tasks.await` and `kotlinx.coroutines.test.runTest`, and change `getting child reference` to: + +```kotlin + @Test + fun `getting child reference`(): Unit = runTest { + val storage = Firebase.storage(app) + val reference = storage.reference + val downloadRef = reference.child("mountains.jpg") + val downloadUrl = downloadRef.downloadUrl + + Assert.assertNotNull(downloadUrl) + // Waits for the request so its logs stay within this test; the outcome is not under test + runCatching { downloadUrl.await() } + } +``` + +- [ ] **Step 4: Run the full suite the way CI does** + +Run: +```bash +JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr firebase emulators:exec --project my-firebase-project --import=src/test/resources/firebase_data './gradlew cleanTest build' +cat build/test-results/test/*.xml | grep -oE 'testsuite name="[^"]*" tests="[0-9]+" skipped="[0-9]+" failures="[0-9]+" errors="[0-9]+"' +``` +Expected: `BUILD SUCCESSFUL`; 49 tests (12 baseline, ContextFilesTest 9, PreferencesFileTest 15, JsonReaderTest 1, DateUtilsTest 2, HeartBeatTest 1, FirebaseRemoteConfigTest 9), 2 skipped, 0 failures. + +- [ ] **Step 5: Check output is clean** + +Read every ``/`` block in `build/test-results/test/*.xml`. Expected: Storage `NetworkRequest`/`StorageUtil` lines appear only in `TEST-FirebaseStorageTest.xml`; `FirebaseFirestoreTest`'s Play-services and SQLite lines are pre-existing (present on master 9f3a1b8); nothing else. Report any other output to Angelos. + +- [ ] **Step 6: Commit** + +```bash +git add README.md src/main/java/android/util/JsonReader.java src/main/java/android/util/JsonScope.java src/main/java/android/util/JsonToken.java src/main/java/android/util/MalformedJsonException.java src/main/java/android/util/Base64DataException.java src/main/java/com/android/internal/util/StringPool.java src/test/kotlin/FirebaseStorageTest.kt +git commit -m "Document file-based preferences, label AOSP ports, and await the Storage test request" +``` From bc488d29e373ef90096f68be2e242e97d09ec559 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 00:06:58 +0300 Subject: [PATCH 18/33] Store preferences as JSON files in getFilesDir --- src/main/java/android/content/Context.kt | 104 +------- .../content/PlatformSharedPreferences.kt | 106 --------- .../java/android/content/PreferencesFile.kt | 171 +++++++++++++ .../android/content/SharedPreferences.java | 2 + src/test/kotlin/ContextFilesTest.kt | 17 ++ src/test/kotlin/FirebaseTest.kt | 11 +- .../kotlin/PlatformSharedPreferencesTest.kt | 149 ------------ src/test/kotlin/PreferencesFileTest.kt | 225 ++++++++++++++++++ src/test/kotlin/fakes/FakeFirebasePlatform.kt | 9 +- 9 files changed, 437 insertions(+), 357 deletions(-) delete mode 100644 src/main/java/android/content/PlatformSharedPreferences.kt create mode 100644 src/main/java/android/content/PreferencesFile.kt delete mode 100644 src/test/kotlin/PlatformSharedPreferencesTest.kt create mode 100644 src/test/kotlin/PreferencesFileTest.kt diff --git a/src/main/java/android/content/Context.kt b/src/main/java/android/content/Context.kt index 4c8879d..c031254 100644 --- a/src/main/java/android/content/Context.kt +++ b/src/main/java/android/content/Context.kt @@ -1,6 +1,5 @@ package android.content -import android.content.SharedPreferences.Editor import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.content.res.Resources @@ -45,101 +44,8 @@ open class Context { val applicationInfo: ApplicationInfo = ApplicationInfo() - fun getSharedPreferences(name: String, mode: Int): SharedPreferences { - if (name.startsWith("frc_") || name == "com.google.android.gms.appid" || name.startsWith("FirebaseHeartBeat")) { - return PlatformSharedPreferences(name) - } - return object : SharedPreferences { - override fun contains(key: String): Boolean { - when (key) { - "firebase_data_collection_default_enabled" -> return false - "auto_init" -> return false - "firebase_messaging_auto_init_enabled" -> return false - "fire-fst" -> return false - "fire-global" -> return !FirebasePlatform.firebasePlatform.retrieve(key).isNullOrEmpty() - } - throw IllegalArgumentException(key) - } - - override fun getString(key: String, defaultValue: String?): String? { - return when { - key == "last-used-date" -> FirebasePlatform.firebasePlatform.retrieve(key) ?: defaultValue - key.contains("|T|") -> null - key.startsWith("com.google.firebase.auth.FIREBASE_USER") -> - FirebasePlatform.firebasePlatform.retrieve(key) ?: defaultValue - else -> throw IllegalArgumentException(key) - } - } - - override fun getLong(key: String?, defaultValue: Long): Long { - when (key) { - "fire-global" -> return FirebasePlatform.firebasePlatform.retrieve(key)?.toLong() ?: defaultValue - } - throw IllegalArgumentException(key) - } - - override fun getInt(key: String?, defaultValue: Int): Int { - throw IllegalArgumentException(key) - } - - override fun getStringSet(key: String?, defaultValues: Set?): Set? { - throw IllegalArgumentException(key) - } - - override fun getAll(): Map { - return emptyMap() - } - - override fun edit(): Editor { - return object : Editor { - override fun putLong(key: String?, value: Long): Editor { - when (key) { - "fire-global" -> FirebasePlatform.firebasePlatform.store(key, value.toString()) - else -> throw IllegalArgumentException(key) - } - return this - } - - override fun putString(key: String?, value: String?): Editor { - when (key) { - "last-used-date" -> FirebasePlatform.firebasePlatform.store(key, value.toString()) - else -> if (key?.startsWith("com.google.firebase.auth.FIREBASE_USER") == true) { - FirebasePlatform.firebasePlatform.store(key, value.toString()) - } else { - throw IllegalArgumentException(key) - } - } - return this - } - - override fun putInt(key: String?, value: Int): Editor { - throw IllegalArgumentException(key) - } - - override fun putStringSet(key: String?, values: Set?): Editor { - throw IllegalArgumentException(key) - } - - override fun remove(key: String?): Editor { - throw IllegalArgumentException(key) - } - - override fun clear(): Editor { - throw IllegalArgumentException(name) - } - - override fun commit(): Boolean { - // Don't need to commit as changes are committed in the put method - return true - } - - override fun apply() { - // Don't need to apply as changes are applied in the put method - } - } - } - } - } + fun getSharedPreferences(name: String, mode: Int): SharedPreferences = + PreferencesFile.at(File(File(filesDir, "shared_prefs"), "${encodeFileName(name)}.json")) fun getSystemService(name: String): Any { when (name) { @@ -172,12 +78,14 @@ open class Context { fun deleteFile(name: String): Boolean = fileStreamPath(name).delete() - // URL-encodes the name so characters such as ':' in Firebase app IDs are valid on every OS private fun fileStreamPath(name: String): File { require('/' !in name && File.separatorChar !in name) { "File $name contains a path separator" } - return File(filesDir, URLEncoder.encode(name, Charsets.UTF_8)) + return File(filesDir, encodeFileName(name)) } + // URL-encodes the name so characters such as ':' in Firebase app IDs are valid on every OS + private fun encodeFileName(name: String): String = URLEncoder.encode(name, Charsets.UTF_8) + companion object { @JvmStatic val CONNECTIVITY_SERVICE = "connectivity" diff --git a/src/main/java/android/content/PlatformSharedPreferences.kt b/src/main/java/android/content/PlatformSharedPreferences.kt deleted file mode 100644 index dab9978..0000000 --- a/src/main/java/android/content/PlatformSharedPreferences.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * SharedPreferences persisted through FirebasePlatform, returned by Context.getSharedPreferences - * for the preference files of Remote Config, Installations, and Firebase heartbeats. Each key is - * stored as "|", with string sets JSON-encoded. An index under "|__keys" maps each - * key to whether its value is a string set, so getAll() can restore the type and clear() can find - * every key. Puts are written immediately, so commit() and apply() have nothing left to do. - */ -package android.content - -import com.google.firebase.FirebasePlatform -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.SetSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json - -internal class PlatformSharedPreferences(private val name: String) : SharedPreferences { - - private val platform: FirebasePlatform - get() = FirebasePlatform.firebasePlatform - - override fun contains(key: String): Boolean = platform.retrieve(storageKey(key)) != null - - override fun getString(key: String, defaultValue: String?): String? = platform.retrieve(storageKey(key)) ?: defaultValue - - override fun getLong(key: String, defValue: Long): Long = platform.retrieve(storageKey(key))?.toLong() ?: defValue - - override fun getInt(key: String, defValue: Int): Int = platform.retrieve(storageKey(key))?.toInt() ?: defValue - - override fun getStringSet(key: String, defValues: Set?): Set? = - platform.retrieve(storageKey(key))?.let { Json.decodeFromString(stringSetSerializer, it) } ?: defValues - - override fun getAll(): Map = synchronized(lock) { - index().mapNotNull { (key, isStringSet) -> - platform.retrieve(storageKey(key))?.let { value -> - key to if (isStringSet) Json.decodeFromString(stringSetSerializer, value) else value - } - }.toMap() - } - - override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor { - override fun putString(key: String, value: String?): SharedPreferences.Editor { - put(key, value) - return this - } - - override fun putLong(key: String, value: Long): SharedPreferences.Editor { - put(key, value.toString()) - return this - } - - override fun putInt(key: String, value: Int): SharedPreferences.Editor { - put(key, value.toString()) - return this - } - - override fun putStringSet(key: String, values: Set?): SharedPreferences.Editor { - put(key, values?.let { Json.encodeToString(stringSetSerializer, it) }, isStringSet = true) - return this - } - - override fun remove(key: String): SharedPreferences.Editor { - put(key, null) - return this - } - - override fun clear(): SharedPreferences.Editor { - clearAll() - return this - } - - override fun commit(): Boolean = true - - override fun apply() { - } - } - - private fun put(key: String, value: String?, isStringSet: Boolean = false) = synchronized(lock) { - if (value == null) { - platform.clear(storageKey(key)) - writeIndex(index() - key) - } else { - platform.store(storageKey(key), value) - writeIndex(index() + (key to isStringSet)) - } - } - - private fun clearAll() = synchronized(lock) { - index().keys.forEach { platform.clear(storageKey(it)) } - platform.clear(indexKey) - } - - private fun index(): Map = platform.retrieve(indexKey)?.let { Json.decodeFromString(indexSerializer, it) } ?: emptyMap() - - private fun writeIndex(index: Map) = platform.store(indexKey, Json.encodeToString(indexSerializer, index)) - - private fun storageKey(key: String) = "$name|$key" - - private val indexKey: String - get() = "$name|__keys" - - companion object { - private val lock = Any() - private val stringSetSerializer = SetSerializer(String.serializer()) - private val indexSerializer = MapSerializer(String.serializer(), Boolean.serializer()) - } -} diff --git a/src/main/java/android/content/PreferencesFile.kt b/src/main/java/android/content/PreferencesFile.kt new file mode 100644 index 0000000..5cc480f --- /dev/null +++ b/src/main/java/android/content/PreferencesFile.kt @@ -0,0 +1,171 @@ +/* + * SharedPreferences stored as one JSON file per preferences name under + * FirebasePlatform.getFilesDir()/shared_prefs, as Android stores them in the app's data folder. + * Keeping preferences beside the other files Firebase writes to getFilesDir() means they are kept + * or lost together. Every caller of a file path shares one instance, which holds the values in + * memory and replaces the whole file atomically on commit. + */ +package android.content + +import android.util.Log +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.concurrent.ConcurrentHashMap + +internal class PreferencesFile private constructor(private val file: File) : SharedPreferences { + + // Loaded from the file on first use; read and replaced only while holding this instance's lock + private var loadedValues: Map? = null + + override fun contains(key: String): Boolean = values().containsKey(key) + + override fun getString(key: String, defaultValue: String?): String? = values()[key] as String? ?: defaultValue + + override fun getInt(key: String, defValue: Int): Int = values()[key] as Int? ?: defValue + + override fun getLong(key: String, defValue: Long): Long = values()[key] as Long? ?: defValue + + override fun getBoolean(key: String, defValue: Boolean): Boolean = values()[key] as Boolean? ?: defValue + + // Returns a copy, so callers cannot change the stored set without an edit + override fun getStringSet(key: String, defValues: Set?): Set? = + (values()[key] as Set<*>?)?.mapTo(HashSet()) { it as String } ?: defValues + + override fun getAll(): Map = + values().mapValues { (_, value) -> if (value is Set<*>) HashSet(value) else value } + + override fun edit(): SharedPreferences.Editor = Edit() + + private fun values(): Map = synchronized(this) { + loadedValues ?: read().also { loadedValues = it } + } + + private fun read(): Map { + if (!file.exists()) return emptyMap() + return try { + Json.parseToJsonElement(file.readText()).jsonObject.mapValues { (_, value) -> decode(value) } + } catch (e: Exception) { + // Unreadable content is discarded; the next commit replaces the file + Log.w(TAG, "Ignoring unreadable preferences file $file", e) + emptyMap() + } + } + + private fun save(changes: Map, clear: Boolean): Boolean { + synchronized(this) { + val updated = if (clear) mutableMapOf() else values().toMutableMap() + changes.forEach { (key, value) -> if (value == null) updated.remove(key) else updated[key] = value } + try { + write(updated) + } catch (e: IOException) { + Log.e(TAG, "Failed to write preferences file $file", e) + return false + } + loadedValues = updated + return true + } + } + + // Writes a temporary file and moves it over the target, so a failed write leaves the previous file intact + private fun write(values: Map) { + val folder = file.parentFile + // Succeeds when another file's write has just created the folder + Files.createDirectories(folder.toPath()) + val temporary = File.createTempFile(file.name, ".tmp", folder) + try { + temporary.writeText(JsonObject(values.mapValues { (_, value) -> encode(value) }).toString()) + Files.move(temporary.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } finally { + temporary.delete() + } + } + + // Collects changes until commit() or apply(), as Android's editor does + private inner class Edit : SharedPreferences.Editor { + + // A null value removes the key + private val changes = mutableMapOf() + + private var clearRequested = false + + override fun putString(key: String, value: String?) = change(key, value) + + override fun putInt(key: String, value: Int) = change(key, value) + + override fun putLong(key: String, value: Long) = change(key, value) + + override fun putBoolean(key: String, value: Boolean) = change(key, value) + + override fun putStringSet(key: String, values: Set?) = change(key, values?.toHashSet()) + + override fun remove(key: String) = change(key, null) + + override fun clear(): SharedPreferences.Editor = synchronized(this) { + clearRequested = true + this + } + + override fun commit(): Boolean = synchronized(this) { + save(changes.toMap(), clearRequested).also { + changes.clear() + clearRequested = false + } + } + + override fun apply() { + commit() + } + + private fun change(key: String, value: Any?): SharedPreferences.Editor = synchronized(this) { + changes[key] = value + this + } + } + + companion object { + private const val TAG = "PreferencesFile" + + private val instances = ConcurrentHashMap() + + fun at(file: File): PreferencesFile = instances.computeIfAbsent(file.absoluteFile) { PreferencesFile(it) } + + // Each value is a one-entry object naming its type, e.g. {"int": 3}, so reads restore the type + private fun encode(value: Any): JsonElement = buildJsonObject { + when (value) { + is String -> put("string", value) + is Int -> put("int", value) + is Long -> put("long", value) + is Boolean -> put("boolean", value) + is Set<*> -> putJsonArray("stringSet") { value.forEach { add(it as String) } } + else -> throw IllegalArgumentException("Unsupported preference value $value") + } + } + + private fun decode(element: JsonElement): Any { + val (type, value) = element.jsonObject.entries.single() + return when (type) { + "string" -> value.jsonPrimitive.content + "int" -> value.jsonPrimitive.int + "long" -> value.jsonPrimitive.long + "boolean" -> value.jsonPrimitive.boolean + "stringSet" -> value.jsonArray.mapTo(HashSet()) { it.jsonPrimitive.content } + else -> throw IllegalArgumentException("Unknown preference type $type") + } + } + } +} diff --git a/src/main/java/android/content/SharedPreferences.java b/src/main/java/android/content/SharedPreferences.java index 596c297..d2c4559 100644 --- a/src/main/java/android/content/SharedPreferences.java +++ b/src/main/java/android/content/SharedPreferences.java @@ -10,6 +10,7 @@ public interface SharedPreferences { Map getAll(); long getLong(String key, long defValue); int getInt(String key, int defValue); + boolean getBoolean(String key, boolean defValue); Set getStringSet(String key, Set defValues); Editor edit(); @@ -18,6 +19,7 @@ interface Editor { Editor putLong(String key, long value); Editor putString(String key, String value); Editor putInt(String key, int value); + Editor putBoolean(String key, boolean value); Editor putStringSet(String key, Set values); Editor remove(String key); Editor clear(); diff --git a/src/test/kotlin/ContextFilesTest.kt b/src/test/kotlin/ContextFilesTest.kt index 87cf9b6..c188388 100644 --- a/src/test/kotlin/ContextFilesTest.kt +++ b/src/test/kotlin/ContextFilesTest.kt @@ -77,4 +77,21 @@ class ContextFilesTest { fun `file names with path separators are rejected`() { assertThrows(IllegalArgumentException::class.java) { context.openFileOutput("../escape.json", 0) } } + + @Test + fun `preferences are stored as files in filesDir`() { + context.getSharedPreferences("frc_1:341458593155:web:bf8e1aa37efe01f32d42b6_firebase_settings", 0) + .edit().putLong("last_fetch_time_in_millis", 1L).commit() + + val stored = File(filesDir, "shared_prefs").list()!!.single() + assertTrue(stored.endsWith(".json")) + assertTrue(':' !in stored) + } + + @Test + fun `any preferences name is accepted`() { + val prefs = context.getSharedPreferences("com.google.firebase.common.prefs:W0RFRkFVTFRd", 0) + + assertTrue(prefs.getBoolean("firebase_data_collection_default_enabled", true)) + } } diff --git a/src/test/kotlin/FirebaseTest.kt b/src/test/kotlin/FirebaseTest.kt index bc085b4..094a824 100644 --- a/src/test/kotlin/FirebaseTest.kt +++ b/src/test/kotlin/FirebaseTest.kt @@ -6,7 +6,10 @@ import com.google.firebase.FirebasePlatform import com.google.firebase.initialize import org.junit.After import org.junit.Before +import org.junit.Rule +import org.junit.rules.TemporaryFolder import java.io.File +import java.util.concurrent.ConcurrentHashMap abstract class FirebaseTest { @@ -25,13 +28,17 @@ abstract class FirebaseTest { Firebase.initialize(Application(), options) } + // Preferences and config files are written under getFilesDir(), so each test gets its own + @get:Rule + val folder = TemporaryFolder() + protected open fun log(message: String) = println(message) @Before fun beforeEach() { FirebasePlatform.initializeFirebasePlatform( object : FirebasePlatform() { - val storage = mutableMapOf() + val storage = ConcurrentHashMap() override fun store( key: String, @@ -48,7 +55,7 @@ abstract class FirebaseTest { override fun getDatabasePath(name: String) = File("./build/$name") - override fun getFilesDir() = File("./build/files") + override fun getFilesDir() = File(folder.root, "files") } ) } diff --git a/src/test/kotlin/PlatformSharedPreferencesTest.kt b/src/test/kotlin/PlatformSharedPreferencesTest.kt deleted file mode 100644 index 0d90fab..0000000 --- a/src/test/kotlin/PlatformSharedPreferencesTest.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Tests for the SharedPreferences that Context returns for Remote Config ("frc_*") and - * Installations ("com.google.android.gms.appid"), persisted through FirebasePlatform. - */ -import android.app.Application -import com.google.firebase.FirebasePlatform -import fakes.FakeFirebasePlatform -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import kotlin.concurrent.thread - -class PlatformSharedPreferencesTest { - - private val platform = FakeFirebasePlatform() - - private val context = Application() - - @Before - fun setUp() { - FirebasePlatform.initializeFirebasePlatform(platform) - } - - @Test - fun `values round-trip by type`() { - val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) - prefs.edit() - .putString("last_fetch_etag", "abc") - .putLong("last_fetch_time_in_millis", 42L) - .putInt("last_fetch_status", -1) - .commit() - - assertEquals("abc", prefs.getString("last_fetch_etag", null)) - assertEquals(42L, prefs.getLong("last_fetch_time_in_millis", 0L)) - assertEquals(-1, prefs.getInt("last_fetch_status", 0)) - assertEquals(7, prefs.getInt("missing", 7)) - assertTrue(prefs.contains("last_fetch_etag")) - assertFalse(prefs.contains("missing")) - } - - @Test - fun `putString null removes the key`() { - val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) - prefs.edit().putString("last_fetch_etag", "abc").commit() - prefs.edit().putString("last_fetch_etag", null).commit() - - assertFalse(prefs.contains("last_fetch_etag")) - assertTrue(prefs.all.isEmpty()) - } - - @Test - fun `files do not see each other's keys`() { - val first = context.getSharedPreferences("frc_first_firebase_settings", 0) - val second = context.getSharedPreferences("frc_second_firebase_settings", 0) - first.edit().putString("key", "first").commit() - - assertNull(second.getString("key", null)) - } - - @Test - fun `clear removes only this file's keys`() { - val first = context.getSharedPreferences("frc_first_firebase_settings", 0) - val second = context.getSharedPreferences("frc_second_firebase_settings", 0) - first.edit().putString("key", "first").commit() - second.edit().putString("key", "second").commit() - - first.edit().clear().commit() - - assertNull(first.getString("key", null)) - assertTrue(first.all.isEmpty()) - assertEquals("second", second.getString("key", null)) - assertTrue(platform.storage.keys.none { it.startsWith("frc_first_firebase_settings|") }) - } - - @Test - fun `values persist across instances`() { - context.getSharedPreferences("frc_app_firebase_settings", 0).edit().putLong("num_failed_fetches", 3L).commit() - - assertEquals(3L, Application().getSharedPreferences("frc_app_firebase_settings", 0).getLong("num_failed_fetches", 0L)) - } - - @Test - fun `installations preferences are platform-backed`() { - val prefs = context.getSharedPreferences("com.google.android.gms.appid", 0) - - assertNull(prefs.getString("|S|id", null)) - } - - @Test - fun `concurrent writes are all cleared`() { - val prefs = context.getSharedPreferences("frc_app_firebase_settings", 0) - (0 until 8).map { i -> - thread { repeat(50) { j -> prefs.edit().putInt("key-$i-$j", j).apply() } } - }.forEach { it.join() } - - assertEquals(400, prefs.all.size) - prefs.edit().clear().commit() - assertTrue(platform.storage.keys.none { it.startsWith("frc_app_firebase_settings|") }) - } - - @Test - fun `string sets round-trip`() { - val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) - prefs.edit().putStringSet("fire-core/20.4.2", setOf("2026-10-07")).commit() - - assertEquals(setOf("2026-10-07"), prefs.getStringSet("fire-core/20.4.2", null)) - assertEquals(setOf("default"), prefs.getStringSet("missing", setOf("default"))) - } - - @Test - fun `getAll returns string sets as sets`() { - val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) - prefs.edit() - .putString("last-used-date", "2026-10-07") - .putStringSet("fire-core/20.4.2", setOf("2026-10-06", "2026-10-07")) - .commit() - - assertEquals( - mapOf("last-used-date" to "2026-10-07", "fire-core/20.4.2" to setOf("2026-10-06", "2026-10-07")), - prefs.all - ) - } - - @Test - fun `remove deletes the key`() { - val prefs = context.getSharedPreferences("FirebaseHeartBeatW0RFRkFVTFRd", 0) - prefs.edit().putStringSet("fire-core/20.4.2", setOf("2026-10-07")).commit() - prefs.edit().remove("fire-core/20.4.2").commit() - - assertFalse(prefs.contains("fire-core/20.4.2")) - assertTrue(prefs.all.isEmpty()) - } - - @Test - fun `other preference files still reject unknown keys`() { - val prefs = context.getSharedPreferences("com.google.firebase.common.prefs:W0RFRkFVTFRd", 0) - - assertThrows(IllegalArgumentException::class.java) { prefs.getInt("unknown", 0) } - assertThrows(IllegalArgumentException::class.java) { prefs.edit().putInt("unknown", 0) } - assertThrows(IllegalArgumentException::class.java) { prefs.edit().clear() } - assertThrows(IllegalArgumentException::class.java) { prefs.getStringSet("unknown", null) } - assertThrows(IllegalArgumentException::class.java) { prefs.edit().putStringSet("unknown", null) } - assertThrows(IllegalArgumentException::class.java) { prefs.edit().remove("unknown") } - } -} diff --git a/src/test/kotlin/PreferencesFileTest.kt b/src/test/kotlin/PreferencesFileTest.kt new file mode 100644 index 0000000..6b7ccf9 --- /dev/null +++ b/src/test/kotlin/PreferencesFileTest.kt @@ -0,0 +1,225 @@ +/* + * Tests for PreferencesFile, the SharedPreferences that stores each preferences name as a JSON + * file under FirebasePlatform.getFilesDir()/shared_prefs. + */ +import android.content.PreferencesFile +import com.google.firebase.FirebasePlatform +import fakes.FakeFirebasePlatform +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.Collections +import java.util.concurrent.CountDownLatch +import kotlin.concurrent.thread + +class PreferencesFileTest { + + @get:Rule + val folder = TemporaryFolder() + + private val platform = FakeFirebasePlatform() + + private fun file(name: String) = File(folder.root, "shared_prefs/$name.json") + + private fun prefs(name: String = "frc_app_firebase_settings") = PreferencesFile.at(file(name)) + + @Before + fun setUp() { + FirebasePlatform.initializeFirebasePlatform(platform) + } + + @Test + fun `values round-trip with their types`() { + val prefs = prefs() + prefs.edit() + .putString("string", "abc") + .putInt("int", -1) + .putLong("long", 42L) + .putBoolean("boolean", true) + .putStringSet("stringSet", setOf("x", "y")) + .commit() + + assertEquals("abc", prefs.getString("string", null)) + assertEquals(-1, prefs.getInt("int", 0)) + assertEquals(42L, prefs.getLong("long", 0L)) + assertTrue(prefs.getBoolean("boolean", false)) + assertEquals(setOf("x", "y"), prefs.getStringSet("stringSet", null)) + assertEquals( + mapOf("string" to "abc", "int" to -1, "long" to 42L, "boolean" to true, "stringSet" to setOf("x", "y")), + prefs.all + ) + } + + @Test + fun `missing keys return defaults`() { + val prefs = prefs() + + assertEquals("default", prefs.getString("missing", "default")) + assertEquals(7, prefs.getInt("missing", 7)) + assertEquals(7L, prefs.getLong("missing", 7L)) + assertTrue(prefs.getBoolean("missing", true)) + assertEquals(setOf("default"), prefs.getStringSet("missing", setOf("default"))) + assertFalse(prefs.contains("missing")) + } + + @Test + fun `remove and putString null delete the key`() { + val prefs = prefs() + prefs.edit().putString("first", "a").putString("second", "b").commit() + + prefs.edit().remove("first").putString("second", null).commit() + + assertTrue(prefs.all.isEmpty()) + } + + @Test + fun `files do not see each other's keys`() { + prefs("first").edit().putString("key", "first").commit() + + assertNull(prefs("second").getString("key", null)) + } + + @Test + fun `clear affects only its own file`() { + prefs("first").edit().putString("key", "first").commit() + prefs("second").edit().putString("key", "second").commit() + + prefs("first").edit().clear().commit() + + assertTrue(prefs("first").all.isEmpty()) + assertEquals("second", prefs("second").getString("key", null)) + } + + @Test + fun `clear applies before the edits it is batched with`() { + val prefs = prefs() + prefs.edit().putString("old", "value").commit() + + prefs.edit().putString("kept", "value").clear().commit() + + assertEquals(mapOf("kept" to "value"), prefs.all) + } + + @Test + fun `edits are not visible before commit`() { + val prefs = prefs() + val edit = prefs.edit().putString("key", "value") + + assertFalse(prefs.contains("key")) + edit.commit() + assertTrue(prefs.contains("key")) + } + + @Test + fun `commit writes json and leaves no temporary files`() { + prefs().edit().putInt("num_failed_fetches", 3).commit() + + val stored = Json.parseToJsonElement(file("frc_app_firebase_settings").readText()).jsonObject + assertEquals(setOf("num_failed_fetches"), stored.keys) + assertEquals(listOf("frc_app_firebase_settings.json"), file("frc_app_firebase_settings").parentFile.list()!!.toList()) + } + + @Test + fun `existing file is loaded when first opened`() { + file("restored").parentFile.mkdirs() + file("restored").writeText( + """{"num_failed_fetches":{"int":3},"last_fetch_time_in_millis":{"long":42},"fire-core":{"stringSet":["2026-10-08"]}}""" + ) + + val prefs = prefs("restored") + + assertEquals(3, prefs.getInt("num_failed_fetches", 0)) + assertEquals(42L, prefs.getLong("last_fetch_time_in_millis", 0L)) + assertEquals(setOf("2026-10-08"), prefs.getStringSet("fire-core", null)) + } + + @Test + fun `missing file reads as empty`() { + val prefs = prefs("absent") + + assertTrue(prefs.all.isEmpty()) + assertFalse(file("absent").exists()) + } + + @Test + fun `corrupt file is logged, read as empty and overwritten`() { + file("corrupt").parentFile.mkdirs() + file("corrupt").writeText("not json") + + val prefs = prefs("corrupt") + + assertTrue(prefs.all.isEmpty()) + assertTrue(platform.logs.any { it.startsWith("PreferencesFile Ignoring unreadable preferences file") }) + prefs.edit().putString("key", "value").commit() + assertEquals(setOf("key"), Json.parseToJsonElement(file("corrupt").readText()).jsonObject.keys) + } + + @Test + fun `failed commit keeps previous values and returns false`() { + val prefs = prefs() + prefs.edit().putString("key", "before").commit() + val sharedPrefs = file("frc_app_firebase_settings").parentFile + sharedPrefs.setWritable(false) + try { + assertFalse(prefs.edit().putString("key", "after").commit()) + assertEquals("before", prefs.getString("key", null)) + assertTrue(platform.logs.any { it.startsWith("PreferencesFile Failed to write preferences file") }) + } finally { + sharedPrefs.setWritable(true) + } + } + + @Test + fun `concurrent commits all land in the file`() { + val prefs = prefs() + (0 until 8).map { i -> + thread { repeat(50) { j -> prefs.edit().putInt("key-$i-$j", j).commit() } } + }.forEach { it.join() } + + assertEquals(400, prefs.all.size) + assertEquals(400, Json.parseToJsonElement(file("frc_app_firebase_settings").readText()).jsonObject.size) + } + + @Test + fun `files first written concurrently in a new folder are all saved`() { + repeat(20) { round -> + val start = CountDownLatch(1) + val results = Collections.synchronizedList(mutableListOf()) + (0 until 8).map { i -> + val prefs = PreferencesFile.at(File(folder.root, "round-$round/shared_prefs/file-$i.json")) + thread { + start.await() + results.add(prefs.edit().putInt("key", i).commit()) + } + }.also { start.countDown() }.forEach { it.join() } + + assertEquals(List(8) { true }, results.toList()) + } + } + + @Test + fun `changing a returned string set does not change the stored value`() { + val prefs = prefs() + prefs.edit().putStringSet("dates", setOf("2026-10-08")).commit() + + (prefs.getStringSet("dates", null) as MutableSet).add("2026-10-09") + @Suppress("UNCHECKED_CAST") + (prefs.all["dates"] as MutableSet).add("2026-10-10") + + assertEquals(setOf("2026-10-08"), prefs.getStringSet("dates", null)) + } + + @Test + fun `same path returns the same instance`() { + assertSame(prefs("shared"), prefs("shared")) + } +} diff --git a/src/test/kotlin/fakes/FakeFirebasePlatform.kt b/src/test/kotlin/fakes/FakeFirebasePlatform.kt index a244d73..c9fa74c 100644 --- a/src/test/kotlin/fakes/FakeFirebasePlatform.kt +++ b/src/test/kotlin/fakes/FakeFirebasePlatform.kt @@ -2,13 +2,15 @@ package fakes import com.google.firebase.FirebasePlatform import java.io.File +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap /** * Fake used to store firebase data during testing. The [storage] is made purposefully public to allow for direct * access and modification if needed. */ class FakeFirebasePlatform( - val storage: MutableMap = mutableMapOf(), + val storage: MutableMap = ConcurrentHashMap(), databaseFolderPath: String = "./build/database/", filesFolderPath: String = "./build/files/" ) : FirebasePlatform() { @@ -17,13 +19,16 @@ class FakeFirebasePlatform( private val filesFolder = File(filesFolderPath) + // Captured instead of printed, so tests can assert on expected log output + val logs: MutableList = Collections.synchronizedList(mutableListOf()) + override fun store(key: String, value: String) { storage[key] = value } override fun retrieve(key: String) = storage[key] override fun clear(key: String) { storage.remove(key) } - override fun log(msg: String) = println(msg) + override fun log(msg: String) { logs.add(msg) } override fun getDatabasePath(name: String) = File(databaseFolder, name) From 51162f8400e0cab0ff519aba620fe06adf7d560e Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 00:08:05 +0300 Subject: [PATCH 19/33] Document file-based preferences, label AOSP ports, and await the Storage test request --- README.md | 9 ++++----- src/main/java/android/util/Base64DataException.java | 5 +++++ src/main/java/android/util/JsonReader.java | 5 +++++ src/main/java/android/util/JsonScope.java | 5 +++++ src/main/java/android/util/JsonToken.java | 5 +++++ src/main/java/android/util/MalformedJsonException.java | 5 +++++ src/main/java/com/android/internal/util/StringPool.java | 4 ++++ src/test/kotlin/FirebaseStorageTest.kt | 6 +++++- 8 files changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4b87e37..731db96 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,8 @@ Before you can use the SDK you need to call the `FirebasePlatform.initializeFire logging, and persistent storage for simple key value pairs. This is used by the various Firebase products, for example, to persist the signed-in user in Firebase Auth. -The Firebase products call these methods from background threads, so your implementation must be thread-safe. The keys are -arbitrary strings that can be several hundred characters long and contain characters such as spaces, `/`, `|` and `:`, so a -storage backend with restrictions on keys (for example a file per key, or `java.util.prefs.Preferences`) needs to map them first, -for example by hashing them. +Firebase Auth calls these methods from background threads, so your implementation must be thread-safe. The other Firebase +products keep their state in files under `getFilesDir()`, described below. Here's a simple example implementation in Kotlin that only persists in-memory: @@ -82,7 +80,8 @@ The `FirebasePlatform` interface also includes a `getFilesDir` method for you to open fun getFilesDir(): File = File("${System.getProperty("java.io.tmpdir")}${File.separatorChar}firebase-files") ``` -This is used by Remote Config and Installations to persist fetched configs and the installation ID. +This is used by Remote Config and Installations to persist fetched configs and the installation ID, and by all Firebase +products to store their preferences, as JSON files in a `shared_prefs` subfolder. #### Initialize the Firebase application diff --git a/src/main/java/android/util/Base64DataException.java b/src/main/java/android/util/Base64DataException.java index de12ee1..47ea24e 100644 --- a/src/main/java/android/util/Base64DataException.java +++ b/src/main/java/android/util/Base64DataException.java @@ -1,3 +1,8 @@ +/* + * Ported unchanged from AOSP (android14-release). Thrown by the android.util.Base64OutputStream + * shim, which firebase-common uses to encode heartbeat headers. + */ + /* * Copyright (C) 2011 The Android Open Source Project * diff --git a/src/main/java/android/util/JsonReader.java b/src/main/java/android/util/JsonReader.java index c75e238..038bcba 100644 --- a/src/main/java/android/util/JsonReader.java +++ b/src/main/java/android/util/JsonReader.java @@ -1,3 +1,8 @@ +/* + * Ported unchanged from AOSP (android14-release). Firebase Installations parses its REST + * responses with android.util.JsonReader. + */ + /* * Copyright (C) 2010 The Android Open Source Project * diff --git a/src/main/java/android/util/JsonScope.java b/src/main/java/android/util/JsonScope.java index ca534e9..553906d 100644 --- a/src/main/java/android/util/JsonScope.java +++ b/src/main/java/android/util/JsonScope.java @@ -1,3 +1,8 @@ +/* + * Ported unchanged from AOSP (android14-release). Part of the android.util.JsonReader port, + * which Firebase Installations uses to parse its REST responses. + */ + /* * Copyright (C) 2010 The Android Open Source Project * diff --git a/src/main/java/android/util/JsonToken.java b/src/main/java/android/util/JsonToken.java index 45bc6ca..5797658 100644 --- a/src/main/java/android/util/JsonToken.java +++ b/src/main/java/android/util/JsonToken.java @@ -1,3 +1,8 @@ +/* + * Ported unchanged from AOSP (android14-release). Part of the android.util.JsonReader port, + * which Firebase Installations uses to parse its REST responses. + */ + /* * Copyright (C) 2010 The Android Open Source Project * diff --git a/src/main/java/android/util/MalformedJsonException.java b/src/main/java/android/util/MalformedJsonException.java index 63c19ff..277d9f7 100644 --- a/src/main/java/android/util/MalformedJsonException.java +++ b/src/main/java/android/util/MalformedJsonException.java @@ -1,3 +1,8 @@ +/* + * Ported unchanged from AOSP (android14-release). Part of the android.util.JsonReader port, + * which Firebase Installations uses to parse its REST responses. + */ + /* * Copyright (C) 2011 The Android Open Source Project * diff --git a/src/main/java/com/android/internal/util/StringPool.java b/src/main/java/com/android/internal/util/StringPool.java index c5180a3..8e86fe9 100644 --- a/src/main/java/com/android/internal/util/StringPool.java +++ b/src/main/java/com/android/internal/util/StringPool.java @@ -1,3 +1,7 @@ +/* + * Ported unchanged from AOSP (android14-release). Used by the android.util.JsonReader port. + */ + /* * Copyright (C) 2010 The Android Open Source Project * diff --git a/src/test/kotlin/FirebaseStorageTest.kt b/src/test/kotlin/FirebaseStorageTest.kt index 332f46b..ae17410 100644 --- a/src/test/kotlin/FirebaseStorageTest.kt +++ b/src/test/kotlin/FirebaseStorageTest.kt @@ -2,6 +2,8 @@ import android.net.Uri import com.google.firebase.Firebase import com.google.firebase.storage.internal.Slashes import com.google.firebase.storage.storage +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Test @@ -31,12 +33,14 @@ class FirebaseStorageTest : FirebaseTest() { } @Test - fun `getting child reference`() { + fun `getting child reference`(): Unit = runTest { val storage = Firebase.storage(app) val reference = storage.reference val downloadRef = reference.child("mountains.jpg") val downloadUrl = downloadRef.downloadUrl Assert.assertNotNull(downloadUrl) + // Waits for the request so its logs stay within this test; the outcome is not under test + runCatching { downloadUrl.await() } } } From 2fc8afb844831ca2a16d1ca17288e3efe2667593 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 00:15:29 +0300 Subject: [PATCH 20/33] Correct storage documentation and warn about the temp-folder default --- README.md | 9 ++++++--- src/test/kotlin/HeartBeatTest.kt | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 731db96..1cb5347 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,10 @@ You can skip the above if you are using the SDK via the = ConcurrentHashMap(), - databaseFolderPath: String = "./build/database/", - filesFolderPath: String = "./build/files/" -) : FirebasePlatform() { - - private val databaseFolder = File(databaseFolderPath) - - private val filesFolder = File(filesFolderPath) - - // Captured instead of printed, so tests can assert on expected log output - val logs: MutableList = Collections.synchronizedList(mutableListOf()) - - override fun store(key: String, value: String) { storage[key] = value } - - override fun retrieve(key: String) = storage[key] - - override fun clear(key: String) { storage.remove(key) } - - override fun log(msg: String) { logs.add(msg) } - - override fun getDatabasePath(name: String) = File(databaseFolder, name) - - override fun getFilesDir() = filesFolder -} -``` - -- [ ] **Step 2: Write the failing `PreferencesFile` tests** - -Create `src/test/kotlin/PreferencesFileTest.kt`: - -```kotlin -/* - * Tests for PreferencesFile, the SharedPreferences that stores each preferences name as a JSON - * file under FirebasePlatform.getFilesDir()/shared_prefs. - */ -import android.content.PreferencesFile -import com.google.firebase.FirebasePlatform -import fakes.FakeFirebasePlatform -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.File -import kotlin.concurrent.thread - -class PreferencesFileTest { - - @get:Rule - val folder = TemporaryFolder() - - private val platform = FakeFirebasePlatform() - - private fun file(name: String) = File(folder.root, "shared_prefs/$name.json") - - private fun prefs(name: String = "frc_app_firebase_settings") = PreferencesFile.at(file(name)) - - @Before - fun setUp() { - FirebasePlatform.initializeFirebasePlatform(platform) - } - - @Test - fun `values round-trip with their types`() { - val prefs = prefs() - prefs.edit() - .putString("string", "abc") - .putInt("int", -1) - .putLong("long", 42L) - .putBoolean("boolean", true) - .putStringSet("stringSet", setOf("x", "y")) - .commit() - - assertEquals("abc", prefs.getString("string", null)) - assertEquals(-1, prefs.getInt("int", 0)) - assertEquals(42L, prefs.getLong("long", 0L)) - assertTrue(prefs.getBoolean("boolean", false)) - assertEquals(setOf("x", "y"), prefs.getStringSet("stringSet", null)) - assertEquals( - mapOf("string" to "abc", "int" to -1, "long" to 42L, "boolean" to true, "stringSet" to setOf("x", "y")), - prefs.all - ) - } - - @Test - fun `missing keys return defaults`() { - val prefs = prefs() - - assertEquals("default", prefs.getString("missing", "default")) - assertEquals(7, prefs.getInt("missing", 7)) - assertEquals(7L, prefs.getLong("missing", 7L)) - assertTrue(prefs.getBoolean("missing", true)) - assertEquals(setOf("default"), prefs.getStringSet("missing", setOf("default"))) - assertFalse(prefs.contains("missing")) - } - - @Test - fun `remove and putString null delete the key`() { - val prefs = prefs() - prefs.edit().putString("first", "a").putString("second", "b").commit() - - prefs.edit().remove("first").putString("second", null).commit() - - assertTrue(prefs.all.isEmpty()) - } - - @Test - fun `files do not see each other's keys`() { - prefs("first").edit().putString("key", "first").commit() - - assertNull(prefs("second").getString("key", null)) - } - - @Test - fun `clear affects only its own file`() { - prefs("first").edit().putString("key", "first").commit() - prefs("second").edit().putString("key", "second").commit() - - prefs("first").edit().clear().commit() - - assertTrue(prefs("first").all.isEmpty()) - assertEquals("second", prefs("second").getString("key", null)) - } - - @Test - fun `clear applies before the edits it is batched with`() { - val prefs = prefs() - prefs.edit().putString("old", "value").commit() - - prefs.edit().putString("kept", "value").clear().commit() - - assertEquals(mapOf("kept" to "value"), prefs.all) - } - - @Test - fun `edits are not visible before commit`() { - val prefs = prefs() - val edit = prefs.edit().putString("key", "value") - - assertFalse(prefs.contains("key")) - edit.commit() - assertTrue(prefs.contains("key")) - } - - @Test - fun `commit writes json and leaves no temporary files`() { - prefs().edit().putInt("num_failed_fetches", 3).commit() - - val stored = Json.parseToJsonElement(file("frc_app_firebase_settings").readText()).jsonObject - assertEquals(setOf("num_failed_fetches"), stored.keys) - assertEquals(listOf("frc_app_firebase_settings.json"), file("frc_app_firebase_settings").parentFile.list()!!.toList()) - } - - @Test - fun `existing file is loaded when first opened`() { - file("restored").parentFile.mkdirs() - file("restored").writeText( - """{"num_failed_fetches":{"int":3},"last_fetch_time_in_millis":{"long":42},"fire-core":{"stringSet":["2026-10-08"]}}""" - ) - - val prefs = prefs("restored") - - assertEquals(3, prefs.getInt("num_failed_fetches", 0)) - assertEquals(42L, prefs.getLong("last_fetch_time_in_millis", 0L)) - assertEquals(setOf("2026-10-08"), prefs.getStringSet("fire-core", null)) - } - - @Test - fun `missing file reads as empty`() { - val prefs = prefs("absent") - - assertTrue(prefs.all.isEmpty()) - assertFalse(file("absent").exists()) - } - - @Test - fun `corrupt file is logged, read as empty and overwritten`() { - file("corrupt").parentFile.mkdirs() - file("corrupt").writeText("not json") - - val prefs = prefs("corrupt") - - assertTrue(prefs.all.isEmpty()) - assertTrue(platform.logs.any { it.startsWith("PreferencesFile Ignoring unreadable preferences file") }) - prefs.edit().putString("key", "value").commit() - assertEquals(setOf("key"), Json.parseToJsonElement(file("corrupt").readText()).jsonObject.keys) - } - - @Test - fun `failed commit keeps previous values and returns false`() { - val prefs = prefs() - prefs.edit().putString("key", "before").commit() - val sharedPrefs = file("frc_app_firebase_settings").parentFile - sharedPrefs.setWritable(false) - try { - assertFalse(prefs.edit().putString("key", "after").commit()) - assertEquals("before", prefs.getString("key", null)) - assertTrue(platform.logs.any { it.startsWith("PreferencesFile Failed to write preferences file") }) - } finally { - sharedPrefs.setWritable(true) - } - } - - @Test - fun `concurrent commits all land in the file`() { - val prefs = prefs() - (0 until 8).map { i -> - thread { repeat(50) { j -> prefs.edit().putInt("key-$i-$j", j).commit() } } - }.forEach { it.join() } - - assertEquals(400, prefs.all.size) - assertEquals(400, Json.parseToJsonElement(file("frc_app_firebase_settings").readText()).jsonObject.size) - } - - @Test - fun `changing a returned string set does not change the stored value`() { - val prefs = prefs() - prefs.edit().putStringSet("dates", setOf("2026-10-08")).commit() - - (prefs.getStringSet("dates", null) as MutableSet).add("2026-10-09") - (prefs.all["dates"] as MutableSet).add("2026-10-10") - - assertEquals(setOf("2026-10-08"), prefs.getStringSet("dates", null)) - } - - @Test - fun `same path returns the same instance`() { - assertSame(prefs("shared"), prefs("shared")) - } -} -``` - -- [ ] **Step 3: Write the failing `Context` tests** - -Append to `ContextFilesTest` (before its closing brace): - -```kotlin - @Test - fun `preferences are stored as files in filesDir`() { - context.getSharedPreferences("frc_1:341458593155:web:bf8e1aa37efe01f32d42b6_firebase_settings", 0) - .edit().putLong("last_fetch_time_in_millis", 1L).commit() - - val stored = File(filesDir, "shared_prefs").list()!!.single() - assertTrue(stored.endsWith(".json")) - assertTrue(':' !in stored) - } - - @Test - fun `any preferences name is accepted`() { - val prefs = context.getSharedPreferences("com.google.firebase.common.prefs:W0RFRkFVTFRd", 0) - - assertTrue(prefs.getBoolean("firebase_data_collection_default_enabled", true)) - } -``` - -- [ ] **Step 4: Run the tests and confirm they fail** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew test --tests 'PreferencesFileTest' --tests 'ContextFilesTest'` -Expected: test compilation fails with unresolved `PreferencesFile`, `putBoolean`, and `getBoolean`. - -- [ ] **Step 5: Add the boolean methods to the interface** - -In `src/main/java/android/content/SharedPreferences.java`, after `int getInt(String key, int defValue);` add `boolean getBoolean(String key, boolean defValue);`, and in `Editor` after `Editor putInt(String key, int value);` add `Editor putBoolean(String key, boolean value);`. - -- [ ] **Step 6: Create `PreferencesFile`** - -Create `src/main/java/android/content/PreferencesFile.kt`: - -```kotlin -/* - * SharedPreferences stored as one JSON file per preferences name under - * FirebasePlatform.getFilesDir()/shared_prefs, as Android stores them in the app's data folder. - * Keeping preferences beside the other files Firebase writes to getFilesDir() means they are kept - * or lost together. Every caller of a file path shares one instance, which holds the values in - * memory and replaces the whole file atomically on commit. - */ -package android.content - -import android.util.Log -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.add -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray -import java.io.File -import java.io.IOException -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.util.concurrent.ConcurrentHashMap - -internal class PreferencesFile private constructor(private val file: File) : SharedPreferences { - - // Loaded from the file on first use; read and replaced only while holding this instance's lock - private var loadedValues: Map? = null - - override fun contains(key: String): Boolean = values().containsKey(key) - - override fun getString(key: String, defaultValue: String?): String? = values()[key] as String? ?: defaultValue - - override fun getInt(key: String, defValue: Int): Int = values()[key] as Int? ?: defValue - - override fun getLong(key: String, defValue: Long): Long = values()[key] as Long? ?: defValue - - override fun getBoolean(key: String, defValue: Boolean): Boolean = values()[key] as Boolean? ?: defValue - - // Returns a copy, so callers cannot change the stored set without an edit - override fun getStringSet(key: String, defValues: Set?): Set? = - (values()[key] as Set<*>?)?.mapTo(HashSet()) { it as String } ?: defValues - - override fun getAll(): Map = - values().mapValues { (_, value) -> if (value is Set<*>) HashSet(value) else value } - - override fun edit(): SharedPreferences.Editor = Edit() - - private fun values(): Map = synchronized(this) { - loadedValues ?: read().also { loadedValues = it } - } - - private fun read(): Map { - if (!file.exists()) return emptyMap() - return try { - Json.parseToJsonElement(file.readText()).jsonObject.mapValues { (_, value) -> decode(value) } - } catch (e: Exception) { - // Unreadable content is discarded; the next commit replaces the file - Log.w(TAG, "Ignoring unreadable preferences file $file", e) - emptyMap() - } - } - - private fun save(changes: Map, clear: Boolean): Boolean { - synchronized(this) { - val updated = if (clear) mutableMapOf() else values().toMutableMap() - changes.forEach { (key, value) -> if (value == null) updated.remove(key) else updated[key] = value } - try { - write(updated) - } catch (e: IOException) { - Log.e(TAG, "Failed to write preferences file $file", e) - return false - } - loadedValues = updated - return true - } - } - - // Writes a temporary file and moves it over the target, so a failed write leaves the previous file intact - private fun write(values: Map) { - val folder = file.parentFile - if (!folder.isDirectory && !folder.mkdirs()) throw IOException("Cannot create $folder") - val temporary = File.createTempFile(file.name, ".tmp", folder) - try { - temporary.writeText(JsonObject(values.mapValues { (_, value) -> encode(value) }).toString()) - Files.move(temporary.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) - } finally { - temporary.delete() - } - } - - // Collects changes until commit() or apply(), as Android's editor does - private inner class Edit : SharedPreferences.Editor { - - // A null value removes the key - private val changes = mutableMapOf() - - private var clearRequested = false - - override fun putString(key: String, value: String?) = change(key, value) - - override fun putInt(key: String, value: Int) = change(key, value) - - override fun putLong(key: String, value: Long) = change(key, value) - - override fun putBoolean(key: String, value: Boolean) = change(key, value) - - override fun putStringSet(key: String, values: Set?) = change(key, values?.toHashSet()) - - override fun remove(key: String) = change(key, null) - - override fun clear(): SharedPreferences.Editor = synchronized(this) { - clearRequested = true - this - } - - override fun commit(): Boolean = synchronized(this) { - save(changes.toMap(), clearRequested).also { - changes.clear() - clearRequested = false - } - } - - override fun apply() { - commit() - } - - private fun change(key: String, value: Any?): SharedPreferences.Editor = synchronized(this) { - changes[key] = value - this - } - } - - companion object { - private const val TAG = "PreferencesFile" - - private val instances = ConcurrentHashMap() - - fun at(file: File): PreferencesFile = instances.computeIfAbsent(file.absoluteFile) { PreferencesFile(it) } - - // Each value is a one-entry object naming its type, e.g. {"int": 3}, so reads restore the type - private fun encode(value: Any): JsonElement = buildJsonObject { - when (value) { - is String -> put("string", value) - is Int -> put("int", value) - is Long -> put("long", value) - is Boolean -> put("boolean", value) - is Set<*> -> putJsonArray("stringSet") { value.forEach { add(it as String) } } - else -> throw IllegalArgumentException("Unsupported preference value $value") - } - } - - private fun decode(element: JsonElement): Any { - val (type, value) = element.jsonObject.entries.single() - return when (type) { - "string" -> value.jsonPrimitive.content - "int" -> value.jsonPrimitive.int - "long" -> value.jsonPrimitive.long - "boolean" -> value.jsonPrimitive.boolean - "stringSet" -> value.jsonArray.mapTo(HashSet()) { it.jsonPrimitive.content } - else -> throw IllegalArgumentException("Unknown preference type $type") - } - } - } -} -``` - -- [ ] **Step 7: Return `PreferencesFile` for every name and delete the strict whitelist** - -In `src/main/java/android/content/Context.kt`: - -- Replace the whole `getSharedPreferences` function (the routing `if` and the anonymous strict `SharedPreferences`) with: - -```kotlin - fun getSharedPreferences(name: String, mode: Int): SharedPreferences = - PreferencesFile.at(File(File(filesDir, "shared_prefs"), "${encodeFileName(name)}.json")) -``` - -- Replace `fileStreamPath` and its comment with: - -```kotlin - private fun fileStreamPath(name: String): File { - require('/' !in name && File.separatorChar !in name) { "File $name contains a path separator" } - return File(filesDir, encodeFileName(name)) - } - - // URL-encodes the name so characters such as ':' in Firebase app IDs are valid on every OS - private fun encodeFileName(name: String): String = URLEncoder.encode(name, Charsets.UTF_8) -``` - -- Remove the now-unused import `android.content.SharedPreferences.Editor`. - -- [ ] **Step 8: Delete the `FirebasePlatform`-backed preferences** - -```bash -git rm src/main/java/android/content/PlatformSharedPreferences.kt src/test/kotlin/PlatformSharedPreferencesTest.kt -``` - -- [ ] **Step 9: Isolate files per test in `FirebaseTest`** - -In `src/test/kotlin/FirebaseTest.kt`: add imports `org.junit.Rule`, `org.junit.rules.TemporaryFolder`, `java.util.concurrent.ConcurrentHashMap`; add before `log`: - -```kotlin - // Preferences and config files are written under getFilesDir(), so each test gets its own - @get:Rule - val folder = TemporaryFolder() -``` - -change `val storage = mutableMapOf()` to `val storage = ConcurrentHashMap()`, and change `override fun getFilesDir() = File("./build/files")` to `override fun getFilesDir() = File(folder.root, "files")`. - -- [ ] **Step 10: Run the tests and confirm they pass** - -Run: `JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr ./gradlew cleanTest test --tests 'PreferencesFileTest' --tests 'ContextFilesTest' --tests 'HeartBeatTest' --tests 'FirebaseRemoteConfigTest' --tests 'JsonReaderTest' --tests 'DateUtilsTest' ktlintCheck` -Expected: PreferencesFileTest 15, ContextFilesTest 9, HeartBeatTest 1, FirebaseRemoteConfigTest 9 (2 skipped), JsonReaderTest 1, DateUtilsTest 2 pass; ktlint clean; `` of `TEST-PreferencesFileTest.xml`, `TEST-ContextFilesTest.xml` and `TEST-FirebaseRemoteConfigTest.xml` empty. - -- [ ] **Step 11: Commit** - -```bash -git add src/main/java/android/content/PreferencesFile.kt src/main/java/android/content/SharedPreferences.java src/main/java/android/content/Context.kt src/test/kotlin/fakes/FakeFirebasePlatform.kt src/test/kotlin/FirebaseTest.kt src/test/kotlin/ContextFilesTest.kt src/test/kotlin/PreferencesFileTest.kt -git commit -m "Store preferences as JSON files in getFilesDir" -``` - ---- - -### Task 2: Documentation, AOSP headers, and the Storage test - -**Files:** -- Modify: `README.md` -- Modify: `src/main/java/android/util/JsonReader.java`, `JsonScope.java`, `JsonToken.java`, `MalformedJsonException.java`, `Base64DataException.java`, `src/main/java/com/android/internal/util/StringPool.java` -- Modify: `src/test/kotlin/FirebaseStorageTest.kt` - -**Interfaces:** -- Consumes: Task 1's file-backed preferences (the README describes them). - -- [ ] **Step 1: Add header comments to the AOSP ports** - -Insert as the first lines of each file, above the existing license comment: - -`JsonReader.java`: -```java -/* - * Ported unchanged from AOSP (android14-release). Firebase Installations parses its REST - * responses with android.util.JsonReader. - */ -``` - -`JsonScope.java`, `JsonToken.java`, `MalformedJsonException.java`: -```java -/* - * Ported unchanged from AOSP (android14-release). Part of the android.util.JsonReader port, - * which Firebase Installations uses to parse its REST responses. - */ -``` - -`com/android/internal/util/StringPool.java`: -```java -/* - * Ported unchanged from AOSP (android14-release). Used by the android.util.JsonReader port. - */ -``` - -`Base64DataException.java`: -```java -/* - * Ported unchanged from AOSP (android14-release). Thrown by the android.util.Base64OutputStream - * shim, which firebase-common uses to encode heartbeat headers. - */ -``` - -- [ ] **Step 2: Update the README** - -Replace the paragraph starting "The Firebase products call these methods from background threads" with: - -```markdown -Firebase Auth calls these methods from background threads, so your implementation must be thread-safe. The other Firebase -products keep their state in files under `getFilesDir()`, described below. -``` - -Replace "This is used by Remote Config and Installations to persist fetched configs and the installation ID." with: - -```markdown -This is used by Remote Config and Installations to persist fetched configs and the installation ID, and by all Firebase -products to store their preferences, as JSON files in a `shared_prefs` subfolder. -``` - -- [ ] **Step 3: Make the Storage test wait for its request** - -In `src/test/kotlin/FirebaseStorageTest.kt`, add imports `kotlinx.coroutines.tasks.await` and `kotlinx.coroutines.test.runTest`, and change `getting child reference` to: - -```kotlin - @Test - fun `getting child reference`(): Unit = runTest { - val storage = Firebase.storage(app) - val reference = storage.reference - val downloadRef = reference.child("mountains.jpg") - val downloadUrl = downloadRef.downloadUrl - - Assert.assertNotNull(downloadUrl) - // Waits for the request so its logs stay within this test; the outcome is not under test - runCatching { downloadUrl.await() } - } -``` - -- [ ] **Step 4: Run the full suite the way CI does** - -Run: -```bash -JAVA_HOME=~/.sdkman/candidates/java/17.0.14-jbr firebase emulators:exec --project my-firebase-project --import=src/test/resources/firebase_data './gradlew cleanTest build' -cat build/test-results/test/*.xml | grep -oE 'testsuite name="[^"]*" tests="[0-9]+" skipped="[0-9]+" failures="[0-9]+" errors="[0-9]+"' -``` -Expected: `BUILD SUCCESSFUL`; 49 tests (12 baseline, ContextFilesTest 9, PreferencesFileTest 15, JsonReaderTest 1, DateUtilsTest 2, HeartBeatTest 1, FirebaseRemoteConfigTest 9), 2 skipped, 0 failures. - -- [ ] **Step 5: Check output is clean** - -Read every ``/`` block in `build/test-results/test/*.xml`. Expected: Storage `NetworkRequest`/`StorageUtil` lines appear only in `TEST-FirebaseStorageTest.xml`; `FirebaseFirestoreTest`'s Play-services and SQLite lines are pre-existing (present on master 9f3a1b8); nothing else. Report any other output to Angelos. - -- [ ] **Step 6: Commit** - -```bash -git add README.md src/main/java/android/util/JsonReader.java src/main/java/android/util/JsonScope.java src/main/java/android/util/JsonToken.java src/main/java/android/util/MalformedJsonException.java src/main/java/android/util/Base64DataException.java src/main/java/com/android/internal/util/StringPool.java src/test/kotlin/FirebaseStorageTest.kt -git commit -m "Document file-based preferences, label AOSP ports, and await the Storage test request" -``` diff --git a/docs/superpowers/specs/2026-10-07-preferences-storage-design.md b/docs/superpowers/specs/2026-10-07-preferences-storage-design.md deleted file mode 100644 index aa2fe67..0000000 --- a/docs/superpowers/specs/2026-10-07-preferences-storage-design.md +++ /dev/null @@ -1,99 +0,0 @@ -# Preferences stored as files — design - -Date: 2026-10-07 -Branch: `feature/remote-config` (PR #70) - -## Goal - -Fix four findings from the `/code-review xhigh` of PR #70: - -1. **Remote Config stuck on defaults.** Remote Config keeps its fetch - metadata, including the ETag, in preferences stored through - `FirebasePlatform`, and its config JSON in `getFilesDir()`, which defaults - to the temp folder. When the OS cleans the temp folder, the next fetch - sends the stale ETag, the server answers `NO_CHANGE`, nothing is cached, - and the app serves defaults until the template changes. -2. **Flaky tests.** The test stores are plain maps that background threads now - write to, and logs from work that outlives a test can fail the next one. -3. **AOSP ports lack the header comment** that every new file needs. -4. **Firebase-internal preferences burden `FirebasePlatform` implementers** - with long keys and background writes, and preference files are routed by - hard-coded name prefixes on top of a strict whitelist. - -## Approach - -Store each preferences file as one JSON file under -`getFilesDir()/shared_prefs/`, as Android does. Preferences, Remote Config's -config files and the Installations file then share one folder. They are kept -or lost together, so they cannot drift apart (finding 1). Firebase-internal -keys never reach `FirebasePlatform` (finding 4). Every preferences name -uses this storage, and the strict whitelist is deleted. - -Angelos approved replacing `PlatformSharedPreferences` (written on this -branch, unreleased) and deleting the strict whitelist. - -Auth is unaffected: it writes the signed-in user directly with -`FirebasePlatform.store` (`FirebaseAuth.kt`), not through preferences. - -## Components - -| Change | File | Purpose | -|---|---|---| -| `PreferencesFile` replaces `PlatformSharedPreferences` | `src/main/java/android/content/PreferencesFile.kt` | One instance per file path, shared through a `ConcurrentHashMap`. Loads `getFilesDir()/shared_prefs/.json` on first use, keeps the values in memory, and guards them with a per-file lock. | -| Typed values | same | The JSON records each value's type (string, int, long, boolean, string set), so `getAll()` returns the stored types. | -| Batched `Editor` | same | Edits collect until `commit()` or `apply()`. A requested `clear()` applies first, then puts and removes, as on Android. The file is written to a temp file in the same folder and moved over the target with `Files.move(ATOMIC_MOVE, REPLACE_EXISTING)`. `apply()` writes synchronously. | -| Missing or corrupt file | same | A missing file reads as empty. A corrupt file is logged and read as empty; the next commit overwrites it. | -| `getSharedPreferences` | `src/main/java/android/content/Context.kt` | Returns the `PreferencesFile` for every name. The strict whitelist object is deleted, including its unreachable `FIREBASE_USER`, `fire-global`, `last-used-date`, and `\|T\|` branches. | -| `getBoolean` / `putBoolean` | `src/main/java/android/content/SharedPreferences.java` | `firebase-common`'s data-collection setting calls them. No `getFloat`: nothing calls it. | -| README | `README.md` | Drop the long-key warning. Keep "must be thread-safe" and the `ConcurrentHashMap` example, since Auth's token refresh writes from network threads. State that preferences live under `getFilesDir()`. | -| AOSP headers | `JsonReader.java`, `JsonScope.java`, `JsonToken.java`, `MalformedJsonException.java`, `Base64DataException.java`, `com/android/internal/util/StringPool.java` | A short comment above the license header: a verbatim AOSP port, and which library needs it. | - -Unchanged: `getFilesDir()`'s default (temp folder, like `getDatabasePath`) and -the URL-encoding of file names. - -Heartbeat values that earlier versions wrote to users' stores under the bare -keys `fire-global` and `last-used-date` stay there, unused. No migration: -nothing else was persisted through these preference files before this branch. - -## Testing - -TDD throughout. `PreferencesFileTest` replaces `PlatformSharedPreferencesTest` -and uses a fresh temp folder per test: - -- Values round-trip and `getAll()` returns them typed (string, int, long, - boolean, string set). -- Two files do not see each other's keys; `clear()` affects only its own file. -- Batching: `edit().putString(k, v).clear().commit()` keeps `k`. -- `commit()` writes valid JSON to `shared_prefs/.json` and leaves no - temp files. -- A file already on disk is loaded when first opened. With the previous test, - this covers persistence across a restart without test hooks in production - code. -- Eight threads committing concurrently all land in the file. -- A missing file reads as empty; a corrupt file is logged, reads as empty, and - is overwritten by the next commit. - -`HeartBeatTest`, `ContextFilesTest`, and `FirebaseRemoteConfigTest` stay. - -Flaky-test fixes: - -- `FirebaseTest` gives each test its own temp `getFilesDir()`, since - preferences are now on disk. `FirebaseTest` and `FakeFirebasePlatform` use a - `ConcurrentHashMap` store. -- `FirebaseStorageTest` waits for its `downloadUrl` task to finish without - asserting the outcome, so its logs stay within that test. - -Verification: the full suite inside the Auth emulator, plus `ktlintCheck`, -with clean output. - -## Out of scope - -- A persistent per-user default for `getFilesDir()`. -- Windows `File.renameTo` in Installations' own `PersistedInstallation` - (documented in the PR). -- Realtime updates; they get their own spec. - -## Design documents - -This spec and its plan are committed on the branch while work is in progress -and removed in one commit before pushing, so the PR stays code-only. From df5520d829a502cf8fabadb55a3a8d3d3ecb9a90 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 00:24:31 +0300 Subject: [PATCH 22/33] Keep new preference values in memory when a write fails, as Android does --- src/main/java/android/content/PreferencesFile.kt | 3 ++- src/test/kotlin/PreferencesFileTest.kt | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/android/content/PreferencesFile.kt b/src/main/java/android/content/PreferencesFile.kt index 5cc480f..9428c3a 100644 --- a/src/main/java/android/content/PreferencesFile.kt +++ b/src/main/java/android/content/PreferencesFile.kt @@ -70,13 +70,14 @@ internal class PreferencesFile private constructor(private val file: File) : Sha synchronized(this) { val updated = if (clear) mutableMapOf() else values().toMutableMap() changes.forEach { (key, value) -> if (value == null) updated.remove(key) else updated[key] = value } + // As on Android, memory is updated first, so the new values stay readable if the write fails + loadedValues = updated try { write(updated) } catch (e: IOException) { Log.e(TAG, "Failed to write preferences file $file", e) return false } - loadedValues = updated return true } } diff --git a/src/test/kotlin/PreferencesFileTest.kt b/src/test/kotlin/PreferencesFileTest.kt index 6b7ccf9..dc05f81 100644 --- a/src/test/kotlin/PreferencesFileTest.kt +++ b/src/test/kotlin/PreferencesFileTest.kt @@ -164,14 +164,15 @@ class PreferencesFileTest { } @Test - fun `failed commit keeps previous values and returns false`() { + fun `failed commit keeps the new values in memory and returns false`() { val prefs = prefs() prefs.edit().putString("key", "before").commit() val sharedPrefs = file("frc_app_firebase_settings").parentFile sharedPrefs.setWritable(false) try { assertFalse(prefs.edit().putString("key", "after").commit()) - assertEquals("before", prefs.getString("key", null)) + // As on Android, memory is updated even when the file cannot be written + assertEquals("after", prefs.getString("key", null)) assertTrue(platform.logs.any { it.startsWith("PreferencesFile Failed to write preferences file") }) } finally { sharedPrefs.setWritable(true) From 25cffac803c3dc2d343b436e9be37cf9d322af8a Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 00:24:58 +0300 Subject: [PATCH 23/33] Sync preference files to disk and skip writes for commits that change nothing --- src/main/java/android/content/PreferencesFile.kt | 10 ++++++++-- src/test/kotlin/PreferencesFileTest.kt | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/main/java/android/content/PreferencesFile.kt b/src/main/java/android/content/PreferencesFile.kt index 9428c3a..fd69fb7 100644 --- a/src/main/java/android/content/PreferencesFile.kt +++ b/src/main/java/android/content/PreferencesFile.kt @@ -22,6 +22,7 @@ import kotlinx.serialization.json.long import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import java.io.File +import java.io.FileOutputStream import java.io.IOException import java.nio.file.Files import java.nio.file.StandardCopyOption @@ -70,6 +71,7 @@ internal class PreferencesFile private constructor(private val file: File) : Sha synchronized(this) { val updated = if (clear) mutableMapOf() else values().toMutableMap() changes.forEach { (key, value) -> if (value == null) updated.remove(key) else updated[key] = value } + if (updated == values()) return true // As on Android, memory is updated first, so the new values stay readable if the write fails loadedValues = updated try { @@ -82,14 +84,18 @@ internal class PreferencesFile private constructor(private val file: File) : Sha } } - // Writes a temporary file and moves it over the target, so a failed write leaves the previous file intact + // Writes a temporary file and moves it over the target, so a failed write leaves the previous file intact. + // The temporary file is synced to disk first, so a power loss cannot leave an empty file behind. private fun write(values: Map) { val folder = file.parentFile // Succeeds when another file's write has just created the folder Files.createDirectories(folder.toPath()) val temporary = File.createTempFile(file.name, ".tmp", folder) try { - temporary.writeText(JsonObject(values.mapValues { (_, value) -> encode(value) }).toString()) + FileOutputStream(temporary).use { output -> + output.write(JsonObject(values.mapValues { (_, value) -> encode(value) }).toString().toByteArray()) + output.fd.sync() + } Files.move(temporary.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) } finally { temporary.delete() diff --git a/src/test/kotlin/PreferencesFileTest.kt b/src/test/kotlin/PreferencesFileTest.kt index dc05f81..4567beb 100644 --- a/src/test/kotlin/PreferencesFileTest.kt +++ b/src/test/kotlin/PreferencesFileTest.kt @@ -142,6 +142,16 @@ class PreferencesFileTest { assertEquals(setOf("2026-10-08"), prefs.getStringSet("fire-core", null)) } + @Test + fun `commits that change nothing do not write the file`() { + val prefs = prefs("unchanged") + + assertTrue(prefs.edit().commit()) + assertTrue(prefs.edit().clear().remove("missing").commit()) + + assertFalse(file("unchanged").exists()) + } + @Test fun `missing file reads as empty`() { val prefs = prefs("absent") From 5f0b0449b309a7ff42ca2ca6c2e629f57fab9c57 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 00:25:26 +0300 Subject: [PATCH 24/33] Skip the failed-write test where folders stay writable, and await the Storage request without swallowing cancellation --- src/test/kotlin/FirebaseStorageTest.kt | 3 ++- src/test/kotlin/PreferencesFileTest.kt | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/FirebaseStorageTest.kt b/src/test/kotlin/FirebaseStorageTest.kt index ae17410..0e0c599 100644 --- a/src/test/kotlin/FirebaseStorageTest.kt +++ b/src/test/kotlin/FirebaseStorageTest.kt @@ -1,4 +1,5 @@ import android.net.Uri +import com.google.android.gms.tasks.Tasks import com.google.firebase.Firebase import com.google.firebase.storage.internal.Slashes import com.google.firebase.storage.storage @@ -41,6 +42,6 @@ class FirebaseStorageTest : FirebaseTest() { Assert.assertNotNull(downloadUrl) // Waits for the request so its logs stay within this test; the outcome is not under test - runCatching { downloadUrl.await() } + Tasks.whenAllComplete(downloadUrl).await() } } diff --git a/src/test/kotlin/PreferencesFileTest.kt b/src/test/kotlin/PreferencesFileTest.kt index 4567beb..b0eace1 100644 --- a/src/test/kotlin/PreferencesFileTest.kt +++ b/src/test/kotlin/PreferencesFileTest.kt @@ -12,6 +12,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue +import org.junit.Assume.assumeFalse import org.junit.Before import org.junit.Rule import org.junit.Test @@ -32,6 +33,11 @@ class PreferencesFileTest { private fun prefs(name: String = "frc_app_firebase_settings") = PreferencesFile.at(file(name)) + private fun canCreateFileIn(folder: File): Boolean { + val probe = File(folder, "probe") + return runCatching { probe.createNewFile() }.getOrDefault(false).also { created -> if (created) probe.delete() } + } + @Before fun setUp() { FirebasePlatform.initializeFirebasePlatform(platform) @@ -180,6 +186,8 @@ class PreferencesFileTest { val sharedPrefs = file("frc_app_firebase_settings").parentFile sharedPrefs.setWritable(false) try { + // Root, and Windows, can still create files in a folder marked read-only + assumeFalse("folder is still writable", canCreateFileIn(sharedPrefs)) assertFalse(prefs.edit().putString("key", "after").commit()) // As on Android, memory is updated even when the file cannot be written assertEquals("after", prefs.getString("key", null)) From a44711e53fb081d7b6d1e0944d8fe68f8942e2f7 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 00:37:13 +0300 Subject: [PATCH 25/33] Create the database folder on demand, pin the default storage folders in tests, and keep FirebaseAppTest data in its temporary folder --- src/main/java/android/content/Context.kt | 4 +++- src/test/kotlin/ContextFilesTest.kt | 9 +++++++ src/test/kotlin/FirebaseAppTest.kt | 6 +++++ src/test/kotlin/FirebasePlatformTest.kt | 30 ++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/FirebasePlatformTest.kt diff --git a/src/main/java/android/content/Context.kt b/src/main/java/android/content/Context.kt index c031254..c7a2a06 100644 --- a/src/main/java/android/content/Context.kt +++ b/src/main/java/android/content/Context.kt @@ -66,7 +66,9 @@ open class Context { return File(System.getProperty("java.io.tmpdir")) } - fun getDatabasePath(name: String): File = FirebasePlatform.firebasePlatform.getDatabasePath(name) + // Creates the folder, because the SQLite driver cannot create a database in a missing folder + fun getDatabasePath(name: String): File = + FirebasePlatform.firebasePlatform.getDatabasePath(name).apply { parentFile?.mkdirs() } val filesDir: File get() = FirebasePlatform.firebasePlatform.getFilesDir().apply { mkdirs() } diff --git a/src/test/kotlin/ContextFilesTest.kt b/src/test/kotlin/ContextFilesTest.kt index c188388..bcb4930 100644 --- a/src/test/kotlin/ContextFilesTest.kt +++ b/src/test/kotlin/ContextFilesTest.kt @@ -88,6 +88,15 @@ class ContextFilesTest { assertTrue(':' !in stored) } + @Test + fun `getDatabasePath creates its folder`() { + val databases = File(folder.root, "databases") + FirebasePlatform.initializeFirebasePlatform(FakeFirebasePlatform(databaseFolderPath = databases.path)) + + assertEquals(File(databases, "firestore.db"), context.getDatabasePath("firestore.db")) + assertTrue(databases.isDirectory) + } + @Test fun `any preferences name is accepted`() { val prefs = context.getSharedPreferences("com.google.firebase.common.prefs:W0RFRkFVTFRd", 0) diff --git a/src/test/kotlin/FirebaseAppTest.kt b/src/test/kotlin/FirebaseAppTest.kt index 8d64303..83b6e10 100644 --- a/src/test/kotlin/FirebaseAppTest.kt +++ b/src/test/kotlin/FirebaseAppTest.kt @@ -4,6 +4,7 @@ import com.google.firebase.FirebaseOptions import com.google.firebase.FirebasePlatform import com.google.firebase.initialize import org.junit.Test +import java.io.File class FirebaseAppTest : FirebaseTest() { @Test @@ -24,6 +25,11 @@ class FirebaseAppTest : FirebaseTest() { } override fun log(msg: String) = println(msg) + + // Keeps Firebase's data in this test's temporary folder + override fun getDatabasePath(name: String) = File(folder.root, name) + + override fun getFilesDir() = File(folder.root, "files") } ) val options = diff --git a/src/test/kotlin/FirebasePlatformTest.kt b/src/test/kotlin/FirebasePlatformTest.kt new file mode 100644 index 0000000..ca9002d --- /dev/null +++ b/src/test/kotlin/FirebasePlatformTest.kt @@ -0,0 +1,30 @@ +/* + * Tests for the default folders FirebasePlatform gives Firebase for its databases and files, which + * apps override to choose persistent folders of their own. + */ +import com.google.firebase.FirebasePlatform +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class FirebasePlatformTest { + + private val platform = object : FirebasePlatform() { + override fun store(key: String, value: String) {} + override fun retrieve(key: String): String? = null + override fun clear(key: String) {} + override fun log(msg: String) {} + } + + private val temp = System.getProperty("java.io.tmpdir") + + @Test + fun `databases default to the temp folder`() { + assertEquals(File("$temp${File.separatorChar}firestore.db"), platform.getDatabasePath("firestore.db")) + } + + @Test + fun `files default to firebase-files in the temp folder`() { + assertEquals(File("$temp${File.separatorChar}firebase-files"), platform.getFilesDir()) + } +} From 53ab563f43db6fddea760f688f95a0728d3ed3fd Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 10:54:25 +0300 Subject: [PATCH 26/33] Give a plain Context an Application as its application context, so Firebase initializes with it --- src/main/java/android/content/Context.kt | 6 +++++- src/test/kotlin/FirebaseAppTest.kt | 9 +++++++++ src/test/kotlin/FirebaseTest.kt | 22 +++++++++++----------- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/main/java/android/content/Context.kt b/src/main/java/android/content/Context.kt index c7a2a06..a343ed4 100644 --- a/src/main/java/android/content/Context.kt +++ b/src/main/java/android/content/Context.kt @@ -1,5 +1,6 @@ package android.content +import android.app.Application import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.content.res.Resources @@ -15,8 +16,11 @@ import java.net.URLEncoder open class Context { + // Firebase casts the application context to Application, so a plain Context provides one val applicationContext: Context - get() = this + get() = this as? Application ?: application + + private val application: Application by lazy { Application() } val mainLooper: Looper get() = Looper.getMainLooper() diff --git a/src/test/kotlin/FirebaseAppTest.kt b/src/test/kotlin/FirebaseAppTest.kt index 83b6e10..e525efa 100644 --- a/src/test/kotlin/FirebaseAppTest.kt +++ b/src/test/kotlin/FirebaseAppTest.kt @@ -1,8 +1,10 @@ import android.app.Application +import android.content.Context import com.google.firebase.Firebase import com.google.firebase.FirebaseOptions import com.google.firebase.FirebasePlatform import com.google.firebase.initialize +import org.junit.Assert.assertTrue import org.junit.Test import java.io.File @@ -44,4 +46,11 @@ class FirebaseAppTest : FirebaseTest() { .build() Firebase.initialize(Application(), options) } + + @Test + fun `initialize firebase with a plain Context`() { + val app = Firebase.initialize(Context(), options) + + assertTrue(app.applicationContext is Application) + } } diff --git a/src/test/kotlin/FirebaseTest.kt b/src/test/kotlin/FirebaseTest.kt index 094a824..c8ee128 100644 --- a/src/test/kotlin/FirebaseTest.kt +++ b/src/test/kotlin/FirebaseTest.kt @@ -13,18 +13,18 @@ import java.util.concurrent.ConcurrentHashMap abstract class FirebaseTest { - protected val app: FirebaseApp by lazy { - val options = - FirebaseOptions - .Builder() - .setProjectId("fir-java-sdk") - .setApplicationId("1:341458593155:web:bf8e1aa37efe01f32d42b6") - .setApiKey("AIzaSyCvVHjTJHyeStnzIE7J9LLtHqWk6reGM08") - .setDatabaseUrl("https://fir-java-sdk-default-rtdb.firebaseio.com") - .setStorageBucket("fir-java-sdk.appspot.com") - .setGcmSenderId("341458593155") - .build() + protected val options: FirebaseOptions = + FirebaseOptions + .Builder() + .setProjectId("fir-java-sdk") + .setApplicationId("1:341458593155:web:bf8e1aa37efe01f32d42b6") + .setApiKey("AIzaSyCvVHjTJHyeStnzIE7J9LLtHqWk6reGM08") + .setDatabaseUrl("https://fir-java-sdk-default-rtdb.firebaseio.com") + .setStorageBucket("fir-java-sdk.appspot.com") + .setGcmSenderId("341458593155") + .build() + protected val app: FirebaseApp by lazy { Firebase.initialize(Application(), options) } From 55329bc915835efa62c88798b2a85444a15ce74d Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 10:55:05 +0300 Subject: [PATCH 27/33] Add createDeviceProtectedStorageContext so the data collection setting works --- src/main/java/android/content/Context.kt | 3 +++ src/test/kotlin/FirebaseAppTest.kt | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/java/android/content/Context.kt b/src/main/java/android/content/Context.kt index a343ed4..e591230 100644 --- a/src/main/java/android/content/Context.kt +++ b/src/main/java/android/content/Context.kt @@ -37,6 +37,9 @@ open class Context { val isDeviceProtectedStorage: Boolean get() = false + // The JVM has no separate device-protected storage, so this context already is it + fun createDeviceProtectedStorageContext(): Context = this + val noBackupFilesDir: File get() = File(System.getProperty("java.io.tmpdir")) diff --git a/src/test/kotlin/FirebaseAppTest.kt b/src/test/kotlin/FirebaseAppTest.kt index e525efa..44a62d6 100644 --- a/src/test/kotlin/FirebaseAppTest.kt +++ b/src/test/kotlin/FirebaseAppTest.kt @@ -4,6 +4,7 @@ import com.google.firebase.Firebase import com.google.firebase.FirebaseOptions import com.google.firebase.FirebasePlatform import com.google.firebase.initialize +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.io.File @@ -47,6 +48,19 @@ class FirebaseAppTest : FirebaseTest() { Firebase.initialize(Application(), options) } + @Test + fun `data collection default can be changed`() { + // The Boolean? overload; the boolean one is deprecated + val enabled: Boolean? = true + val disabled: Boolean? = false + + app.setDataCollectionDefaultEnabled(enabled) + assertTrue(app.isDataCollectionDefaultEnabled) + + app.setDataCollectionDefaultEnabled(disabled) + assertFalse(app.isDataCollectionDefaultEnabled) + } + @Test fun `initialize firebase with a plain Context`() { val app = Firebase.initialize(Context(), options) From 4382b8a2e8ea2d3a3a1cc4059144393151e49961 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 10:56:13 +0300 Subject: [PATCH 28/33] Write preferences again after a failed or lost write, and test write failures on every OS --- .../java/android/content/PreferencesFile.kt | 12 ++++- src/test/kotlin/PreferencesFileTest.kt | 53 +++++++++++++------ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/src/main/java/android/content/PreferencesFile.kt b/src/main/java/android/content/PreferencesFile.kt index fd69fb7..5ce4c65 100644 --- a/src/main/java/android/content/PreferencesFile.kt +++ b/src/main/java/android/content/PreferencesFile.kt @@ -33,6 +33,9 @@ internal class PreferencesFile private constructor(private val file: File) : Sha // Loaded from the file on first use; read and replaced only while holding this instance's lock private var loadedValues: Map? = null + // The values the file last held, which differ from loadedValues after a failed write + private var storedValues: Map = emptyMap() + override fun contains(key: String): Boolean = values().containsKey(key) override fun getString(key: String, defaultValue: String?): String? = values()[key] as String? ?: defaultValue @@ -53,7 +56,10 @@ internal class PreferencesFile private constructor(private val file: File) : Sha override fun edit(): SharedPreferences.Editor = Edit() private fun values(): Map = synchronized(this) { - loadedValues ?: read().also { loadedValues = it } + loadedValues ?: read().also { + loadedValues = it + storedValues = it + } } private fun read(): Map { @@ -71,15 +77,17 @@ internal class PreferencesFile private constructor(private val file: File) : Sha synchronized(this) { val updated = if (clear) mutableMapOf() else values().toMutableMap() changes.forEach { (key, value) -> if (value == null) updated.remove(key) else updated[key] = value } - if (updated == values()) return true // As on Android, memory is updated first, so the new values stay readable if the write fails loadedValues = updated + // A missing file reads as empty, so it only needs writing when it would hold values + if (updated == storedValues && (updated.isEmpty() || file.exists())) return true try { write(updated) } catch (e: IOException) { Log.e(TAG, "Failed to write preferences file $file", e) return false } + storedValues = updated return true } } diff --git a/src/test/kotlin/PreferencesFileTest.kt b/src/test/kotlin/PreferencesFileTest.kt index b0eace1..3fe76ce 100644 --- a/src/test/kotlin/PreferencesFileTest.kt +++ b/src/test/kotlin/PreferencesFileTest.kt @@ -12,7 +12,6 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue -import org.junit.Assume.assumeFalse import org.junit.Before import org.junit.Rule import org.junit.Test @@ -33,9 +32,14 @@ class PreferencesFileTest { private fun prefs(name: String = "frc_app_firebase_settings") = PreferencesFile.at(file(name)) - private fun canCreateFileIn(folder: File): Boolean { - val probe = File(folder, "probe") - return runCatching { probe.createNewFile() }.getOrDefault(false).also { created -> if (created) probe.delete() } + // A non-empty folder in place of the file makes every write fail, on every OS and for every user + private fun blockWrites(name: String) { + file(name).delete() + File(file(name), "blocker").apply { parentFile.mkdirs() }.createNewFile() + } + + private fun unblockWrites(name: String) { + file(name).deleteRecursively() } @Before @@ -183,18 +187,35 @@ class PreferencesFileTest { fun `failed commit keeps the new values in memory and returns false`() { val prefs = prefs() prefs.edit().putString("key", "before").commit() - val sharedPrefs = file("frc_app_firebase_settings").parentFile - sharedPrefs.setWritable(false) - try { - // Root, and Windows, can still create files in a folder marked read-only - assumeFalse("folder is still writable", canCreateFileIn(sharedPrefs)) - assertFalse(prefs.edit().putString("key", "after").commit()) - // As on Android, memory is updated even when the file cannot be written - assertEquals("after", prefs.getString("key", null)) - assertTrue(platform.logs.any { it.startsWith("PreferencesFile Failed to write preferences file") }) - } finally { - sharedPrefs.setWritable(true) - } + blockWrites("frc_app_firebase_settings") + + assertFalse(prefs.edit().putString("key", "after").commit()) + // As on Android, memory is updated even when the file cannot be written + assertEquals("after", prefs.getString("key", null)) + assertTrue(platform.logs.any { it.startsWith("PreferencesFile Failed to write preferences file") }) + } + + @Test + fun `commit after a failed write writes the values`() { + val prefs = prefs() + blockWrites("frc_app_firebase_settings") + assertFalse(prefs.edit().putString("key", "after").commit()) + unblockWrites("frc_app_firebase_settings") + + assertTrue(prefs.edit().putString("key", "after").commit()) + + assertEquals(setOf("key"), Json.parseToJsonElement(file("frc_app_firebase_settings").readText()).jsonObject.keys) + } + + @Test + fun `commit recreates a deleted file even when values are unchanged`() { + val prefs = prefs() + prefs.edit().putString("key", "value").commit() + file("frc_app_firebase_settings").delete() + + prefs.edit().putString("key", "value").commit() + + assertTrue(file("frc_app_firebase_settings").exists()) } @Test From cb4bd1d3b38ca1bd798e4d85c7eadd74803d4d48 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 10:58:34 +0300 Subject: [PATCH 29/33] Give each test a data folder that outlives Firebase's background work, and terminate Firestore after its test --- build.gradle.kts | 5 +++++ src/test/kotlin/FirebaseAppTest.kt | 6 +++--- src/test/kotlin/FirebaseFirestoreTest.kt | 7 +++++++ src/test/kotlin/FirebaseTest.kt | 13 ++++++------- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 8466b03..baf7948 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -72,6 +72,11 @@ tasks { javadoc { exclude("android/**", "libcore/util/**") } + test { + // Tests keep Firebase's data in per-test folders here until the next run + val testData = layout.buildDirectory.dir("test-data") + doFirst { delete(testData) } + } } val jar by tasks.getting(Jar::class) { diff --git a/src/test/kotlin/FirebaseAppTest.kt b/src/test/kotlin/FirebaseAppTest.kt index 44a62d6..97c74c3 100644 --- a/src/test/kotlin/FirebaseAppTest.kt +++ b/src/test/kotlin/FirebaseAppTest.kt @@ -29,10 +29,10 @@ class FirebaseAppTest : FirebaseTest() { override fun log(msg: String) = println(msg) - // Keeps Firebase's data in this test's temporary folder - override fun getDatabasePath(name: String) = File(folder.root, name) + // Keeps Firebase's data in this test's folder + override fun getDatabasePath(name: String) = File(dataFolder, name) - override fun getFilesDir() = File(folder.root, "files") + override fun getFilesDir() = File(dataFolder, "files") } ) val options = diff --git a/src/test/kotlin/FirebaseFirestoreTest.kt b/src/test/kotlin/FirebaseFirestoreTest.kt index 3feff0e..73b9623 100644 --- a/src/test/kotlin/FirebaseFirestoreTest.kt +++ b/src/test/kotlin/FirebaseFirestoreTest.kt @@ -2,11 +2,18 @@ import com.google.firebase.Firebase import com.google.firebase.firestore.firestore import kotlinx.coroutines.tasks.await import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Test class FirebaseFirestoreTest : FirebaseTest() { + // Stops Firestore's background work, such as cache garbage collection, so it does not log into later tests + @After + fun terminateFirestore(): Unit = runTest { + Firebase.firestore(app).terminate().await() + } + @Test fun `set and get a document`(): Unit = runTest { val firestore = Firebase.firestore(app) diff --git a/src/test/kotlin/FirebaseTest.kt b/src/test/kotlin/FirebaseTest.kt index c8ee128..8c8e0e3 100644 --- a/src/test/kotlin/FirebaseTest.kt +++ b/src/test/kotlin/FirebaseTest.kt @@ -6,9 +6,8 @@ import com.google.firebase.FirebasePlatform import com.google.firebase.initialize import org.junit.After import org.junit.Before -import org.junit.Rule -import org.junit.rules.TemporaryFolder import java.io.File +import java.util.UUID import java.util.concurrent.ConcurrentHashMap abstract class FirebaseTest { @@ -28,9 +27,9 @@ abstract class FirebaseTest { Firebase.initialize(Application(), options) } - // Preferences and config files are written under getFilesDir(), so each test gets its own - @get:Rule - val folder = TemporaryFolder() + // Each test gets its own folder for Firebase's files and databases. It is kept until the next test run, so + // background work that outlives a test writes into this folder instead of failing into a later test. + protected val dataFolder = File("build/test-data/${UUID.randomUUID()}") protected open fun log(message: String) = println(message) @@ -53,9 +52,9 @@ abstract class FirebaseTest { override fun log(msg: String) = this@FirebaseTest.log(msg) - override fun getDatabasePath(name: String) = File("./build/$name") + override fun getDatabasePath(name: String) = File(dataFolder, name) - override fun getFilesDir() = File(folder.root, "files") + override fun getFilesDir() = File(dataFolder, "files") } ) } From 3f91e8041dfa6babe6bb1ea4633f769b96ea8e5a Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 10:59:02 +0300 Subject: [PATCH 30/33] Check the Storage child reference's path instead of requesting its download URL --- src/test/kotlin/FirebaseStorageTest.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/test/kotlin/FirebaseStorageTest.kt b/src/test/kotlin/FirebaseStorageTest.kt index 0e0c599..5e4f2db 100644 --- a/src/test/kotlin/FirebaseStorageTest.kt +++ b/src/test/kotlin/FirebaseStorageTest.kt @@ -1,10 +1,7 @@ import android.net.Uri -import com.google.android.gms.tasks.Tasks import com.google.firebase.Firebase import com.google.firebase.storage.internal.Slashes import com.google.firebase.storage.storage -import kotlinx.coroutines.tasks.await -import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Test @@ -34,14 +31,11 @@ class FirebaseStorageTest : FirebaseTest() { } @Test - fun `getting child reference`(): Unit = runTest { + fun `getting child reference`() { val storage = Firebase.storage(app) val reference = storage.reference val downloadRef = reference.child("mountains.jpg") - val downloadUrl = downloadRef.downloadUrl - Assert.assertNotNull(downloadUrl) - // Waits for the request so its logs stay within this test; the outcome is not under test - Tasks.whenAllComplete(downloadUrl).await() + Assert.assertEquals("/mountains.jpg", downloadRef.path) } } From a6c8e4597d19636e6729b801139186c942a4d0d9 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 11:09:47 +0300 Subject: [PATCH 31/33] Test that a written preferences file is read back with its types --- src/test/kotlin/PreferencesFileTest.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/test/kotlin/PreferencesFileTest.kt b/src/test/kotlin/PreferencesFileTest.kt index 3fe76ce..5e98d3f 100644 --- a/src/test/kotlin/PreferencesFileTest.kt +++ b/src/test/kotlin/PreferencesFileTest.kt @@ -69,6 +69,24 @@ class PreferencesFileTest { ) } + @Test + fun `a written file is read back with its types`() { + prefs("written").edit() + .putString("string", "abc") + .putInt("int", -1) + .putLong("long", 42L) + .putBoolean("boolean", true) + .putStringSet("stringSet", setOf("x", "y")) + .commit() + // A copy is read by a new instance, as after a restart, not from the writer's memory + file("written").copyTo(file("copy")) + + assertEquals( + mapOf("string" to "abc", "int" to -1, "long" to 42L, "boolean" to true, "stringSet" to setOf("x", "y")), + prefs("copy").all + ) + } + @Test fun `missing keys return defaults`() { val prefs = prefs() From 354e259a71e97dd44ad27bc9061f8c0e83b023f9 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 11:10:36 +0300 Subject: [PATCH 32/33] Check the stored heartbeat on disk instead of the heartbeat header --- src/test/kotlin/HeartBeatTest.kt | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/test/kotlin/HeartBeatTest.kt b/src/test/kotlin/HeartBeatTest.kt index 40d84f9..50a894d 100644 --- a/src/test/kotlin/HeartBeatTest.kt +++ b/src/test/kotlin/HeartBeatTest.kt @@ -1,21 +1,32 @@ /* * Tests that Firebase's heartbeat reporting (firebase-common), which Installations attaches to - * its requests, can store and read heartbeats through the file-backed preferences. + * its requests, stores its heartbeats through the file-backed preferences. */ +import android.content.PreferencesFile import com.google.firebase.heartbeatinfo.DefaultHeartBeatController import kotlinx.coroutines.tasks.await import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone class HeartBeatTest : FirebaseTest() { @Test - fun `registered heartbeat is reported in the header`(): Unit = runTest { - val controller = app.get(DefaultHeartBeatController::class.java) + fun `registered heartbeat is stored for today`(): Unit = runTest { + app.get(DefaultHeartBeatController::class.java).registerHeartBeat().await() - controller.registerHeartBeat().await() + // A copy is read by a new instance, so the check covers what reached the disk + val stored = File(dataFolder, "files/shared_prefs").listFiles()!!.single { it.name.startsWith("FirebaseHeartBeat") } + val heartbeats = PreferencesFile.at(stored.copyTo(File(dataFolder, "heartbeats-copy.json"))) + val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date()) - assertTrue(controller.heartBeatsHeader.await().isNotEmpty()) + assertEquals(1L, heartbeats.getLong("fire-count", 0L)) + assertTrue(heartbeats.all.values.any { it is Set<*> && today in it }) } } From 29a54c965cff7a8798019e7c51ea4897ed3cf567 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Thu, 8 Oct 2026 11:11:44 +0300 Subject: [PATCH 33/33] Fail the preferences and Context file tests on unexpected logs --- src/test/kotlin/ContextFilesTest.kt | 14 ++++++++++++-- src/test/kotlin/PreferencesFileTest.kt | 12 ++++++++++-- src/test/kotlin/fakes/FakeFirebasePlatform.kt | 10 +++++++++- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/test/kotlin/ContextFilesTest.kt b/src/test/kotlin/ContextFilesTest.kt index bcb4930..0d99d3c 100644 --- a/src/test/kotlin/ContextFilesTest.kt +++ b/src/test/kotlin/ContextFilesTest.kt @@ -6,6 +6,7 @@ import android.app.Application import android.content.Context import com.google.firebase.FirebasePlatform import fakes.FakeFirebasePlatform +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows @@ -24,12 +25,20 @@ class ContextFilesTest { private lateinit var filesDir: File + private lateinit var platform: FakeFirebasePlatform + private val context = Application() @Before fun setUp() { filesDir = File(folder.root, "files") - FirebasePlatform.initializeFirebasePlatform(FakeFirebasePlatform(filesFolderPath = filesDir.path)) + platform = FakeFirebasePlatform(filesFolderPath = filesDir.path) + FirebasePlatform.initializeFirebasePlatform(platform) + } + + @After + fun nothingUnexpectedWasLogged() { + assertEquals(emptyList(), platform.logs.toList()) } @Test @@ -91,7 +100,8 @@ class ContextFilesTest { @Test fun `getDatabasePath creates its folder`() { val databases = File(folder.root, "databases") - FirebasePlatform.initializeFirebasePlatform(FakeFirebasePlatform(databaseFolderPath = databases.path)) + platform = FakeFirebasePlatform(databaseFolderPath = databases.path) + FirebasePlatform.initializeFirebasePlatform(platform) assertEquals(File(databases, "firestore.db"), context.getDatabasePath("firestore.db")) assertTrue(databases.isDirectory) diff --git a/src/test/kotlin/PreferencesFileTest.kt b/src/test/kotlin/PreferencesFileTest.kt index 5e98d3f..5536879 100644 --- a/src/test/kotlin/PreferencesFileTest.kt +++ b/src/test/kotlin/PreferencesFileTest.kt @@ -7,6 +7,7 @@ import com.google.firebase.FirebasePlatform import fakes.FakeFirebasePlatform import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -47,6 +48,11 @@ class PreferencesFileTest { FirebasePlatform.initializeFirebasePlatform(platform) } + @After + fun nothingUnexpectedWasLogged() { + assertEquals(emptyList(), platform.logs.toList()) + } + @Test fun `values round-trip with their types`() { val prefs = prefs() @@ -196,7 +202,7 @@ class PreferencesFileTest { val prefs = prefs("corrupt") assertTrue(prefs.all.isEmpty()) - assertTrue(platform.logs.any { it.startsWith("PreferencesFile Ignoring unreadable preferences file") }) + assertEquals(1, platform.takeLogs("PreferencesFile Ignoring unreadable preferences file")) prefs.edit().putString("key", "value").commit() assertEquals(setOf("key"), Json.parseToJsonElement(file("corrupt").readText()).jsonObject.keys) } @@ -210,14 +216,16 @@ class PreferencesFileTest { assertFalse(prefs.edit().putString("key", "after").commit()) // As on Android, memory is updated even when the file cannot be written assertEquals("after", prefs.getString("key", null)) - assertTrue(platform.logs.any { it.startsWith("PreferencesFile Failed to write preferences file") }) + assertEquals(1, platform.takeLogs("PreferencesFile Failed to write preferences file")) } @Test fun `commit after a failed write writes the values`() { val prefs = prefs() + prefs.edit().putString("key", "before").commit() blockWrites("frc_app_firebase_settings") assertFalse(prefs.edit().putString("key", "after").commit()) + assertEquals(1, platform.takeLogs("PreferencesFile Failed to write preferences file")) unblockWrites("frc_app_firebase_settings") assertTrue(prefs.edit().putString("key", "after").commit()) diff --git a/src/test/kotlin/fakes/FakeFirebasePlatform.kt b/src/test/kotlin/fakes/FakeFirebasePlatform.kt index c9fa74c..7bc541f 100644 --- a/src/test/kotlin/fakes/FakeFirebasePlatform.kt +++ b/src/test/kotlin/fakes/FakeFirebasePlatform.kt @@ -19,9 +19,17 @@ class FakeFirebasePlatform( private val filesFolder = File(filesFolderPath) - // Captured instead of printed, so tests can assert on expected log output + // Captured so tests can assert on expected log output val logs: MutableList = Collections.synchronizedList(mutableListOf()) + // Removes the logged lines starting with [prefix] and returns how many there were, so a test can check + // its expected lines and then that nothing else was logged + fun takeLogs(prefix: String): Int = synchronized(logs) { + val taken = logs.filter { it.startsWith(prefix) } + logs.removeAll(taken) + taken.size + } + override fun store(key: String, value: String) { storage[key] = value } override fun retrieve(key: String) = storage[key]