feat: Introduce FireMarkers Sample (#2325)

* feat: Adds a new sample demonstrating how to use Google Maps with Firebase Realtime Database on Android.

* feat(viewmodel): Add tests and error handling for controller logic

This commit introduces unit tests for the `MarkersViewModel` and enhances the app's robustness by adding error handling and improving documentation.

- **ViewModel Unit Tests:** Adds unit tests for `MarkersViewModel` using Mockito, Turbine, and Robolectric. The tests verify the controller/agent logic, ensuring that state is correctly managed based on the `controllerId` from Firebase. It also tests the new error reporting mechanism.
- **Error Handling:** Implements a `SharedFlow` in the `MarkersViewModel` to propagate database errors to the UI. The `MainActivity` now observes this flow and displays errors to the user in a `Snackbar`.
- **Architecture Documentation:** Replaces the static SVG architecture diagram with a more detailed Mermaid diagram in `ARCHITECTURE.md`. The new documentation explains the controller/agent synchronization pattern used for animations.
- **Dependency Updates:** Upgrades Gradle to version 9.1.0 and adds `mockito-kotlin` and `turbine` as test dependencies. The `libs.versions.toml` file is reorganized for better clarity.

* chore: Configure Gradle JVM args and expose ViewModel property

This commit includes two maintenance changes: enabling custom JVM arguments for the Gradle daemon and updating the visibility of a property in the `MarkersViewModel`.

* chore: Annotate the version catalog and build.gradle.kts file

- **Gradle Build Documentation:** Introduces extensive documentation and organization to the `libs.versions.toml` and `app/build.gradle.kts` files. Dependencies, plugins, and versions are now grouped logically with comments explaining their purpose, improving maintainability and clarity.
- **README Update:** The main `README.md` is updated to include a description of the new `FireMarkers` sample.
- **Manifest Cleanup:** Removes the redundant `android:label` from the `MainActivity` in the manifest.

* chore: adds copyright to the new source files

* chore: headers

---------

Co-authored-by: Enrique López Mañas <eenriquelopez@gmail.com>
This commit is contained in:
Dale Hawkins 2025-10-31 01:23:30 -06:00 committed by GitHub
parent de889b7340
commit ad237b2607
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 3039 additions and 0 deletions

34
FireMarkers/.gitignore vendored Normal file
View File

@ -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

View File

@ -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<br>(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`.
```

201
FireMarkers/LICENSE Normal file
View File

@ -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.

75
FireMarkers/README.md Normal file
View File

@ -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 |
|:--------------------------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------|
| <img src="screenshots/jack-o-lantern.png" alt="Screenshot of the controller UI with animation controls" width="400"/> | <img src="screenshots/tree.png" alt="Screenshot of the agent UI with the 'Take Control' button" width="400"/> |
## 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.

1
FireMarkers/app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -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"
}

21
FireMarkers/app/proguard-rules.pro vendored Normal file
View File

@ -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

View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".FireMarkersApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FireMarkers">
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}"/>
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.FireMarkers">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -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
}
}

View File

@ -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)
)
}
}
}
}

View File

@ -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()
}

View File

@ -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<ShapePoint> = 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<ShapePoint> = 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)
)
}

View File

@ -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()
}
}

View File

@ -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
)

View File

@ -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)

View File

@ -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
)
}

View File

@ -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
)
*/
)

View File

@ -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<List<MarkerData>>(emptyList())
private val _animationStateDB = MutableStateFlow(AnimationState())
private var animationJob: Job? = null
private val _errorEvents = MutableSharedFlow<String>()
val errorEvents = _errorEvents.asSharedFlow()
val markers: StateFlow<List<MarkerData>> = 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<Boolean> = _animationStateDB.map { it.running }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = false
)
val hasMarkers: StateFlow<Boolean> = _markers.map { it.isNotEmpty() }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = false
)
val isController: StateFlow<Boolean> = _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<MarkerData>, fraction: Double): List<MarkerData> {
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<String, Any> {
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<ShapePoint>, 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)
}

View File

@ -0,0 +1,186 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,46 @@
<!--
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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,19 @@
<!--
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.
-->
<resources>
<string name="app_name">Fire Markers</string>
</resources>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<resources>
<style name="Theme.FireMarkers" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?><!--
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.
-->
<!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@ -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)

View File

