diff --git a/FireMarkers/.gitignore b/FireMarkers/.gitignore
new file mode 100644
index 00000000..3cc7d265
--- /dev/null
+++ b/FireMarkers/.gitignore
@@ -0,0 +1,34 @@
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Secrets
+secrets.properties
+
+# Log/OS Files
+*.log
+
+# Android Studio generated files and folders
+captures/
+.externalNativeBuild/
+.cxx/
+*.aab
+*.apk
+output-metadata.json
+
+# IntelliJ
+*.iml
+.idea/
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Android Profiling
+*.hprof
\ No newline at end of file
diff --git a/FireMarkers/ARCHITECTURE.md b/FireMarkers/ARCHITECTURE.md
new file mode 100644
index 00000000..68d15bad
--- /dev/null
+++ b/FireMarkers/ARCHITECTURE.md
@@ -0,0 +1,92 @@
+# FireMarkers Architecture
+
+This document provides a visual overview of the FireMarkers application's architecture. The core of the app is a **controller/agent synchronization pattern** that uses Firebase Realtime Database to keep multiple devices in sync.
+
+## Architecture Diagram
+
+```mermaid
+flowchart TD
+ subgraph "Firebase Cloud"
+ MarkersDB[(Firebase Realtime Database\n/markers)]
+ AnimationDB[(Firebase Realtime Database\n/animation)]
+ end
+
+ subgraph "Android App"
+ subgraph "UI Layer (View)"
+ A[MainActivity] --> B(MapScreen Composable);
+ B -- User Clicks --> C["TopAppBar Actions
(toggleAnimation, seedDatabase, clearMarkers, takeControl)"];
+ B -- Renders Markers --> D[GoogleMap Composable];
+ end
+
+ subgraph "State & Logic Layer (ViewModel)"
+ VM[MarkersViewModel];
+ end
+
+ subgraph "Data Layer"
+ FC[FirebaseConnection];
+ SD[ShapeData];
+ M(MarkerData Model);
+ end
+
+ subgraph "Dependency Injection"
+ Hilt(Hilt/Dagger);
+ end
+ end
+
+ %% --- Interactions ---
+ C -- Calls function --> VM;
+ VM -- Uses --> FC;
+ VM -- Reads shape vectors --> SD;
+
+ %% Controller writes to Firebase
+ VM -- Writes animation state --> AnimationDB;
+ VM -- Writes marker data --> MarkersDB;
+
+ %% All clients listen for real-time updates
+ MarkersDB -.->|Real-time updates| FC;
+ AnimationDB -.->|Real-time updates| FC;
+
+ FC -.-> VM;
+ VM -- Updates StateFlow --> B;
+ VM -- Uses Model --> M;
+
+ %% --- DI Graph ---
+ Hilt -- Injects --> FC;
+ Hilt -- Injects --> VM;
+
+ %% --- Styling ---
+ style A fill:#cde,stroke:#333,stroke-width:2px
+ style B fill:#cde,stroke:#333,stroke-width:2px
+ style C fill:#cde,stroke:#333,stroke-width:2px
+ style D fill:#cde,stroke:#333,stroke-width:2px
+ style VM fill:#dce,stroke:#333,stroke-width:2px
+ style FC fill:#edc,stroke:#333,stroke-width:2px
+ style SD fill:#edc,stroke:#333,stroke-width:2px
+ style M fill:#edc,stroke:#333,stroke-width:2px
+ style MarkersDB fill:#f9d,stroke:#333,stroke-width:2px
+ style AnimationDB fill:#f9d,stroke:#333,stroke-width:2px
+ style Hilt fill:#eee,stroke:#333,stroke-width:2px
+```
+
+### How it Works
+
+The application uses a controller/agent model to synchronize animations across devices.
+
+1. **Controller and Agents:** At any time, only one device is the **controller**. It is responsible for running the animation loop and writing the current animation state (progress, running status) to the `/animation` node in Firebase. All other devices are **agents** that passively listen for changes to this node.
+2. **UI Layer:** The `MainActivity` hosts the `MapScreen` composable. The UI in the `TopAppBar` is dynamic:
+ * If the device is the **controller**, it shows buttons to `toggleAnimation`, `seedDatabase`, and `clearMarkers`.
+ * If the device is an **agent**, it shows a single button that allows the user to `takeControl`.
+3. **ViewModel:** UI interactions call functions on the `MarkersViewModel`.
+ * If the controller toggles the animation, the ViewModel starts a local animation loop and writes the progress to the `/animation` node in Firebase.
+ * If an agent requests control, the ViewModel updates the `controllerId` field in the `/animation` node.
+4. **Data Layer:**
+ * The `MarkersViewModel` uses the `FirebaseConnection` service to interact with Firebase.
+ * The `ShapeData` object provides the static vector coordinates for the jack-o'-lantern and tree shapes.
+5. **Real-time Updates:** The `MarkersViewModel` establishes listeners on two Firebase paths:
+ * `/markers`: When the marker data changes (e.g., after seeding), Firebase pushes the updates to all clients.
+ * `/animation`: When the animation state changes (written by the controller), Firebase pushes the new state to all agents.
+6. **State Flow & Interpolation:** The `MarkersViewModel` uses a `combine` operator on two `StateFlow`s (one for markers, one for animation state). When new data is received from either listener, it recalculates the interpolated position and color for every marker based on the animation progress (`fraction`).
+7. **UI Update:** The `MapScreen` composable collects the final `StateFlow` of interpolated marker data. On each new emission, the `GoogleMap` recomposes and smoothly animates the markers to their new positions and colors.
+8. **Dependency Injection:** Hilt provides the `FirebaseConnection` as a singleton to the `MarkersViewModel`.
+
+```
\ No newline at end of file
diff --git a/FireMarkers/LICENSE b/FireMarkers/LICENSE
new file mode 100644
index 00000000..261eeb9e
--- /dev/null
+++ b/FireMarkers/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
diff --git a/FireMarkers/README.md b/FireMarkers/README.md
new file mode 100644
index 00000000..6eaca9fb
--- /dev/null
+++ b/FireMarkers/README.md
@@ -0,0 +1,75 @@
+# Realtime, Synchronized Map Animations with Firebase and Google Maps
+
+This sample demonstrates how to use the Firebase Realtime Database to drive perfectly synchronized, live animations on a Google Map across multiple Android devices. It showcases a **controller/agent architecture** where one device drives the animation state, and all other connected devices act as passive observers, ensuring all users see the same animation at the same time.
+
+The app displays a set of markers that can animate between two complex shapes: a jack-o'-lantern and a Christmas tree. One device acts as the "controller," with UI controls to start, stop, and reset the animation. All other devices are "agents," with a button to take control.
+
+| Controller View | Agent View |
+|:--------------------------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------|
+|
|
|
+
+## Key Concepts Demonstrated
+
+* **Firebase Realtime Database:** Connecting an Android app to a Firebase Realtime Database.
+* **Controller/Agent Synchronization:** A robust pattern where one device ("controller") writes animation state to Firebase, and other devices ("agents") listen for real-time updates to synchronize their UIs.
+* **Shared Real-time State:** Using a separate `/animation` node in Firebase to store and sync shared state like animation progress, running status, and the current controller's ID.
+* **Dynamic, State-Driven UI:** Using Jetpack Compose to build a UI that dynamically changes its controls based on whether the device is the current controller or an agent.
+* **Procedural Animation:** Implementing a client-side animation loop that smoothly interpolates marker properties (latitude, longitude, and color) between two predefined vector shapes.
+* **Hilt for Dependency Injection:** Using Hilt to provide a singleton `FirebaseDatabase` instance to the application's ViewModel.
+
+For a detailed visual guide to the project's structure and data flow, see the [**ARCHITECTURE.md**](ARCHITECTURE.md) file.
+
+## Getting Started
+
+This sample uses the Gradle build system. To build the app, use the `gradlew build` command or use "Import Project" in Android Studio.
+
+### Prerequisites
+
+* Android Studio (latest version recommended)
+* An Android device or emulator with API level 24 or higher
+* A Google account to create a Firebase project
+
+### Setup
+
+To run this sample, you will need a Firebase project and a Google Maps API key.
+
+**1. Set up your Firebase Project:**
+
+The easiest way to connect your app to Firebase is by using the **Firebase Assistant** in Android Studio.
+
+* In Android Studio, go to **Tools > Firebase**.
+* In the Assistant panel, expand **Realtime Database** and follow the on-screen instructions to:
+ 1. **Connect to Firebase:** This will open a browser for you to log in and either create a new Firebase project or select an existing one.
+ 2. **Add Realtime Database to your app:** This will automatically add the necessary dependencies to your `build.gradle.kts` file and download the `google-services.json` configuration file into the `app/` directory.
+
+* For more detailed instructions, see the official guide: [**Add Firebase to your Android project**](https://firebase.google.com/docs/android/setup).
+
+**2. Configure Database Rules:**
+
+For this sample to work, your Realtime Database must be configured with public read/write rules. In the Firebase console, navigate to your **Realtime Database** and select the **Rules** tab. Replace the existing rules with the following:
+
+```json
+{
+ "rules": {
+ ".read": "true",
+ ".write": "true"
+ }
+}
+```
+**Note:** These rules are for demonstration purposes only and are **insecure**. They allow anyone to read or write to your database. For a production app, you must implement more restrictive rules to protect your data. See the official guide on [**Securing Realtime Database Rules**](https://firebase.google.com/docs/database/security) for more information.
+
+**3. Add your Google Maps API Key:**
+
+The app requires a Google Maps API key to display the map.
+
+1. Follow the [**Maps SDK for Android documentation**](https://developers.google.com/maps/documentation/android-sdk/get-api-key) to get an API key.
+2. Create a file named `secrets.properties` in the project's root directory (at the same level as `local.properties`).
+3. Add your API key to the `secrets.properties` file, like this:
+ ```
+ MAPS_API_KEY="YOUR_API_KEY"
+ ```
+ (Replace `YOUR_API_KEY` with the key you obtained). The project is configured to read this key via the Secrets Gradle Plugin.
+
+> **⚠️ IMPORTANT SECURITY NOTE:** You must prevent your API key from being checked into source control. The included `.gitignore` file is configured to ignore the `secrets.properties` file. **Do not remove this entry.** Committing your API key to a public repository can lead to unauthorized use and result in unexpected charges to your account.
+
+Once you have completed these steps, you can run the app on your device or emulator.
diff --git a/FireMarkers/app/.gitignore b/FireMarkers/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/FireMarkers/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/FireMarkers/app/build.gradle.kts b/FireMarkers/app/build.gradle.kts
new file mode 100644
index 00000000..337aad81
--- /dev/null
+++ b/FireMarkers/app/build.gradle.kts
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * 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
+ *
+ * https://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.
+ */
+
+plugins {
+ // ---------------------------------------------------------------------------------------------
+ // Core App Plugins
+ //
+ // These plugins are essential for building an Android application with Kotlin and Jetpack
+ // Compose. They handle the core compilation, packaging, and resource processing.
+ // ---------------------------------------------------------------------------------------------
+ alias(libs.plugins.android.application) // Applies the core Android application plugin.
+ alias(libs.plugins.kotlin.android) // Enables Kotlin language support for Android.
+ alias(libs.plugins.kotlin.compose) // Provides support for the Jetpack Compose compiler.
+
+ // ---------------------------------------------------------------------------------------------
+ // Dependency Injection
+ //
+ // These plugins set up Hilt for dependency injection, which simplifies the management of
+ // dependencies and improves the app's architecture. KSP (Kotlin Symbol Processing) is used
+ // by Hilt for efficient code generation.
+ // ---------------------------------------------------------------------------------------------
+ alias(libs.plugins.ksp) // Enables the Kotlin Symbol Processing tool.
+ alias(libs.plugins.hilt.android) // Integrates Hilt for dependency injection.
+
+ // ---------------------------------------------------------------------------------------------
+ // Utility Plugins
+ //
+ // These plugins provide additional functionalities, such as managing API keys with the
+ // Secrets Gradle Plugin and enabling Kotlin serialization for data conversion.
+ // ---------------------------------------------------------------------------------------------
+ alias(libs.plugins.secrets.gradle.plugin) // Manages secrets and API keys.
+ alias(libs.plugins.kotlin.serialization) // Provides Kotlin serialization capabilities.
+ id("com.google.gms.google-services") // Integrates Google services, like Firebase.
+}
+
+android {
+ namespace = "com.example.firemarkers"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.example.firemarkers"
+ minSdk = 24
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+ }
+}
+
+dependencies {
+ // ---------------------------------------------------------------------------------------------
+ // AndroidX & Jetpack
+ //
+ // These dependencies from the AndroidX library are fundamental for modern Android development.
+ // They provide core functionalities, lifecycle-aware components, and the Jetpack Compose UI
+ // toolkit for building the app's user interface.
+ // ---------------------------------------------------------------------------------------------
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.material.icons.extended)
+ implementation(libs.androidx.hilt.navigation.compose)
+
+ // ---------------------------------------------------------------------------------------------
+ // Dependency Injection
+ //
+ // Hilt is used for dependency injection, which helps in managing the object graph and
+ // providing dependencies to different parts of the application.
+ // ---------------------------------------------------------------------------------------------
+ implementation(libs.dagger)
+ ksp(libs.hilt.android.compiler)
+ implementation(libs.hilt.android)
+
+ // ---------------------------------------------------------------------------------------------
+ // Firebase
+ //
+ // The Firebase Bill of Materials (BOM) ensures that all Firebase libraries use compatible
+ // versions. The `firebase-database` dependency is included for real-time data synchronization.
+ // ---------------------------------------------------------------------------------------------
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.database)
+
+ // ---------------------------------------------------------------------------------------------
+ // Google Maps
+ //
+ // These dependencies provide the necessary components for integrating Google Maps into the
+ // application using Jetpack Compose. This includes the Maps Compose library, extended material
+ // icons, and navigation components for Compose.
+ // ---------------------------------------------------------------------------------------------
+ implementation(libs.maps.compose)
+ implementation(libs.maps.utils.ktx)
+
+ // ---------------------------------------------------------------------------------------------
+ // Kotlin Libraries
+ //
+ // Provides additional Kotlin functionalities, such as the `kotlinx-datetime` library for
+ // handling dates and times in a more idiomatic and multiplatform-friendly way.
+ // ---------------------------------------------------------------------------------------------
+ implementation(libs.kotlinx.datetime)
+
+ // ---------------------------------------------------------------------------------------------
+ // Unit Testing
+ //
+ // These dependencies are used for running local unit tests on the JVM. They include JUnit for
+ // the core testing framework, Robolectric for running Android-specific code without an
+ // emulator, and coroutines-test for testing asynchronous code.
+ // ---------------------------------------------------------------------------------------------
+ testImplementation(libs.junit)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.google.truth)
+ testImplementation(libs.mockito.kotlin)
+
+ // ---------------------------------------------------------------------------------------------
+ // Flow Testing
+ //
+ // Turbine is a small testing library for Kotlin Flows. It makes it easy to test Flows by
+ // providing a simple and concise API for asserting on the items that a Flow emits. This is
+ // particularly useful for testing ViewModels that expose data using StateFlow or SharedFlow.
+ // ---------------------------------------------------------------------------------------------
+ testImplementation(libs.turbine)
+
+ // ---------------------------------------------------------------------------------------------
+ // Instrumentation Testing
+ //
+ // These dependencies are for running tests on an Android device or emulator. They include
+ // AndroidX Test libraries for JUnit and Espresso, as well as Compose-specific testing tools
+ // for UI testing.
+ // ---------------------------------------------------------------------------------------------
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+
+ // ---------------------------------------------------------------------------------------------
+ // Debug Tools
+ //
+ // These dependencies are only included in the debug build. They provide tools for inspecting
+ // and debugging the application's UI and other components.
+ // ---------------------------------------------------------------------------------------------
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
+
+secrets {
+ propertiesFileName = "secrets.properties"
+ defaultPropertiesFileName = "local.defaults.properties"
+}
\ No newline at end of file
diff --git a/FireMarkers/app/proguard-rules.pro b/FireMarkers/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/FireMarkers/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/AndroidManifest.xml b/FireMarkers/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..905a35aa
--- /dev/null
+++ b/FireMarkers/app/src/main/AndroidManifest.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/java/com/example/firemarkers/FireMarkersApplication.kt b/FireMarkers/app/src/main/java/com/example/firemarkers/FireMarkersApplication.kt
new file mode 100644
index 00000000..2e8dd5bb
--- /dev/null
+++ b/FireMarkers/app/src/main/java/com/example/firemarkers/FireMarkersApplication.kt
@@ -0,0 +1,82 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers
+
+import android.app.Application
+import android.content.pm.PackageManager
+import android.util.Log
+import android.widget.Toast
+import dagger.hilt.android.HiltAndroidApp
+import java.util.Objects
+import kotlin.text.isBlank
+
+/**
+ * The main application class for the FireMarkers app.
+ *
+ * This class is the entry point of the application and is responsible for initializing Hilt for
+ * dependency injection.
+ */
+@HiltAndroidApp
+class FireMarkersApplication : Application() {
+ /**
+ * Called when the application is starting, before any other application objects have been created.
+ *
+ * This method is used to initialize the application, including setting up Hilt for dependency
+ * injection.
+ */
+ override fun onCreate() {
+ super.onCreate()
+ checkApiKey()
+ }
+
+ /**
+ * Checks if the API key for Google Maps is properly configured in the application's metadata.
+ *
+ * This method retrieves the API key from the application's metadata, specifically looking for
+ * a string value associated with the key "com.google.android.geo.maps.API_KEY".
+ * The key must be present, not blank, and not set to the placeholder value "DEFAULT_API_KEY".
+ *
+ * If any of these checks fail, a Toast message is displayed indicating that the API key is missing or
+ * incorrectly configured, and a RuntimeException is thrown.
+ */
+ private fun checkApiKey() {
+ try {
+ val appInfo =
+ packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
+ val bundle = Objects.requireNonNull(appInfo.metaData)
+
+ val mapsApiKey =
+ bundle.getString("com.google.android.geo.API_KEY") // Key name is important!
+
+ if (mapsApiKey == null || mapsApiKey.isBlank() || mapsApiKey == "DEFAULT_API_KEY") {
+ Toast.makeText(
+ this,
+ "Maps API Key was not set in secrets.properties",
+ Toast.LENGTH_LONG
+ ).show()
+ throw RuntimeException("Maps API Key was not set in secrets.properties")
+ }
+ } catch (e: PackageManager.NameNotFoundException) {
+ Log.e(TAG, "Package name not found.", e)
+ throw RuntimeException("Error getting package info.", e)
+ } catch (e: NullPointerException) {
+ Log.e(TAG, "Error accessing meta-data.", e) // Handle the case where meta-data is completely missing.
+ throw RuntimeException("Error accessing meta-data in manifest", e)
+ }
+ }
+
+ companion object {
+ private val TAG = this::class.java.simpleName
+ }
+}
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/java/com/example/firemarkers/MainActivity.kt b/FireMarkers/app/src/main/java/com/example/firemarkers/MainActivity.kt
new file mode 100644
index 00000000..5658ab24
--- /dev/null
+++ b/FireMarkers/app/src/main/java/com/example/firemarkers/MainActivity.kt
@@ -0,0 +1,160 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AddLocation
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.sp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import com.example.firemarkers.ui.theme.FireMarkersTheme
+import com.example.firemarkers.viewmodel.MarkersViewModel
+import com.google.android.gms.maps.model.BitmapDescriptorFactory
+import com.google.android.gms.maps.model.CameraPosition
+import com.google.android.gms.maps.model.LatLng
+import com.google.maps.android.compose.GoogleMap
+import com.google.maps.android.compose.Marker
+import com.google.maps.android.compose.MarkerState
+import com.google.maps.android.compose.rememberCameraPositionState
+import dagger.hilt.android.AndroidEntryPoint
+
+import androidx.core.view.WindowCompat
+import kotlinx.coroutines.flow.collectLatest
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ enableEdgeToEdge()
+ setContent {
+ FireMarkersTheme {
+ MapScreen(modifier = Modifier.fillMaxSize())
+ }
+ }
+ }
+}
+
+/**
+ * The main screen of the application. This composable function displays the Google Map,
+ * observes the list of markers from the [MarkersViewModel], and renders them. It also
+ * provides a dynamic TopAppBar that changes its controls based on whether the current
+ * device is the "controller" of the animation or an "agent" observing it.
+ *
+ * @param modifier The modifier to be applied to the MapScreen.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MapScreen(modifier: Modifier = Modifier) {
+ val viewModel: MarkersViewModel = hiltViewModel()
+ val markers by viewModel.markers.collectAsState()
+ val animationRunning by viewModel.animationRunning.collectAsState()
+ val hasMarkers by viewModel.hasMarkers.collectAsState()
+ val isController by viewModel.isController.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ val boulder = LatLng(40.0150, -105.2705)
+ val cameraPositionState = rememberCameraPositionState {
+ position = CameraPosition.fromLatLngZoom(boulder, 10f)
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.errorEvents.collectLatest { message ->
+ snackbarHostState.showSnackbar(message)
+ }
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ "FireMarkers",
+ fontSize = 20.sp
+ )
+ },
+ actions = {
+ if (isController) {
+ Text("🎄") // Tree emoji for controller
+ IconButton(
+ onClick = { viewModel.toggleAnimation() },
+ enabled = hasMarkers
+ ) {
+ Icon(
+ imageVector = if (animationRunning) Icons.Default.Stop else Icons.Default.PlayArrow,
+ contentDescription = "Animate Shape"
+ )
+ }
+ IconButton(onClick = { viewModel.seedDatabase() }) {
+ Icon(
+ imageVector = Icons.Default.AddLocation,
+ contentDescription = "Seed Database"
+ )
+ }
+ IconButton(onClick = { viewModel.clearMarkers() }) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Clear Markers"
+ )
+ }
+ } else {
+ // Agent UI
+ IconButton(onClick = { viewModel.takeControl() }) {
+ Text("🎃") // Pumpkin emoji for agent
+ }
+ }
+ }
+ )
+ },
+ modifier = modifier
+ ) { innerPadding ->
+ GoogleMap(
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize(),
+ cameraPositionState = cameraPositionState
+ ) {
+ markers.forEach { markerData ->
+ Marker(
+ state = MarkerState(position = LatLng(markerData.latitude, markerData.longitude)),
+ title = markerData.label,
+ icon = BitmapDescriptorFactory.defaultMarker(markerData.color)
+ )
+ }
+ }
+ }
+}
diff --git a/FireMarkers/app/src/main/java/com/example/firemarkers/data/FirebaseConnectionProvider.kt b/FireMarkers/app/src/main/java/com/example/firemarkers/data/FirebaseConnectionProvider.kt
new file mode 100644
index 00000000..2d4086cc
--- /dev/null
+++ b/FireMarkers/app/src/main/java/com/example/firemarkers/data/FirebaseConnectionProvider.kt
@@ -0,0 +1,33 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers.data
+
+import com.google.firebase.database.FirebaseDatabase
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * A singleton provider for the [FirebaseDatabase] instance.
+ *
+ * This class is responsible for creating and providing a single instance of the [FirebaseDatabase]
+ * throughout the application. It is injected into the [com.example.firemarkers.viewmodel.MarkersViewModel] to provide access to the
+ * database.
+ */
+@Singleton
+class FirebaseConnection @Inject constructor() {
+ /**
+ * The singleton instance of the [FirebaseDatabase].
+ */
+ val database: FirebaseDatabase = FirebaseDatabase.getInstance()
+}
diff --git a/FireMarkers/app/src/main/java/com/example/firemarkers/data/ShapeData.kt b/FireMarkers/app/src/main/java/com/example/firemarkers/data/ShapeData.kt
new file mode 100644
index 00000000..8d5177cc
--- /dev/null
+++ b/FireMarkers/app/src/main/java/com/example/firemarkers/data/ShapeData.kt
@@ -0,0 +1,186 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers.data
+
+import com.google.android.gms.maps.model.BitmapDescriptorFactory
+
+/**
+ * A data class to represent a single point in a shape definition.
+ * @param latOffset The latitude offset from the shape's center.
+ * @param lonOffset The longitude offset from the shape's center.
+ * @param color The hue for the marker's color.
+ */
+data class ShapePoint(val latOffset: Double, val lonOffset: Double, val color: Float)
+
+/**
+ * A static object that holds the vector definitions for shapes.
+ */
+object ShapeData {
+
+ // Base color hues for the shapes
+ private const val ORANGE_HUE = BitmapDescriptorFactory.HUE_ORANGE
+ private const val BLACK_HUE = 0f // Black doesn't have a hue, we'll handle it
+ private const val GREEN_HUE = BitmapDescriptorFactory.HUE_GREEN
+ private const val BROWN_HUE = 30f // Brownish hue
+ private const val YELLOW_HUE = BitmapDescriptorFactory.HUE_YELLOW
+ private const val RED_HUE = BitmapDescriptorFactory.HUE_RED
+ private const val BLUE_HUE = BitmapDescriptorFactory.HUE_AZURE
+
+ /**
+ * Defines the points for a Jack-O'-Lantern shape.
+ * Total points: 56
+ */
+ val jackOLanternShape: List = listOf(
+ // Pumpkin Outline (scaled up)
+ ShapePoint(0.2, 0.0, ORANGE_HUE),
+ ShapePoint(0.196, 0.04, ORANGE_HUE),
+ ShapePoint(0.184, 0.08, ORANGE_HUE),
+ ShapePoint(0.16, 0.12, ORANGE_HUE),
+ ShapePoint(0.12, 0.16, ORANGE_HUE),
+ ShapePoint(0.08, 0.184, ORANGE_HUE),
+ ShapePoint(0.04, 0.196, ORANGE_HUE),
+ ShapePoint(0.0, 0.2, ORANGE_HUE),
+ ShapePoint(-0.04, 0.196, ORANGE_HUE),
+ ShapePoint(-0.08, 0.184, ORANGE_HUE),
+ ShapePoint(-0.12, 0.16, ORANGE_HUE),
+ ShapePoint(-0.16, 0.12, ORANGE_HUE),
+ ShapePoint(-0.184, 0.08, ORANGE_HUE),
+ ShapePoint(-0.196, 0.04, ORANGE_HUE),
+ ShapePoint(-0.2, 0.0, ORANGE_HUE),
+ ShapePoint(-0.196, -0.04, ORANGE_HUE),
+ ShapePoint(-0.184, -0.08, ORANGE_HUE),
+ ShapePoint(-0.16, -0.12, ORANGE_HUE),
+ ShapePoint(-0.12, -0.16, ORANGE_HUE),
+ ShapePoint(-0.08, -0.184, ORANGE_HUE),
+ ShapePoint(-0.04, -0.196, ORANGE_HUE),
+ ShapePoint(0.0, -0.2, ORANGE_HUE),
+ ShapePoint(0.04, -0.196, ORANGE_HUE),
+ ShapePoint(0.08, -0.184, ORANGE_HUE),
+ ShapePoint(0.12, -0.16, ORANGE_HUE),
+ ShapePoint(0.16, -0.12, ORANGE_HUE),
+ ShapePoint(0.184, -0.08, ORANGE_HUE),
+ ShapePoint(0.196, -0.04, ORANGE_HUE),
+
+ // Left Eye
+ ShapePoint(0.1, -0.1, BLACK_HUE),
+ ShapePoint(0.04, -0.06, BLACK_HUE),
+ ShapePoint(0.04, -0.14, BLACK_HUE),
+
+ // Right Eye
+ ShapePoint(0.1, 0.1, BLACK_HUE),
+ ShapePoint(0.04, 0.06, BLACK_HUE),
+ ShapePoint(0.04, 0.14, BLACK_HUE),
+
+ // Nose
+ ShapePoint(-0.02, 0.0, BLACK_HUE),
+ ShapePoint(-0.06, -0.04, BLACK_HUE),
+ ShapePoint(-0.06, 0.04, BLACK_HUE),
+
+ // Mouth with teeth (more points)
+ ShapePoint(-0.1, -0.12, BLACK_HUE),
+ ShapePoint(-0.12, -0.1, BLACK_HUE), // down
+ ShapePoint(-0.1, -0.08, BLACK_HUE), // up (tooth)
+ ShapePoint(-0.12, -0.06, BLACK_HUE), // down
+ ShapePoint(-0.1, -0.04, BLACK_HUE), // up (tooth)
+ ShapePoint(-0.12, -0.02, BLACK_HUE), // down
+ ShapePoint(-0.1, 0.0, BLACK_HUE), // up (center)
+ ShapePoint(-0.12, 0.02, BLACK_HUE), // down
+ ShapePoint(-0.1, 0.04, BLACK_HUE), // up (tooth)
+ ShapePoint(-0.12, 0.06, BLACK_HUE), // down
+ ShapePoint(-0.1, 0.08, BLACK_HUE), // up (tooth)
+ ShapePoint(-0.12, 0.1, BLACK_HUE), // down
+ ShapePoint(-0.1, 0.12, BLACK_HUE),
+ ShapePoint(-0.14, 0.0, BLACK_HUE), // Center dip
+ ShapePoint(-0.15, -0.12, BLACK_HUE),
+ ShapePoint(-0.15, 0.12, BLACK_HUE),
+ ShapePoint(0.16, 0.0, ORANGE_HUE), // Extra point to match count
+ ShapePoint(0.0, 0.0, ORANGE_HUE), // Extra point to match count
+ ShapePoint(-0.16, 0.0, ORANGE_HUE) // Extra point to match count
+ )
+
+ /**
+ * Defines the points for a Christmas Tree shape.
+ * Must have the same number of points as jackOLanternShape.
+ * Total points: 56
+ */
+ val christmasTreeShape: List = listOf(
+ // Star Topper (5 points)
+ ShapePoint(0.22, 0.0, YELLOW_HUE),
+ ShapePoint(0.19, 0.03, YELLOW_HUE),
+ ShapePoint(0.19, -0.03, YELLOW_HUE),
+ ShapePoint(0.17, 0.05, YELLOW_HUE),
+ ShapePoint(0.17, -0.05, YELLOW_HUE),
+
+ // Top Tier (6 points)
+ ShapePoint(0.15, 0.0, GREEN_HUE),
+ ShapePoint(0.12, 0.08, GREEN_HUE),
+ ShapePoint(0.12, -0.08, GREEN_HUE),
+ ShapePoint(0.13, 0.04, GREEN_HUE),
+ ShapePoint(0.13, -0.04, GREEN_HUE),
+ ShapePoint(0.10, 0.0, GREEN_HUE),
+
+ // Middle Tier (10 points)
+ ShapePoint(0.08, 0.12, GREEN_HUE),
+ ShapePoint(0.08, -0.12, GREEN_HUE),
+ ShapePoint(0.06, 0.08, GREEN_HUE),
+ ShapePoint(0.06, -0.08, GREEN_HUE),
+ ShapePoint(0.04, 0.16, GREEN_HUE),
+ ShapePoint(0.04, -0.16, GREEN_HUE),
+ ShapePoint(0.02, 0.12, GREEN_HUE),
+ ShapePoint(0.02, -0.12, GREEN_HUE),
+ ShapePoint(0.0, 0.08, GREEN_HUE),
+ ShapePoint(0.0, -0.08, GREEN_HUE),
+
+ // Bottom Tier (14 points)
+ ShapePoint(-0.02, 0.22, GREEN_HUE),
+ ShapePoint(-0.02, -0.22, GREEN_HUE),
+ ShapePoint(-0.04, 0.18, GREEN_HUE),
+ ShapePoint(-0.04, -0.18, GREEN_HUE),
+ ShapePoint(-0.06, 0.24, GREEN_HUE),
+ ShapePoint(-0.06, -0.24, GREEN_HUE),
+ ShapePoint(-0.08, 0.20, GREEN_HUE),
+ ShapePoint(-0.08, -0.20, GREEN_HUE),
+ ShapePoint(-0.1, 0.16, GREEN_HUE),
+ ShapePoint(-0.1, -0.16, GREEN_HUE),
+ ShapePoint(-0.12, 0.12, GREEN_HUE),
+ ShapePoint(-0.12, -0.12, GREEN_HUE),
+ ShapePoint(-0.14, 0.08, GREEN_HUE),
+ ShapePoint(-0.14, -0.08, GREEN_HUE),
+
+ // Trunk (4 points)
+ ShapePoint(-0.16, 0.04, BROWN_HUE),
+ ShapePoint(-0.16, -0.04, BROWN_HUE),
+ ShapePoint(-0.2, 0.04, BROWN_HUE),
+ ShapePoint(-0.2, -0.04, BROWN_HUE),
+
+ // Ornaments (17 points)
+ ShapePoint(0.11, 0.0, RED_HUE),
+ ShapePoint(0.07, 0.05, BLUE_HUE),
+ ShapePoint(0.07, -0.05, YELLOW_HUE),
+ ShapePoint(0.03, 0.1, RED_HUE),
+ ShapePoint(0.03, -0.1, BLUE_HUE),
+ ShapePoint(-0.01, 0.15, YELLOW_HUE),
+ ShapePoint(-0.01, -0.15, RED_HUE),
+ ShapePoint(-0.05, 0.2, BLUE_HUE),
+ ShapePoint(-0.05, -0.2, YELLOW_HUE),
+ ShapePoint(-0.09, 0.15, RED_HUE),
+ ShapePoint(-0.09, -0.15, BLUE_HUE),
+ ShapePoint(-0.13, 0.0, YELLOW_HUE),
+ ShapePoint(0.0, 0.0, RED_HUE),
+ ShapePoint(-0.04, 0.0, BLUE_HUE),
+ ShapePoint(-0.08, 0.0, YELLOW_HUE),
+ ShapePoint(-0.12, 0.05, RED_HUE),
+ ShapePoint(-0.12, -0.05, BLUE_HUE)
+ )
+}
diff --git a/FireMarkers/app/src/main/java/com/example/firemarkers/di/AppModule.kt b/FireMarkers/app/src/main/java/com/example/firemarkers/di/AppModule.kt
new file mode 100644
index 00000000..46054524
--- /dev/null
+++ b/FireMarkers/app/src/main/java/com/example/firemarkers/di/AppModule.kt
@@ -0,0 +1,43 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers.di
+
+import com.example.firemarkers.data.FirebaseConnection
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+/**
+ * The Hilt module for providing dependencies to the application.
+ *
+ * This module is responsible for providing the [FirebaseConnection] as a singleton dependency
+ * to the application.
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ /**
+ * Provides a singleton instance of the [FirebaseConnection].
+ *
+ * @return A singleton instance of the [FirebaseConnection].
+ */
+ @Provides
+ @Singleton
+ fun provideFirebaseConnection(): FirebaseConnection {
+ return FirebaseConnection()
+ }
+}
diff --git a/FireMarkers/app/src/main/java/com/example/firemarkers/model/MarkerData.kt b/FireMarkers/app/src/main/java/com/example/firemarkers/model/MarkerData.kt
new file mode 100644
index 00000000..947463f2
--- /dev/null
+++ b/FireMarkers/app/src/main/java/com/example/firemarkers/model/MarkerData.kt
@@ -0,0 +1,37 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers.model
+
+/**
+ * A data class representing a single marker on the map.
+ *
+ * This class is used to store the data for each marker, including its location, label, and style.
+ * It is used by the [com.example.firemarkers.viewmodel.MarkersViewModel] to manage the state of the markers and by the UI to
+ * render the markers on the map.
+ *
+ * @property id The unique ID of the marker.
+ * @property latitude The latitude of the marker's location.
+ * @property longitude The longitude of the marker's location.
+ * @property label The label to be displayed for the marker.
+ * @property style The style of the marker.
+ * @property color The color of the marker, represented as a hue value.
+ */
+data class MarkerData(
+ val id: String = "",
+ val latitude: Double = 0.0,
+ val longitude: Double = 0.0,
+ val label: String = "",
+ val style: String = "",
+ val color: Float = 0.0f
+)
diff --git a/FireMarkers/app/src/main/java/com/example/firemarkers/ui/theme/Color.kt b/FireMarkers/app/src/main/java/com/example/firemarkers/ui/theme/Color.kt
new file mode 100644
index 00000000..99ff2dda
--- /dev/null
+++ b/FireMarkers/app/src/main/java/com/example/firemarkers/ui/theme/Color.kt
@@ -0,0 +1,24 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/java/com/example/firemarkers/ui/theme/Theme.kt b/FireMarkers/app/src/main/java/com/example/firemarkers/ui/theme/Theme.kt
new file mode 100644
index 00000000..33201355
--- /dev/null
+++ b/FireMarkers/app/src/main/java/com/example/firemarkers/ui/theme/Theme.kt
@@ -0,0 +1,69 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun FireMarkersTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/java/com/example/firemarkers/ui/theme/Type.kt b/FireMarkers/app/src/main/java/com/example/firemarkers/ui/theme/Type.kt
new file mode 100644
index 00000000..635602fa
--- /dev/null
+++ b/FireMarkers/app/src/main/java/com/example/firemarkers/ui/theme/Type.kt
@@ -0,0 +1,47 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/java/com/example/firemarkers/viewmodel/MarkersViewModel.kt b/FireMarkers/app/src/main/java/com/example/firemarkers/viewmodel/MarkersViewModel.kt
new file mode 100644
index 00000000..b6d5e0f9
--- /dev/null
+++ b/FireMarkers/app/src/main/java/com/example/firemarkers/viewmodel/MarkersViewModel.kt
@@ -0,0 +1,404 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers.viewmodel
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.firemarkers.data.FirebaseConnection
+import com.example.firemarkers.data.ShapeData
+import com.example.firemarkers.data.ShapePoint
+import com.example.firemarkers.model.MarkerData
+import com.google.android.gms.maps.model.LatLng
+import com.google.firebase.database.DataSnapshot
+import com.google.firebase.database.DatabaseError
+import com.google.firebase.database.ServerValue
+import com.google.firebase.database.ValueEventListener
+import com.google.maps.android.ktx.utils.withSphericalLinearInterpolation
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.util.UUID
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * ViewModel for the FireMarkers application, demonstrating integration of Google Maps Platform
+ * with Firebase Realtime Database for synchronized marker animations.
+ *
+ * This class serves as a reference implementation for Android developers looking to integrate
+ * Google Maps with Firebase. It illustrates basic concepts of real-time data synchronization
+ * and animated map markers.
+ *
+ * **Note:** While this ViewModel demonstrates Firebase integration, the Firebase-specific
+ * implementation aspects are for illustrative purposes and may not represent the most
+ * optimized or production-ready patterns for all Firebase use cases. The primary focus
+ * is on showcasing Google Maps Platform capabilities with a real-time backend.
+ *
+ * It manages the state for the map screen, including the markers and their synchronized animation.
+ *
+ * This ViewModel is the core of the application's logic, orchestrating data flow between the
+ * Firebase Realtime Database and the UI. It employs a controller/agent architecture to ensure
+ * that animations are perfectly synchronized across all connected devices.
+ *
+ * ### Architecture and Synchronization
+ *
+ * A key concept is the **controller/agent** model. At any given time, only one device acts as the
+ * "controller." This device is responsible for:
+ * 1. Running the animation loop (`startAnimationDriver`).
+ * 2. Calculating the animation's progress (`fraction`).
+ * 3. Writing the updated animation state to the `/animation` node in Firebase.
+ * 4. Seeding the initial marker data or clearing all markers.
+ *
+ * All other connected devices act as "agents." They passively listen for changes to the `/animation`
+ * node and update their UI accordingly. A device can become the controller by calling `takeControl()`.
+ *
+ * The shared state, including whether the animation is running, its progress, and the current
+ * controller's ID, is stored in the `AnimationState` data class and persisted in Firebase.
+ *
+ * ### State Management and Data Flow
+ *
+ * This ViewModel uses `StateFlow` to expose data to the Compose UI in a reactive way.
+ * - **`_markers`**: A private `MutableStateFlow` holding the raw list of `MarkerData` fetched
+ * from the `/markers` node in Firebase.
+ * - **`_animationStateDB`**: A private `MutableStateFlow` that mirrors the state of the `/animation`
+ * node in Firebase.
+ * - **`markers`**: A public `StateFlow` that is the main output for the UI. It's created by using
+ * the `combine` operator on `_markers` and `_animationStateDB`. For each emission, it calculates
+ * the new interpolated geographic coordinates and colors for every marker based on the current
+ * animation `fraction`, ensuring a smooth visual transition.
+ * - **`isController`**, **`animationRunning`**, **`hasMarkers`**: These are simple `StateFlow`s
+ * derived from the primary state flows to control UI elements like buttons and icons.
+ *
+ * @param firebaseConnection The Hilt-injected provider for the Firebase Realtime Database instance.
+ */
+@HiltViewModel
+class MarkersViewModel @Inject constructor(
+ private val firebaseConnection: FirebaseConnection
+) : ViewModel() {
+
+ internal val viewModelId = UUID.randomUUID().toString().substring(0, 4)
+ private val _markers = MutableStateFlow>(emptyList())
+ private val _animationStateDB = MutableStateFlow(AnimationState())
+ private var animationJob: Job? = null
+
+ private val _errorEvents = MutableSharedFlow()
+ val errorEvents = _errorEvents.asSharedFlow()
+
+ val markers: StateFlow> = combine(
+ _markers,
+ _animationStateDB
+ ) { markers, animState ->
+ if (markers.isEmpty()) {
+ markers
+ } else {
+ updateMarkers(markers, animState.fraction)
+ }
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = emptyList()
+ )
+
+ val animationRunning: StateFlow = _animationStateDB.map { it.running }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = false
+ )
+
+ val hasMarkers: StateFlow = _markers.map { it.isNotEmpty() }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = false
+ )
+
+ val isController: StateFlow = _animationStateDB.map { it.controllerId == viewModelId }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = false
+ )
+
+ init {
+ Log.d(TAG, "[$viewModelId] ViewModel initialized.")
+ listenForMarkerUpdates()
+ listenForAnimationState()
+ }
+
+ private fun listenForMarkerUpdates() {
+ firebaseConnection.database.getReference("markers")
+ .addValueEventListener(object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val markerList = snapshot.children.mapNotNull { it.getValue(MarkerData::class.java) }
+ _markers.value = markerList
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ Log.e(TAG, "[$viewModelId] Database error on markers: ${error.message}")
+ viewModelScope.launch {
+ _errorEvents.emit("Database error on markers: ${error.message}")
+ }
+ }
+ })
+ }
+
+ private fun listenForAnimationState() {
+ firebaseConnection.database.getReference("animation")
+ .addValueEventListener(object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val animState = snapshot.getValue(AnimationState::class.java) ?: AnimationState()
+ _animationStateDB.value = animState
+ Log.d(TAG, "[$viewModelId] DB anim state received: $animState")
+
+
+ val shouldDrive = animState.controllerId == viewModelId && animState.running
+ val isDriving = animationJob?.isActive == true
+
+ Log.d(TAG, "[$viewModelId] Evaluating driver state: shouldDrive=$shouldDrive, isDriving=$isDriving")
+
+ if (shouldDrive && !isDriving) {
+ Log.d(TAG, "[$viewModelId] Condition met: Starting animation driver.")
+ startAnimationDriver()
+ } else if (!shouldDrive && isDriving) {
+ Log.d(TAG, "[$viewModelId] Condition met: Stopping animation driver.")
+ animationJob?.cancel()
+ animationJob = null
+ }
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ Log.e(TAG, "[$viewModelId] DB error on animation: ${error.message}")
+ viewModelScope.launch {
+ _errorEvents.emit("DB error on animation: ${error.message}")
+ }
+ }
+ })
+ }
+
+ /**
+ * Toggles the animation state (running/paused) in Firebase.
+ *
+ * This action is only permitted if the current ViewModel instance is the designated controller.
+ * If a non-controller attempts to toggle the animation, the action is ignored.
+ * The updated animation state is written to the `/animation` node in Firebase, which
+ * then propagates to all connected clients.
+ */
+ fun toggleAnimation() {
+ if (_animationStateDB.value.controllerId != viewModelId) {
+ Log.w(TAG, "[$viewModelId] Agent tried to toggle animation. Ignoring.")
+ return
+ }
+
+ val animRef = firebaseConnection.database.getReference("animation")
+ val currentState = _animationStateDB.value
+ val newState = currentState.copy(running = !currentState.running)
+ Log.d(TAG, "[$viewModelId] toggleAnimation: Writing new state to DB: $newState")
+ animRef.setValue(newState.toMap())
+ }
+
+ /**
+ * Attempts to designate the current ViewModel instance as the animation controller.
+ *
+ * This updates the `controllerId` in the `/animation` node in Firebase to this ViewModel's
+ * unique ID. When successful, this instance will become responsible for driving the animation.
+ * The animation is automatically paused when control is taken.
+ */
+ fun takeControl() {
+ Log.d(TAG, "[$viewModelId] takeControl: Attempting to become controller.")
+ val animRef = firebaseConnection.database.getReference("animation")
+ val currentState = _animationStateDB.value
+ val newState = currentState.copy(
+ controllerId = viewModelId,
+ running = false
+ )
+ Log.d(TAG, "[$viewModelId] takeControl: Writing new state to DB: $newState")
+ animRef.setValue(newState.toMap())
+ }
+
+ private fun startAnimationDriver() {
+ animationJob?.cancel()
+ animationJob = viewModelScope.launch {
+ var loopState = _animationStateDB.value
+ Log.d(TAG, "[$viewModelId] Animation driver started with initial state: $loopState")
+
+ while (isActive) {
+ val animRef = firebaseConnection.database.getReference("animation")
+
+ var nextFraction = loopState.fraction + loopState.direction * ANIMATION_STEP_SIZE
+ var nextDirection = loopState.direction
+
+ if (nextFraction >= 1.0 || nextFraction <= 0.0) {
+ nextFraction = nextFraction.coerceIn(0.0, 1.0)
+ nextDirection *= -1
+ }
+
+ loopState = loopState.copy(
+ fraction = nextFraction,
+ direction = nextDirection
+ )
+ Log.d(TAG, "[$viewModelId] Driver loop tick: Writing new state to DB: $loopState")
+ animRef.setValue(loopState.toMap())
+
+ if (nextFraction >= 1.0 || nextFraction <= 0.0) {
+ delay(PAUSE_DURATION)
+ } else {
+ delay(ANIMATION_DELAY)
+ }
+ }
+ }
+ }
+
+ private fun updateMarkers(markers: List, fraction: Double): List {
+ val newLocations = Shape.locationPairs.map { (startPoint, endPoint) ->
+ startPoint.withSphericalLinearInterpolation(endPoint, fraction)
+ }
+ val colors = Shape.colorPairs.map { (a, b) -> a + (b - a) * fraction }
+
+ return markers.mapIndexed { index, marker ->
+ marker.copy(
+ latitude = newLocations[index].latitude,
+ longitude = newLocations[index].longitude,
+ label = if (fraction > 0.5) Shape.Tree.label else Shape.JackOLantern.label,
+ color = colors[index].toFloat()
+ )
+ }
+ }
+
+ /**
+ * Seeds the Firebase Realtime Database with an initial set of marker data.
+ *
+ * This operation is only allowed if the current ViewModel instance is the controller,
+ * or if no controller is currently assigned (allowing the first user to initialize the data).
+ * It populates the `/markers` node with a predefined set of `MarkerData` objects
+ * representing the "Jack-o'-lantern" shape.
+ */
+ fun seedDatabase() {
+ // Only the controller can seed the database.
+ // The `isNotEmpty` check allows the very first user of the app (when no controller
+ // is assigned yet) to claim control and seed the initial data.
+ if (_animationStateDB.value.controllerId != viewModelId && _animationStateDB.value.controllerId.isNotEmpty()) {
+ Log.w(TAG, "[$viewModelId] Agent cannot seed. Take control first.")
+ return
+ }
+ Log.d(TAG, "[$viewModelId] Seeding database as controller.")
+ val animState = AnimationState(controllerId = viewModelId)
+ firebaseConnection.database.getReference("animation").setValue(animState.toMap())
+ viewModelScope.launch {
+ val databaseReference = firebaseConnection.database.getReference("markers")
+ val markersToSeed = Shape.JackOLantern.shapeData.map { shapePoint ->
+ val markerId = databaseReference.push().key ?: ""
+ MarkerData(
+ id = markerId,
+ latitude = BOULDER_LAT_LNG.latitude + shapePoint.latOffset,
+ longitude = BOULDER_LAT_LNG.longitude + shapePoint.lonOffset,
+ label = "Pumpkin",
+ style = "jack-o-lantern",
+ color = shapePoint.color
+ )
+ }
+ databaseReference.setValue(markersToSeed)
+ }
+ }
+
+ /**
+ * Clears all marker data from the Firebase Realtime Database.
+ *
+ * This operation is only allowed if the current ViewModel instance is the controller,
+ * or if no controller is currently assigned (allowing the first user to clear data).
+ * It removes all data from the `/markers` node in Firebase and resets the animation state.
+ */
+ fun clearMarkers() {
+ if (_animationStateDB.value.controllerId != viewModelId && _animationStateDB.value.controllerId.isNotEmpty()) {
+ Log.w(TAG, "[$viewModelId] Agent cannot clear. Take control first.")
+ return
+ }
+ Log.d(TAG, "[$viewModelId] Clearing markers as controller.")
+ firebaseConnection.database.getReference("animation").setValue(AnimationState(controllerId = viewModelId))
+ firebaseConnection.database.getReference("markers").removeValue()
+ }
+
+ /**
+ * Represents the complete, synchronized state of the animation.
+ * This state is stored in the Firebase Realtime Database under the `/animation` node
+ * and serves as the single source of truth for all connected clients.
+ *
+ * @property running True if the animation should be playing, false if paused.
+ * Note: This property is named `running` instead of `isRunning` to avoid
+ * a known issue with Firebase's automatic deserialization of Kotlin
+ * data classes where properties prefixed with "is" may not be correctly
+ * mapped.
+ * @property fraction The current progress of the animation, from 0.0 (start shape) to 1.0 (end shape).
+ * @property direction The current direction of the animation (-1.0 or 1.0).
+ * @property controllerId The unique ID of the ViewModel instance currently driving the animation.
+ * @property timestamp The server-side timestamp of the last state update.
+ */
+ internal data class AnimationState(
+ val running: Boolean = false,
+ val fraction: Double = 0.0,
+ val direction: Double = 1.0,
+ val controllerId: String = "",
+ val timestamp: Long = 0
+ ) {
+ /**
+ * Converts this object to a Map for writing to Firebase.
+ * This is necessary to include the `ServerValue.TIMESTAMP`, which allows Firebase
+ * to write a consistent, server-side timestamp for when the update occurred.
+ */
+ fun toMap(): Map {
+ return mapOf(
+ "running" to running,
+ "fraction" to fraction,
+ "direction" to direction,
+ "controllerId" to controllerId,
+ "timestamp" to ServerValue.TIMESTAMP
+ )
+ }
+ }
+
+ private sealed class Shape(val shapeData: List, val label: String) {
+ val locations = shapeData.map {
+ BOULDER_LAT_LNG + it
+ }
+ val colors = shapeData.map { it.color }
+
+ data object Tree : Shape(ShapeData.christmasTreeShape, "Tree")
+ data object JackOLantern : Shape(ShapeData.jackOLanternShape, "Pumpkin")
+
+ companion object {
+ val locationPairs by lazy { JackOLantern.locations.zip(Tree.locations) }
+ val colorPairs by lazy { JackOLantern.colors.zip(Tree.colors) }
+ }
+ }
+
+ companion object {
+ private const val TAG = "MarkersViewModel"
+ private val BOULDER_LAT_LNG = LatLng(40.0150, -105.2705)
+ private val ANIMATION_DELAY = 100.milliseconds
+ private val PAUSE_DURATION = 1000.milliseconds
+ private const val ANIMATION_STEP_SIZE = 0.05
+ }
+}
+
+private operator fun LatLng.plus(other: ShapePoint): LatLng {
+ return LatLng(other.latOffset + this.latitude, other.lonOffset + this.longitude)
+}
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/res/drawable/ic_launcher_background.xml b/FireMarkers/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..bdf5ba85
--- /dev/null
+++ b/FireMarkers/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FireMarkers/app/src/main/res/drawable/ic_launcher_foreground.xml b/FireMarkers/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..27a3ba98
--- /dev/null
+++ b/FireMarkers/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/FireMarkers/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..4293905e
--- /dev/null
+++ b/FireMarkers/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/FireMarkers/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..4293905e
--- /dev/null
+++ b/FireMarkers/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/FireMarkers/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000..c209e78e
Binary files /dev/null and b/FireMarkers/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/FireMarkers/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/FireMarkers/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..b2dfe3d1
Binary files /dev/null and b/FireMarkers/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/FireMarkers/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/FireMarkers/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000..4f0f1d64
Binary files /dev/null and b/FireMarkers/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/FireMarkers/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/FireMarkers/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..62b611da
Binary files /dev/null and b/FireMarkers/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/FireMarkers/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/FireMarkers/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000..948a3070
Binary files /dev/null and b/FireMarkers/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/FireMarkers/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/FireMarkers/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..1b9a6956
Binary files /dev/null and b/FireMarkers/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/FireMarkers/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/FireMarkers/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..28d4b77f
Binary files /dev/null and b/FireMarkers/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/FireMarkers/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/FireMarkers/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9287f508
Binary files /dev/null and b/FireMarkers/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/FireMarkers/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/FireMarkers/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..aa7d6427
Binary files /dev/null and b/FireMarkers/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/FireMarkers/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/FireMarkers/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9126ae37
Binary files /dev/null and b/FireMarkers/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/FireMarkers/app/src/main/res/values/colors.xml b/FireMarkers/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..ae25b229
--- /dev/null
+++ b/FireMarkers/app/src/main/res/values/colors.xml
@@ -0,0 +1,26 @@
+
+
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/res/values/strings.xml b/FireMarkers/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..b57f8b52
--- /dev/null
+++ b/FireMarkers/app/src/main/res/values/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Fire Markers
+
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/res/values/themes.xml b/FireMarkers/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..66717c09
--- /dev/null
+++ b/FireMarkers/app/src/main/res/values/themes.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/res/xml/backup_rules.xml b/FireMarkers/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..636cad63
--- /dev/null
+++ b/FireMarkers/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FireMarkers/app/src/main/res/xml/data_extraction_rules.xml b/FireMarkers/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..da8cc9d4
--- /dev/null
+++ b/FireMarkers/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FireMarkers/app/src/test/java/com/example/firemarkers/CustomTestRunner.kt b/FireMarkers/app/src/test/java/com/example/firemarkers/CustomTestRunner.kt
new file mode 100644
index 00000000..f6fe3bb8
--- /dev/null
+++ b/FireMarkers/app/src/test/java/com/example/firemarkers/CustomTestRunner.kt
@@ -0,0 +1,18 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers
+
+import org.robolectric.RobolectricTestRunner
+
+class CustomTestRunner(klass: Class<*>) : RobolectricTestRunner(klass)
diff --git a/FireMarkers/app/src/test/java/com/example/firemarkers/viewmodel/MarkersViewModelTest.kt b/FireMarkers/app/src/test/java/com/example/firemarkers/viewmodel/MarkersViewModelTest.kt
new file mode 100644
index 00000000..f0df895a
--- /dev/null
+++ b/FireMarkers/app/src/test/java/com/example/firemarkers/viewmodel/MarkersViewModelTest.kt
@@ -0,0 +1,168 @@
+// Copyright 2025 Google LLC
+//
+// 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.example.firemarkers.viewmodel
+
+import com.example.firemarkers.CustomTestRunner
+import com.example.firemarkers.data.FirebaseConnection
+import com.google.common.truth.Truth.assertThat
+import com.google.firebase.database.DataSnapshot
+import com.google.firebase.database.DatabaseError
+import com.google.firebase.database.DatabaseReference
+import com.google.firebase.database.FirebaseDatabase
+import com.google.firebase.database.ValueEventListener
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.annotation.Config
+import app.cash.turbine.test
+
+@RunWith(CustomTestRunner::class)
+@Config(manifest=Config.NONE)
+@ExperimentalCoroutinesApi
+class MarkersViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ private lateinit var viewModel: MarkersViewModel
+ private val mockFirebaseConnection: FirebaseConnection = mock()
+ private val mockFirebaseDatabase: FirebaseDatabase = mock()
+ private val mockMarkersRef: DatabaseReference = mock()
+ private val mockAnimationRef: DatabaseReference = mock()
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ whenever(mockFirebaseConnection.database).thenReturn(mockFirebaseDatabase)
+ whenever(mockFirebaseDatabase.getReference("markers")).thenReturn(mockMarkersRef)
+ whenever(mockFirebaseDatabase.getReference("animation")).thenReturn(mockAnimationRef)
+ whenever(mockMarkersRef.addValueEventListener(any())).thenReturn(mock())
+ whenever(mockAnimationRef.addValueEventListener(any())).thenReturn(mock())
+
+ viewModel = MarkersViewModel(mockFirebaseConnection)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `isController is true when controllerId matches viewModelId`() = runTest {
+ val listenerCaptor = argumentCaptor()
+ verify(mockAnimationRef).addValueEventListener(listenerCaptor.capture())
+
+ // Use Turbine to test the flow
+ viewModel.isController.test {
+ // The initial value is always false
+ assertThat(awaitItem()).isFalse()
+
+ // Simulate the Firebase update making this VM the controller
+ val mockSnapshot = mockAnimationStateSnapshot(
+ MarkersViewModel.AnimationState(controllerId = viewModel.viewModelId)
+ )
+ listenerCaptor.firstValue.onDataChange(mockSnapshot)
+
+ // Assert that the flow now emits true
+ assertThat(awaitItem()).isTrue()
+ }
+ }
+
+ @Test
+ fun `isController is false when controllerId does not match`() = runTest {
+ val listenerCaptor = argumentCaptor()
+ verify(mockAnimationRef).addValueEventListener(listenerCaptor.capture())
+
+ val mockSnapshot = mockAnimationStateSnapshot(
+ MarkersViewModel.AnimationState(controllerId = "some-other-id")
+ )
+ listenerCaptor.firstValue.onDataChange(mockSnapshot)
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ assertThat(viewModel.isController.value).isFalse()
+ }
+
+ @Test
+ fun `toggleAnimation does nothing if not controller`() = runTest {
+ val listenerCaptor = argumentCaptor()
+ verify(mockAnimationRef).addValueEventListener(listenerCaptor.capture())
+ val mockSnapshot = mockAnimationStateSnapshot(
+ MarkersViewModel.AnimationState(controllerId = "not-this-vm")
+ )
+ listenerCaptor.firstValue.onDataChange(mockSnapshot)
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ viewModel.toggleAnimation()
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ verify(mockAnimationRef, never()).setValue(any())
+ }
+
+ @Test
+ fun `takeControl updates controllerId in Firebase`() = runTest {
+ viewModel.takeControl()
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ val stateMapCaptor = argumentCaptor