diff --git a/README.md b/README.md index 470a5b8..1cb5347 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,16 @@ You can skip the above if you are using the SDK via the 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 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 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) { @@ -130,10 +73,32 @@ 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() } + + 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() + + 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) companion object { @JvmStatic val CONNECTIVITY_SERVICE = "connectivity" + + const val MODE_APPEND = 0x8000 } } diff --git a/src/main/java/android/content/PreferencesFile.kt b/src/main/java/android/content/PreferencesFile.kt new file mode 100644 index 0000000..5ce4c65 --- /dev/null +++ b/src/main/java/android/content/PreferencesFile.kt @@ -0,0 +1,186 @@ +/* + * 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.FileOutputStream +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 + + // 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 + + 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 + storedValues = 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 } + // 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 + } + } + + // 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 { + 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() + } + } + + // 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 d6e72c2..d2c4559 100644 --- a/src/main/java/android/content/SharedPreferences.java +++ b/src/main/java/android/content/SharedPreferences.java @@ -1,19 +1,28 @@ 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); + boolean getBoolean(String key, boolean defValue); + Set getStringSet(String key, Set defValues); Editor edit(); 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(); boolean commit(); void apply(); } 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/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/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/main/java/android/util/Base64DataException.java b/src/main/java/android/util/Base64DataException.java new file mode 100644 index 0000000..47ea24e --- /dev/null +++ b/src/main/java/android/util/Base64DataException.java @@ -0,0 +1,35 @@ +/* + * 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 + * + * 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/main/java/android/util/JsonReader.java b/src/main/java/android/util/JsonReader.java new file mode 100644 index 0000000..038bcba --- /dev/null +++ b/src/main/java/android/util/JsonReader.java @@ -0,0 +1,1178 @@ +/* + * Ported unchanged from AOSP (android14-release). Firebase Installations parses its REST + * responses with android.util.JsonReader. + */ + +/* + * 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. + *

+ *

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: + * + *

+ */ + 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..553906d --- /dev/null +++ b/src/main/java/android/util/JsonScope.java @@ -0,0 +1,73 @@ +/* + * 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 + * + * 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..5797658 --- /dev/null +++ b/src/main/java/android/util/JsonToken.java @@ -0,0 +1,87 @@ +/* + * 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 + * + * 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..277d9f7 --- /dev/null +++ b/src/main/java/android/util/MalformedJsonException.java @@ -0,0 +1,36 @@ +/* + * 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 + * + * 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..8e86fe9 --- /dev/null +++ b/src/main/java/com/android/internal/util/StringPool.java @@ -0,0 +1,81 @@ +/* + * Ported unchanged from AOSP (android14-release). Used by the android.util.JsonReader port. + */ + +/* + * 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/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..0d99d3c --- /dev/null +++ b/src/test/kotlin/ContextFilesTest.kt @@ -0,0 +1,116 @@ +/* + * 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.After +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 lateinit var platform: FakeFirebasePlatform + + private val context = Application() + + @Before + fun setUp() { + filesDir = File(folder.root, "files") + platform = FakeFirebasePlatform(filesFolderPath = filesDir.path) + FirebasePlatform.initializeFirebasePlatform(platform) + } + + @After + fun nothingUnexpectedWasLogged() { + assertEquals(emptyList(), platform.logs.toList()) + } + + @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) } + } + + @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 `getDatabasePath creates its folder`() { + val databases = File(folder.root, "databases") + platform = FakeFirebasePlatform(databaseFolderPath = databases.path) + FirebasePlatform.initializeFirebasePlatform(platform) + + 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) + + assertTrue(prefs.getBoolean("firebase_data_collection_default_enabled", true)) + } +} 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)) + } +} diff --git a/src/test/kotlin/FirebaseAppTest.kt b/src/test/kotlin/FirebaseAppTest.kt index 8d64303..97c74c3 100644 --- a/src/test/kotlin/FirebaseAppTest.kt +++ b/src/test/kotlin/FirebaseAppTest.kt @@ -1,9 +1,13 @@ 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.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File class FirebaseAppTest : FirebaseTest() { @Test @@ -24,6 +28,11 @@ class FirebaseAppTest : FirebaseTest() { } override fun log(msg: String) = println(msg) + + // Keeps Firebase's data in this test's folder + override fun getDatabasePath(name: String) = File(dataFolder, name) + + override fun getFilesDir() = File(dataFolder, "files") } ) val options = @@ -38,4 +47,24 @@ class FirebaseAppTest : FirebaseTest() { .build() 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) + + assertTrue(app.applicationContext is Application) + } } 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/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()) + } +} diff --git a/src/test/kotlin/FirebaseRemoteConfigTest.kt b/src/test/kotlin/FirebaseRemoteConfigTest.kt new file mode 100644 index 0000000..7b52367 --- /dev/null +++ b/src/test/kotlin/FirebaseRemoteConfigTest.kt @@ -0,0 +1,183 @@ +/* + * 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.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) + } + + // 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, + "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() + + assertEquals(emptyList(), logs.toList().filterNot { log -> expectedLogs.any { log.startsWith(it) } }) + } + + @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) + } + + @Test + fun `fetchAndActivate succeeds`(): Unit = runTest { + remoteConfig.fetchAndActivate().await() + + assertEquals(FirebaseRemoteConfig.LAST_FETCH_STATUS_SUCCESS, remoteConfig.info.lastFetchStatus) + + val capturedLogs = logs.toList() + 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 + // 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/FirebaseStorageTest.kt b/src/test/kotlin/FirebaseStorageTest.kt index 332f46b..5e4f2db 100644 --- a/src/test/kotlin/FirebaseStorageTest.kt +++ b/src/test/kotlin/FirebaseStorageTest.kt @@ -35,8 +35,7 @@ class FirebaseStorageTest : FirebaseTest() { val storage = Firebase.storage(app) val reference = storage.reference val downloadRef = reference.child("mountains.jpg") - val downloadUrl = downloadRef.downloadUrl - Assert.assertNotNull(downloadUrl) + Assert.assertEquals("/mountains.jpg", downloadRef.path) } } diff --git a/src/test/kotlin/FirebaseTest.kt b/src/test/kotlin/FirebaseTest.kt index 36d91b0..8c8e0e3 100644 --- a/src/test/kotlin/FirebaseTest.kt +++ b/src/test/kotlin/FirebaseTest.kt @@ -7,29 +7,37 @@ import com.google.firebase.initialize import org.junit.After import org.junit.Before import java.io.File +import java.util.UUID +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) } + // 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) + @Before fun beforeEach() { FirebasePlatform.initializeFirebasePlatform( object : FirebasePlatform() { - val storage = mutableMapOf() + val storage = ConcurrentHashMap() override fun store( key: String, @@ -42,9 +50,11 @@ 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(dataFolder, name) - override fun getDatabasePath(name: String) = File("./build/$name") + override fun getFilesDir() = File(dataFolder, "files") } ) } diff --git a/src/test/kotlin/HeartBeatTest.kt b/src/test/kotlin/HeartBeatTest.kt new file mode 100644 index 0000000..50a894d --- /dev/null +++ b/src/test/kotlin/HeartBeatTest.kt @@ -0,0 +1,32 @@ +/* + * Tests that Firebase's heartbeat reporting (firebase-common), which Installations attaches to + * 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 stored for today`(): Unit = runTest { + app.get(DefaultHeartBeatController::class.java).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()) + + assertEquals(1L, heartbeats.getLong("fire-count", 0L)) + assertTrue(heartbeats.all.values.any { it is Set<*> && today in it }) + } +} 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 + ) + } +} diff --git a/src/test/kotlin/PreferencesFileTest.kt b/src/test/kotlin/PreferencesFileTest.kt new file mode 100644 index 0000000..5536879 --- /dev/null +++ b/src/test/kotlin/PreferencesFileTest.kt @@ -0,0 +1,291 @@ +/* + * 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.After +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)) + + // 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 + fun setUp() { + FirebasePlatform.initializeFirebasePlatform(platform) + } + + @After + fun nothingUnexpectedWasLogged() { + assertEquals(emptyList(), platform.logs.toList()) + } + + @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 `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() + + 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 `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") + + 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()) + 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) + } + + @Test + fun `failed commit keeps the new values in memory and returns false`() { + val prefs = prefs() + prefs.edit().putString("key", "before").commit() + 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)) + 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()) + + 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 + 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 6074ad9..7bc541f 100644 --- a/src/test/kotlin/fakes/FakeFirebasePlatform.kt +++ b/src/test/kotlin/fakes/FakeFirebasePlatform.kt @@ -2,25 +2,43 @@ 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(), - databaseFolderPath: String = "./build/database/" + val storage: MutableMap = ConcurrentHashMap(), + databaseFolderPath: String = "./build/database/", + filesFolderPath: String = "./build/files/" ) : FirebasePlatform() { private val databaseFolder = File(databaseFolderPath) + private val filesFolder = File(filesFolderPath) + + // 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] 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) + + override fun getFilesDir() = filesFolder }