@ -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<ValueEventListener>()
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<ValueEventListener>()
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<ValueEventListener>()
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<Map<String, Any>>()
verify(mockAnimationRef).setValue(stateMapCaptor.capture())
assertThat(stateMapCaptor.firstValue["controllerId"]).isEqualTo(viewModel.viewModelId)
assertThat(stateMapCaptor.firstValue["running"]).isEqualTo(false)
}
@Test
fun `errorEvents emits message on database error`() = runTest {
val listenerCaptor = argumentCaptor<ValueEventListener>()
verify(mockAnimationRef).addValueEventListener(listenerCaptor.capture())
val error = mock<DatabaseError> {
on { message }.thenReturn("Test Error")
}
var errorMessage: String? = null
val job = launch(testDispatcher) {
viewModel.errorEvents.first {
errorMessage = it
true
}
}
listenerCaptor.firstValue.onCancelled(error)
testDispatcher.scheduler.advanceUntilIdle()
assertThat(errorMessage).isEqualTo("DB error on animation: Test Error")
job.cancel()
}
}
private fun mockAnimationStateSnapshot(state: MarkersViewModel.AnimationState): DataSnapshot {
val mockSnapshot = mock<DataSnapshot>()
whenever(mockSnapshot.getValue(eq(MarkersViewModel.AnimationState::class.java))).thenReturn(state)
return mockSnapshot
}

View File

