From e8c9652618fcb1bbb67f2abc54d62a42c8e4e658 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 7 Oct 2026 20:22:14 +0300 Subject: [PATCH 01/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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] From 2c4db2f6da80c62182a03a310d0667e7a51b9c65 Mon Sep 17 00:00:00 2001 From: Nick Williams Date: Mon, 26 Oct 2026 11:12:59 +0000 Subject: [PATCH 34/34] Load a preferences file before clearing it, and keep the Remote Config limitations footnote Clearing a preferences file as its first operation skipped the load, so storedValues was still empty, the unchanged check passed and the file kept its values for the next start. The README row keeps its footnote reference, which the merge had dropped. --- README.md | 2 +- src/main/java/android/content/PreferencesFile.kt | 4 +++- src/test/kotlin/PreferencesFileTest.kt | 11 +++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1e09ec6..6c8d38d 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ The following libraries are available for the various Firebase products. | [Cloud Firestore](https://firebase.google.com/docs/firestore) | `26.6.0` | | [Realtime Database](https://firebase.google.com/docs/database) | `22.0.1` | | [Cloud Functions](https://firebase.google.com/docs/functions) | `22.1.1` | -| [Remote Config](https://firebase.google.com/docs/remote-config) | `23.1.0` | +| [Remote Config](https://firebase.google.com/docs/remote-config) | `23.1.0`[^2] | | [Installations](https://firebase.google.com/docs/projects/manage-installations) | `19.1.2` | [^1]: Google has not open-sourced the Firebase Auth implementation for Android so a basic implementation using the Rest API is provided. diff --git a/src/main/java/android/content/PreferencesFile.kt b/src/main/java/android/content/PreferencesFile.kt index 5ce4c65..ad447b9 100644 --- a/src/main/java/android/content/PreferencesFile.kt +++ b/src/main/java/android/content/PreferencesFile.kt @@ -75,7 +75,9 @@ internal class PreferencesFile private constructor(private val file: File) : Sha private fun save(changes: Map, clear: Boolean): Boolean { synchronized(this) { - val updated = if (clear) mutableMapOf() else values().toMutableMap() + // Loading first keeps storedValues true to the file, so clearing an unread file still writes it + val current = values() + val updated = if (clear) mutableMapOf() else current.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 diff --git a/src/test/kotlin/PreferencesFileTest.kt b/src/test/kotlin/PreferencesFileTest.kt index 5536879..e2e320b 100644 --- a/src/test/kotlin/PreferencesFileTest.kt +++ b/src/test/kotlin/PreferencesFileTest.kt @@ -176,6 +176,17 @@ class PreferencesFileTest { assertEquals(setOf("2026-10-08"), prefs.getStringSet("fire-core", null)) } + @Test + fun `clearing an existing file before reading it empties the file`() { + file("cleared").parentFile.mkdirs() + file("cleared").writeText("""{"num_failed_fetches":{"int":3}}""") + + assertTrue(prefs("cleared").edit().clear().commit()) + + assertEquals(emptyMap(), Json.parseToJsonElement(file("cleared").readText()).jsonObject) + assertEquals(0, prefs("cleared").getInt("num_failed_fetches", 0)) + } + @Test fun `commits that change nothing do not write the file`() { val prefs = prefs("unchanged")