@ -0,0 +1,70 @@
digraph Architecture {
rankdir=TB;
node [shape=box, style=rounded];
subgraph "cluster_firebase" {
label="Firebase Cloud";
style="filled";
color="lightgrey";
MarkersDB [label="Firebase Realtime Database
/markers"];
AnimationDB [label="Firebase Realtime Database
/animation"];
}
subgraph "cluster_android" {
label="Android App";
style="filled";
color="lightgrey";
subgraph "cluster_ui" {
label="UI Layer (View)";
A [label="MainActivity"];
B [label="MapScreen Composable"];
C [label="TopAppBar Actions
(toggleAnimation, seedDatabase, clearMarkers, takeControl)"];
D [label="GoogleMap Composable"];
}
subgraph "cluster_vm" {
label="State & Logic Layer (ViewModel)";
VM [label="MarkersViewModel"];
}
subgraph "cluster_data" {
label="Data Layer";
FC [label="FirebaseConnection"];
SD [label="ShapeData"];
M [label="MarkerData Model"];
}
subgraph "cluster_di" {
label="Dependency Injection";
Hilt [label="Hilt/Dagger"];
}
}
// --- Interactions ---
A -> B;
B -> C;
B -> D [label="Renders Markers"];
C -> VM [label="Calls function"];
VM -> FC [label="Uses"];
VM -> SD [label="Reads shape vectors"];
// Controller writes to Firebase
VM -> AnimationDB [label="Writes animation state"];
VM -> MarkersDB [label="Writes marker data"];
// All clients listen for real-time updates
MarkersDB -> FC [label="Real-time updates", style=dashed];
AnimationDB -> FC [label="Real-time updates", style=dashed];
FC -> VM;
VM -> B [label="Updates StateFlow"];
VM -> M [label="Uses Model"];
// --- DI Graph ---
Hilt -> FC [label="Injects"];
Hilt -> VM [label="Injects"];
}

View File

@ -0,0 +1,29 @@
/*
* 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.
*/
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.hilt.android) apply false
alias(libs.plugins.secrets.gradle.plugin) apply false
alias(libs.plugins.ksp)
id("com.google.gms.google-services") version "4.4.4" apply false
}

View File

@ -0,0 +1,20 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx10248m -XX:MaxPermSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
android.enableJetifier=true
android.useAndroidX=true

View File

@ -0,0 +1,189 @@
#
# 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.
#
# #################################################################################################
# GRADLE VERSION CATALOG
#
# This file defines the dependencies used in the project, organized into four main sections:
# - `[versions]`: Centralizes dependency versions for easy management and updates.
# - `[libraries]`: Declares the actual dependencies, referencing versions from the `[versions]` block.
# - `[bundles]`: (Not used here) Groups libraries that are commonly used together.
# - `[plugins]`: Configures Gradle plugins used for building and packaging the application.
#
# For more information, see https://docs.gradle.org/current/userguide/platforms.html
# #################################################################################################
[versions]
# -------------------------------------------------------------------------------------------------
# Android SDK
#
# Specifies the core Android SDK versions for compilation, minimum supported API level, and target
# API level. These versions are fundamental to how the application is built and how it interacts
# with the Android operating system.
# -------------------------------------------------------------------------------------------------
android-compile-sdk = "36"
android-min-sdk = "24"
android-target-sdk = "36"
# -------------------------------------------------------------------------------------------------
# Kotlin
#
# Defines the versions for the Kotlin programming language and related libraries, such as
# coroutines for asynchronous programming and datetime for handling dates and times.
# -------------------------------------------------------------------------------------------------
kotlin = "2.2.21"
kotlinx-coroutines-test = "1.10.2"
kotlinx-datetime = "0.7.1"
# -------------------------------------------------------------------------------------------------
# AndroidX
#
# Centralizes versions for AndroidX libraries, which are part of the Android Jetpack suite.
# This includes components for UI (Compose), core functionality (KTX), lifecycle management,
# and testing.
# -------------------------------------------------------------------------------------------------
androidx-activity-compose = "1.11.0"
androidx-compose-bom = "2025.10.01"
androidx-core-ktx = "1.17.0"
androidx-espresso-core = "3.7.0"
androidx-hilt-navigation-compose = "1.3.0"
androidx-junit = "1.3.0"
androidx-lifecycle-runtime-ktx = "2.9.4"
androidx-material-icons-extended = "1.7.8"
androidx-material3 = "1.4.0"
# -------------------------------------------------------------------------------------------------
# Google & Firebase
#
# Manages versions for Google-provided libraries, including Firebase for backend services,
# Hilt for dependency injection, and Google Maps Platform SDKs for mapping functionalities.
# -------------------------------------------------------------------------------------------------
dagger = "2.57.2"
firebase-bom = "34.5.0"
hilt-android = "2.57.2"
maps-compose = "6.12.1"
maps-utils-ktx = "5.2.1"
truth = "1.4.5"
# -------------------------------------------------------------------------------------------------
# Testing
#
# Specifies the versions for testing frameworks and libraries used for unit and instrumentation
# tests. This includes JUnit, Mockito for mocking objects, Robolectric for running Android tests
# on the JVM, and Turbine for testing Kotlin Flows.
# -------------------------------------------------------------------------------------------------
junit = "4.13.2"
mockito = "6.1.0"
robolectric = "4.16"
turbine = "1.2.1"
# -------------------------------------------------------------------------------------------------
# Gradle Plugins
#
# Defines the versions for Gradle plugins that provide core build functionalities, such as the
# Android Gradle Plugin (AGP), Kotlin plugins, and utility plugins like the Secrets Gradle Plugin
# for managing API keys.
# -------------------------------------------------------------------------------------------------
android-gradle-plugin = "8.13.0"
ksp = "2.2.20-2.0.4"
secrets-gradle-plugin = "2.0.1"
[libraries]
# -------------------------------------------------------------------------------------------------
# AndroidX
#
# This section declares the AndroidX libraries used in the project. These libraries provide
# backward-compatible versions of Android framework APIs, as well as new features and utilities
# for modern Android development.
# -------------------------------------------------------------------------------------------------
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt-navigation-compose" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-ktx" }
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-material-icons-extended" }
androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-material3" }
androidx-ui = { module = "androidx.compose.ui:ui" }
androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
# -------------------------------------------------------------------------------------------------
# Google & Firebase
#
# Declares dependencies for Google and Firebase services. This includes the Maps SDK for Compose,
# Firebase Realtime Database, and Hilt for dependency injection.
# -------------------------------------------------------------------------------------------------
dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
firebase-database = { module = "com.google.firebase:firebase-database" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt-android" }
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }
maps-utils-ktx = { module = "com.google.maps.android:maps-utils-ktx", version.ref = "maps-utils-ktx" }
# -------------------------------------------------------------------------------------------------
# Kotlin
#
# Defines Kotlin-specific libraries, such as the datetime library for handling dates and times
# in a multiplatform-friendly way.
# -------------------------------------------------------------------------------------------------
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
# -------------------------------------------------------------------------------------------------
# Testing
#
# This section lists all dependencies required for testing. It includes libraries for both local
# unit tests (JUnit, Mockito, Robolectric) and instrumented tests that run on an Android device
# or emulator (Espresso, AndroidX Test).
# -------------------------------------------------------------------------------------------------
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso-core" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" }
androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
google-truth = { module = "com.google.truth:truth", version.ref = "truth" }
junit = { module = "junit:junit", version.ref = "junit" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" }
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
[plugins]
# -------------------------------------------------------------------------------------------------
# Gradle Plugins
#
# This section defines the Gradle plugins used in the project. Plugins extend Gradle's capabilities,
# enabling tasks like compiling Android applications, processing Kotlin code, managing secrets,
# and handling dependency injection with Hilt and KSP.
# -------------------------------------------------------------------------------------------------
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt-android" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets-gradle-plugin" }

Binary file not shown.

View File

@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
FireMarkers/gradlew vendored Executable file
View File

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# 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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
FireMarkers/gradlew.bat vendored Normal file
View File

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1 @@
MAPS_API_KEY=DEFAULT_API_KEY

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

View File

@ -0,0 +1,39 @@
/*
* 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.
*/
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Fire Markers"
include(":app")

View File

@ -19,6 +19,7 @@ Samples demonstrating how to use
This repo contains the following samples:
1. [ApiDemos](ApiDemos): A collection of small demos showing most features of the Maps SDK for Android.
1. [FireMarkers](FireMarkers): Demonstrates how to use Firebase Realtime Database to drive synchronized, live animations on a Google Map across multiple devices using a controller/agent architecture.
1. [WearOS](WearOS):
Displays a map on a Wear OS device. This sample demonstrates the basic
setup required for a gradle-based Android Studio project.