diff --git a/firebase-dataconnect/demo/.gitignore b/firebase-dataconnect/demo/.gitignore
new file mode 100644
index 00000000000..ee8a7cf69a2
--- /dev/null
+++ b/firebase-dataconnect/demo/.gitignore
@@ -0,0 +1,24 @@
+# Copyright 2024 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.
+
+.gradle/
+.idea/
+.kotlin/
+
+build/
+local.properties
+
+*.log
+*.hprof
+/dataConnectGeneratedSources/
diff --git a/firebase-dataconnect/demo/.idea/runConfigurations/spotlessApply.xml b/firebase-dataconnect/demo/.idea/runConfigurations/spotlessApply.xml
new file mode 100644
index 00000000000..93b3583a85c
--- /dev/null
+++ b/firebase-dataconnect/demo/.idea/runConfigurations/spotlessApply.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+ false
+
+
+
diff --git a/firebase-dataconnect/demo/build.gradle.kts b/firebase-dataconnect/demo/build.gradle.kts
new file mode 100644
index 00000000000..d79ba7d2e24
--- /dev/null
+++ b/firebase-dataconnect/demo/build.gradle.kts
@@ -0,0 +1,261 @@
+/*
+ * Copyright 2024 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.
+ */
+
+import com.android.build.api.variant.ApplicationAndroidComponentsExtension
+import java.nio.charset.StandardCharsets
+
+plugins {
+ // Use whichever versions of these dependencies suit your application.
+ // The versions shown here were the latest versions as of December 03, 2024.
+ // Note, however, that the version of kotlin("plugin.serialization") _must_,
+ // in general, match the version of kotlin("android").
+ id("com.android.application") version "8.7.3"
+ id("com.google.gms.google-services") version "4.4.2"
+ val kotlinVersion = "2.1.0"
+ kotlin("android") version kotlinVersion
+ kotlin("plugin.serialization") version kotlinVersion
+
+ // The following code in this "plugins" block can be omitted from customer
+ // facing documentation as it is an implementation detail of this application.
+ id("com.diffplug.spotless") version "7.0.0.BETA4"
+}
+
+dependencies {
+ // Use whichever versions of these dependencies suit your application.
+ // The versions shown here were the latest versions as of December 03, 2024.
+ implementation("com.google.firebase:firebase-dataconnect:16.0.0-beta03")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.activity:activity-ktx:1.9.3")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
+ implementation("com.google.android.material:material:1.12.0")
+
+ // The following code in this "dependencies" block can be omitted from customer
+ // facing documentation as it is an implementation detail of this application.
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.3")
+ implementation("io.kotest:kotest-property:5.9.1")
+ implementation("io.kotest.extensions:kotest-property-arbs:2.1.2")
+}
+
+// The remaining code in this file can be omitted from customer facing
+// documentation. It's here just to make things compile and/or configure
+// optional components of the build (e.g. spotless code formatting).
+
+android {
+ namespace = "com.google.firebase.dataconnect.minimaldemo"
+ compileSdk = 35
+ defaultConfig {
+ minSdk = 21
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ isCoreLibraryDesugaringEnabled = true
+ }
+ buildFeatures.viewBinding = true
+ kotlinOptions.jvmTarget = "1.8"
+}
+
+spotless {
+ val ktfmtVersion = "0.53"
+ kotlin {
+ target("**/*.kt")
+ targetExclude("build/")
+ ktfmt(ktfmtVersion).googleStyle()
+ }
+ kotlinGradle {
+ target("**/*.gradle.kts")
+ targetExclude("build/")
+ ktfmt(ktfmtVersion).googleStyle()
+ }
+ json {
+ target("**/*.json")
+ targetExclude("build/")
+ simple().indentWithSpaces(2)
+ }
+ yaml {
+ target("**/*.yaml")
+ targetExclude("build/")
+ jackson()
+ .yamlFeature("INDENT_ARRAYS", true)
+ .yamlFeature("MINIMIZE_QUOTES", true)
+ .yamlFeature("WRITE_DOC_START_MARKER", false)
+ }
+ format("xml") {
+ target("**/*.xml")
+ targetExclude("build/")
+ trimTrailingWhitespace()
+ indentWithSpaces(2)
+ endWithNewline()
+ }
+}
+
+abstract class DataConnectGenerateSourcesTask : DefaultTask() {
+
+ @get:InputDirectory abstract val inputDirectory: DirectoryProperty
+
+ @get:OutputDirectory abstract val outputDirectory: DirectoryProperty
+
+ @get:Internal abstract val nodeExecutableDirectory: DirectoryProperty
+
+ @get:Internal abstract val firebaseCommand: Property
+
+ @get:Internal abstract val workDirectory: DirectoryProperty
+
+ @get:Inject protected abstract val execOperations: ExecOperations
+
+ @get:Inject protected abstract val providerFactory: ProviderFactory
+
+ @get:Inject protected abstract val fileSystemOperations: FileSystemOperations
+
+ @TaskAction
+ fun run(inputChanges: InputChanges) {
+ if (inputChanges.isIncremental) {
+ val onlyLogFilesChanged =
+ inputChanges.getFileChanges(inputDirectory).all { it.file.name.endsWith(".log") }
+ if (onlyLogFilesChanged) {
+ didWork = false
+ return
+ }
+ }
+
+ val inputDirectory: File = inputDirectory.get().asFile
+ val outputDirectory: File = outputDirectory.get().asFile
+ val nodeExecutableDirectory: File? = nodeExecutableDirectory.orNull?.asFile
+ val firebaseCommand: String? = firebaseCommand.orNull
+ val workDirectory: File = workDirectory.get().asFile
+
+ outputDirectory.deleteRecursively()
+ outputDirectory.mkdirs()
+ workDirectory.deleteRecursively()
+ workDirectory.mkdirs()
+
+ val newPath: String? =
+ if (nodeExecutableDirectory === null) {
+ null
+ } else {
+ val nodeExecutableDirectoryAbsolutePath = nodeExecutableDirectory.absolutePath
+ val oldPath = providerFactory.environmentVariable("PATH").orNull
+ if (oldPath === null) {
+ nodeExecutableDirectoryAbsolutePath
+ } else {
+ nodeExecutableDirectoryAbsolutePath + File.pathSeparator + oldPath
+ }
+ }
+
+ val logFile =
+ if (logger.isInfoEnabled) {
+ null
+ } else {
+ File(workDirectory, "dataconnect.sdk.generate.log.txt")
+ }
+
+ val execResult =
+ logFile?.outputStream().use { logStream ->
+ execOperations.runCatching {
+ exec {
+ commandLine(firebaseCommand ?: "firebase", "--debug", "dataconnect:sdk:generate")
+ workingDir(inputDirectory)
+ isIgnoreExitValue = false
+ if (newPath !== null) {
+ environment("PATH", newPath)
+ }
+ if (logStream !== null) {
+ standardOutput = logStream
+ errorOutput = logStream
+ }
+ }
+ }
+ }
+
+ execResult.onFailure { exception ->
+ logFile?.readText(StandardCharsets.UTF_8)?.lines()?.forEach { line ->
+ logger.warn("{}", line.trimEnd())
+ }
+ throw exception
+ }
+ }
+}
+
+abstract class CopyDirectoryTask : DefaultTask() {
+
+ @get:InputDirectory abstract val srcDirectory: DirectoryProperty
+
+ @get:OutputDirectory abstract val destDirectory: DirectoryProperty
+
+ @get:Inject protected abstract val fileSystemOperations: FileSystemOperations
+
+ @TaskAction
+ fun run() {
+ val srcDirectory: File = srcDirectory.get().asFile
+ val destDirectory: File = destDirectory.get().asFile
+
+ logger.info("srcDirectory: {}", srcDirectory.absolutePath)
+ logger.info("destDirectory: {}", destDirectory.absolutePath)
+
+ destDirectory.deleteRecursively()
+ destDirectory.mkdirs()
+
+ fileSystemOperations.copy {
+ from(srcDirectory)
+ into(destDirectory)
+ }
+ }
+}
+
+run {
+ val dataConnectTaskGroupName = "Firebase Data Connect Minimal App"
+ val projectDirectory = layout.projectDirectory
+
+ val generateSourcesTask =
+ tasks.register("dataConnectGenerateSources") {
+ group = dataConnectTaskGroupName
+ description =
+ "Run firebase dataconnect:sdk:generate to generate the Data Connect Kotlin SDK sources"
+
+ inputDirectory = projectDirectory.dir("firebase")
+ outputDirectory = projectDirectory.dir("dataConnectGeneratedSources")
+
+ nodeExecutableDirectory =
+ project.providers.gradleProperty("dataConnect.minimalApp.nodeExecutableDirectory").map {
+ projectDirectory.dir(it)
+ }
+ firebaseCommand = project.providers.gradleProperty("dataConnect.minimalApp.firebaseCommand")
+
+ workDirectory = layout.buildDirectory.dir(name)
+ }
+
+ val androidComponents = extensions.getByType()
+ androidComponents.onVariants { variant ->
+ val variantNameTitleCase = variant.name[0].uppercase() + variant.name.substring(1)
+ val copyTaskName = "dataConnectCopy${variantNameTitleCase}GeneratedSources"
+ val copyTask =
+ tasks.register(copyTaskName) {
+ group = dataConnectTaskGroupName
+ description =
+ "Copy the generated Data Connect Kotlin SDK sources into the " +
+ "generated code directory for the \"${variant.name}\" variant."
+ srcDirectory = generateSourcesTask.flatMap { it.outputDirectory }
+ }
+
+ variant.sources.java!!.addGeneratedSourceDirectory(copyTask, CopyDirectoryTask::destDirectory)
+ }
+}
diff --git a/firebase-dataconnect/demo/firebase/.firebaserc b/firebase-dataconnect/demo/firebase/.firebaserc
new file mode 100644
index 00000000000..77b37b80ab8
--- /dev/null
+++ b/firebase-dataconnect/demo/firebase/.firebaserc
@@ -0,0 +1,5 @@
+{
+ "projects": {
+ "default": "fakeproject"
+ }
+}
diff --git a/firebase-dataconnect/demo/firebase/dataconnect/.gitignore b/firebase-dataconnect/demo/firebase/dataconnect/.gitignore
new file mode 100644
index 00000000000..e0a389a7884
--- /dev/null
+++ b/firebase-dataconnect/demo/firebase/dataconnect/.gitignore
@@ -0,0 +1,15 @@
+# Copyright 2024 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.
+
+/.dataconnect/
diff --git a/firebase-dataconnect/demo/firebase/dataconnect/connector/connector.yaml b/firebase-dataconnect/demo/firebase/dataconnect/connector/connector.yaml
new file mode 100644
index 00000000000..0f8db03a8ed
--- /dev/null
+++ b/firebase-dataconnect/demo/firebase/dataconnect/connector/connector.yaml
@@ -0,0 +1,5 @@
+connectorId: ctry3q3tp6kzx
+generate:
+ kotlinSdk:
+ outputDir: ../../../dataConnectGeneratedSources
+ package: com.google.firebase.dataconnect.minimaldemo.connector
diff --git a/firebase-dataconnect/demo/firebase/dataconnect/connector/operations.gql b/firebase-dataconnect/demo/firebase/dataconnect/connector/operations.gql
new file mode 100644
index 00000000000..85c30c43329
--- /dev/null
+++ b/firebase-dataconnect/demo/firebase/dataconnect/connector/operations.gql
@@ -0,0 +1,50 @@
+# Copyright 2024 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.
+
+mutation InsertItem(
+ $string: String,
+ $int: Int,
+ $int64: Int64,
+ $float: Float,
+ $boolean: Boolean,
+ $date: Date,
+ $timestamp: Timestamp,
+ $any: Any,
+) @auth(level: PUBLIC) {
+ key: zwda6x9zyy_insert(data: {
+ string: $string,
+ int: $int,
+ int64: $int64,
+ float: $float,
+ boolean: $boolean,
+ date: $date,
+ timestamp: $timestamp,
+ any: $any,
+ })
+}
+
+query GetItemByKey(
+ $key: zwda6x9zyy_Key!
+) @auth(level: PUBLIC) {
+ item: zwda6x9zyy(key: $key) {
+ string
+ int
+ int64
+ float
+ boolean
+ date
+ timestamp
+ any
+ }
+}
diff --git a/firebase-dataconnect/demo/firebase/dataconnect/dataconnect.yaml b/firebase-dataconnect/demo/firebase/dataconnect/dataconnect.yaml
new file mode 100644
index 00000000000..341a3fc587a
--- /dev/null
+++ b/firebase-dataconnect/demo/firebase/dataconnect/dataconnect.yaml
@@ -0,0 +1,9 @@
+specVersion: v1beta
+serviceId: srv3ar8skbsza
+location: us-central1
+schema:
+ source: ./schema
+ datasource:
+ postgresql: null
+connectorDirs:
+ - ./connector
diff --git a/firebase-dataconnect/demo/firebase/dataconnect/schema/schema.gql b/firebase-dataconnect/demo/firebase/dataconnect/schema/schema.gql
new file mode 100644
index 00000000000..5b9c93cef13
--- /dev/null
+++ b/firebase-dataconnect/demo/firebase/dataconnect/schema/schema.gql
@@ -0,0 +1,24 @@
+# Copyright 2024 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.
+
+type zwda6x9zyy @table {
+ string: String
+ int: Int
+ int64: Int64
+ float: Float
+ boolean: Boolean
+ date: Date
+ timestamp: Timestamp
+ any: Any
+}
diff --git a/firebase-dataconnect/demo/firebase/firebase.json b/firebase-dataconnect/demo/firebase/firebase.json
new file mode 100644
index 00000000000..5994e591654
--- /dev/null
+++ b/firebase-dataconnect/demo/firebase/firebase.json
@@ -0,0 +1,8 @@
+{
+ "dataconnect": {"source": "dataconnect"},
+ "emulators": {
+ "singleProjectMode": true,
+ "dataconnect": {"port": 9399},
+ "ui": {"enabled": true}
+ }
+}
diff --git a/firebase-dataconnect/demo/google-services.json b/firebase-dataconnect/demo/google-services.json
new file mode 100644
index 00000000000..34bccc8818b
--- /dev/null
+++ b/firebase-dataconnect/demo/google-services.json
@@ -0,0 +1,14 @@
+{
+ "client": [{
+ "client_info": {
+ "android_client_info": {"package_name": "com.google.firebase.dataconnect.minimaldemo"},
+ "mobilesdk_app_id": "1:12345678901:android:1234567890abcdef123456"
+ },
+ "api_key": [{"current_key": "AIzayDNSXIbFmlXbIE6mCzDLQAqITYefhixbX4A"}]
+ }],
+ "configuration_version": "1",
+ "project_info": {
+ "project_number": "12345678901",
+ "project_id": "fakeproject"
+ }
+}
diff --git a/firebase-dataconnect/demo/gradle.properties b/firebase-dataconnect/demo/gradle.properties
new file mode 100644
index 00000000000..12a9a7c6e9c
--- /dev/null
+++ b/firebase-dataconnect/demo/gradle.properties
@@ -0,0 +1,7 @@
+org.gradle.configuration-cache=true
+org.gradle.parallel=true
+org.gradle.caching=true
+
+android.useAndroidX=true
+
+org.gradle.jvmargs=-Xmx2g
diff --git a/firebase-dataconnect/demo/gradle/wrapper/gradle-wrapper.jar b/firebase-dataconnect/demo/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000000..a4b76b9530d
Binary files /dev/null and b/firebase-dataconnect/demo/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/firebase-dataconnect/demo/gradle/wrapper/gradle-wrapper.properties b/firebase-dataconnect/demo/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000000..e2847c82004
--- /dev/null
+++ b/firebase-dataconnect/demo/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/firebase-dataconnect/demo/gradlew b/firebase-dataconnect/demo/gradlew
new file mode 100755
index 00000000000..f5feea6d6b1
--- /dev/null
+++ b/firebase-dataconnect/demo/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 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
+' "$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
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# 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" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ 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, 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" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# 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" "$@"
diff --git a/firebase-dataconnect/demo/gradlew.bat b/firebase-dataconnect/demo/gradlew.bat
new file mode 100644
index 00000000000..9b42019c791
--- /dev/null
+++ b/firebase-dataconnect/demo/gradlew.bat
@@ -0,0 +1,94 @@
+@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
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+: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
diff --git a/firebase-dataconnect/demo/settings.gradle.kts b/firebase-dataconnect/demo/settings.gradle.kts
new file mode 100644
index 00000000000..72011613a80
--- /dev/null
+++ b/firebase-dataconnect/demo/settings.gradle.kts
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2024 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()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
diff --git a/firebase-dataconnect/demo/src/main/AndroidManifest.xml b/firebase-dataconnect/demo/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..157258a1a8b
--- /dev/null
+++ b/firebase-dataconnect/demo/src/main/AndroidManifest.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainActivity.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainActivity.kt
new file mode 100644
index 00000000000..06c51458c25
--- /dev/null
+++ b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainActivity.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2024 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.google.firebase.dataconnect.minimaldemo
+
+import android.os.Bundle
+import android.view.View.OnClickListener
+import android.widget.CompoundButton.OnCheckedChangeListener
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.google.firebase.dataconnect.minimaldemo.MainActivityViewModel.State.OperationState
+import com.google.firebase.dataconnect.minimaldemo.databinding.ActivityMainBinding
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var myApplication: MyApplication
+ private lateinit var viewBinding: ActivityMainBinding
+ private val viewModel: MainActivityViewModel by viewModels { MainActivityViewModel.Factory }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ myApplication = application as MyApplication
+
+ viewBinding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(viewBinding.root)
+
+ viewBinding.insertItemButton.setOnClickListener(insertButtonOnClickListener)
+ viewBinding.getItemButton.setOnClickListener(getItemButtonOnClickListener)
+ viewBinding.useEmulatorCheckBox.setOnCheckedChangeListener(useEmulatorOnCheckedChangeListener)
+ viewBinding.debugLoggingCheckBox.setOnCheckedChangeListener(debugLoggingOnCheckedChangeListener)
+
+ lifecycleScope.launch {
+ viewModel.state.flowWithLifecycle(lifecycle).collectLatest(::collectViewModelState)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ lifecycleScope.launch {
+ viewBinding.useEmulatorCheckBox.isChecked = myApplication.getUseDataConnectEmulator()
+ viewBinding.debugLoggingCheckBox.isChecked = myApplication.getDataConnectDebugLoggingEnabled()
+ }
+ }
+
+ private fun collectViewModelState(state: MainActivityViewModel.State) {
+ val (insertProgressText, insertSequenceNumber) =
+ when (state.insertItem) {
+ is OperationState.New -> Pair(null, null)
+ is OperationState.InProgress ->
+ Pair(
+ "Inserting item: ${state.insertItem.variables.toDisplayString()}",
+ state.insertItem.sequenceNumber,
+ )
+ is OperationState.Completed ->
+ Pair(
+ state.insertItem.result.fold(
+ onSuccess = {
+ "Inserted item with id=${it.id}:\n${state.insertItem.variables.toDisplayString()}"
+ },
+ onFailure = { "Inserting item ${state.insertItem.variables} FAILED: $it" },
+ ),
+ state.insertItem.sequenceNumber,
+ )
+ }
+
+ val (getProgressText, getSequenceNumber) =
+ when (state.getItem) {
+ is OperationState.New -> Pair(null, null)
+ is OperationState.InProgress ->
+ Pair(
+ "Retrieving item with ID ${state.getItem.variables.id}...",
+ state.getItem.sequenceNumber,
+ )
+ is OperationState.Completed ->
+ Pair(
+ state.getItem.result.fold(
+ onSuccess = {
+ "Retrieved item with ID ${state.getItem.variables.id}:\n${it?.toDisplayString()}"
+ },
+ onFailure = { "Retrieving item with ID ${state.getItem.variables.id} FAILED: $it" },
+ ),
+ state.getItem.sequenceNumber,
+ )
+ }
+
+ viewBinding.insertItemButton.isEnabled = state.insertItem !is OperationState.InProgress
+ viewBinding.getItemButton.isEnabled =
+ state.getItem !is OperationState.InProgress && state.lastInsertedKey !== null
+
+ viewBinding.progressText.text =
+ if (getSequenceNumber === null) {
+ insertProgressText
+ } else if (insertSequenceNumber === null) {
+ getProgressText
+ } else if (insertSequenceNumber > getSequenceNumber) {
+ insertProgressText
+ } else {
+ getProgressText
+ }
+ }
+
+ private val insertButtonOnClickListener = OnClickListener { viewModel.insertItem() }
+
+ private val getItemButtonOnClickListener = OnClickListener { viewModel.getItem() }
+
+ private val debugLoggingOnCheckedChangeListener = OnCheckedChangeListener { _, isChecked ->
+ if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
+ return@OnCheckedChangeListener
+ }
+ myApplication.coroutineScope.launch {
+ myApplication.setDataConnectDebugLoggingEnabled(isChecked)
+ }
+ }
+
+ private val useEmulatorOnCheckedChangeListener = OnCheckedChangeListener { _, isChecked ->
+ if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
+ return@OnCheckedChangeListener
+ }
+ myApplication.coroutineScope.launch { myApplication.setUseDataConnectEmulator(isChecked) }
+ }
+}
diff --git a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainActivityViewModel.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainActivityViewModel.kt
new file mode 100644
index 00000000000..2cfffe725df
--- /dev/null
+++ b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainActivityViewModel.kt
@@ -0,0 +1,318 @@
+/*
+ * Copyright 2024 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.google.firebase.dataconnect.minimaldemo
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import com.google.firebase.dataconnect.minimaldemo.connector.GetItemByKeyQuery
+import com.google.firebase.dataconnect.minimaldemo.connector.InsertItemMutation
+import com.google.firebase.dataconnect.minimaldemo.connector.Zwda6x9zyyKey
+import com.google.firebase.dataconnect.minimaldemo.connector.execute
+import io.kotest.property.Arb
+import io.kotest.property.RandomSource
+import io.kotest.property.arbitrary.next
+import java.util.Objects
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.serialization.Serializable
+
+class MainActivityViewModel(private val app: MyApplication) : ViewModel() {
+
+ private val _state =
+ MutableStateFlow(
+ State(
+ insertItem = State.OperationState.New,
+ getItem = State.OperationState.New,
+ lastInsertedKey = null,
+ nextSequenceNumber = 19999000,
+ )
+ )
+ val state: StateFlow = _state.asStateFlow()
+
+ private val rs = RandomSource.default()
+
+ fun insertItem() {
+ while (true) {
+ if (tryInsertItem()) {
+ break
+ }
+ }
+ }
+
+ private fun tryInsertItem(): Boolean {
+ val arb = Arb.insertItemVariables()
+ val variables = if (rs.random.nextFloat() < 0.333f) arb.edgecase(rs)!! else arb.next(rs)
+
+ val oldState = _state.value
+
+ // If there is already an "insert" in progress, then just return and let the in-progress
+ // operation finish.
+ when (oldState.getItem) {
+ is State.OperationState.InProgress -> return true
+ is State.OperationState.New,
+ is State.OperationState.Completed -> Unit
+ }
+
+ // Create a new coroutine to perform the "insert" operation, but don't start it yet by
+ // specifying start=CoroutineStart.LAZY because we won't start it until the state is
+ // successfully set.
+ val newInsertJob: Deferred =
+ viewModelScope.async(start = CoroutineStart.LAZY) {
+ app.getConnector().insertItem.ref(variables).execute().data.key
+ }
+
+ // Update the state and start the coroutine if it is successfully set.
+ val insertItemOperationInProgressState =
+ State.OperationState.InProgress(oldState.nextSequenceNumber, variables, newInsertJob)
+ val newState = oldState.withInsertInProgress(insertItemOperationInProgressState)
+ if (!_state.compareAndSet(oldState, newState)) {
+ return false
+ }
+
+ // Actually start the coroutine now that the state has been set.
+ Log.i(TAG, "Inserting item: $variables")
+ newState.startInsert(insertItemOperationInProgressState)
+ return true
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun State.startInsert(
+ insertItemOperationInProgressState:
+ State.OperationState.InProgress
+ ) {
+ require(insertItemOperationInProgressState === insertItem)
+ val job: Deferred = insertItemOperationInProgressState.job
+ val variables: InsertItemMutation.Variables = insertItemOperationInProgressState.variables
+
+ job.start()
+
+ job.invokeOnCompletion { exception ->
+ val result =
+ if (exception !== null) {
+ Log.w(TAG, "WARNING: Inserting item FAILED: $exception (variables=$variables)", exception)
+ Result.failure(exception)
+ } else {
+ val key = job.getCompleted()
+ Log.i(TAG, "Inserted item with key: $key (variables=${variables})")
+ Result.success(key)
+ }
+
+ while (true) {
+ val oldState = _state.value
+ if (oldState.insertItem !== insertItemOperationInProgressState) {
+ break
+ }
+
+ val insertItemOperationCompletedState =
+ State.OperationState.Completed(oldState.nextSequenceNumber, variables, result)
+ val newState = oldState.withInsertCompleted(insertItemOperationCompletedState)
+ if (_state.compareAndSet(oldState, newState)) {
+ break
+ }
+ }
+ }
+ }
+
+ fun getItem() {
+ while (true) {
+ if (tryGetItem()) {
+ break
+ }
+ }
+ }
+
+ private fun tryGetItem(): Boolean {
+ val oldState = _state.value
+
+ // If there is no previous successful "insert" operation, then we don't know any ID's to get,
+ // so just do nothing.
+ val key: Zwda6x9zyyKey = oldState.lastInsertedKey ?: return true
+
+ // If there is already a "get" in progress, then just return and let the in-progress operation
+ // finish.
+ when (oldState.getItem) {
+ is State.OperationState.InProgress -> return true
+ is State.OperationState.New,
+ is State.OperationState.Completed -> Unit
+ }
+
+ // Create a new coroutine to perform the "get" operation, but don't start it yet by specifying
+ // start=CoroutineStart.LAZY because we won't start it until the state is successfully set.
+ val newGetJob: Deferred =
+ viewModelScope.async(start = CoroutineStart.LAZY) {
+ app.getConnector().getItemByKey.execute(key).data.item
+ }
+
+ // Update the state and start the coroutine if it is successfully set.
+ val getItemOperationInProgressState =
+ State.OperationState.InProgress(oldState.nextSequenceNumber, key, newGetJob)
+ val newState = oldState.withGetInProgress(getItemOperationInProgressState)
+ if (!_state.compareAndSet(oldState, newState)) {
+ return false
+ }
+
+ // Actually start the coroutine now that the state has been set.
+ Log.i(TAG, "Getting item with key: $key")
+ newState.startGet(getItemOperationInProgressState)
+ return true
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun State.startGet(
+ getItemOperationInProgressState:
+ State.OperationState.InProgress
+ ) {
+ require(getItemOperationInProgressState === getItem)
+ val job: Deferred = getItemOperationInProgressState.job
+ val key: Zwda6x9zyyKey = getItemOperationInProgressState.variables
+
+ job.start()
+
+ job.invokeOnCompletion { exception ->
+ val result =
+ if (exception !== null) {
+ Log.w(TAG, "WARNING: Getting item with key $key FAILED: $exception", exception)
+ Result.failure(exception)
+ } else {
+ val item = job.getCompleted()
+ Log.i(TAG, "Got item with key $key: $item")
+ Result.success(item)
+ }
+
+ while (true) {
+ val oldState = _state.value
+ if (oldState.getItem !== getItemOperationInProgressState) {
+ break
+ }
+
+ val getItemOperationCompletedState =
+ State.OperationState.Completed(
+ oldState.nextSequenceNumber,
+ getItemOperationInProgressState.variables,
+ result,
+ )
+ val newState = oldState.withGetCompleted(getItemOperationCompletedState)
+ if (_state.compareAndSet(oldState, newState)) {
+ break
+ }
+ }
+ }
+ }
+
+ @Serializable
+ class State(
+ val insertItem: OperationState,
+ val getItem: OperationState,
+ val lastInsertedKey: Zwda6x9zyyKey?,
+ val nextSequenceNumber: Long,
+ ) {
+
+ fun withInsertInProgress(
+ insertItem: OperationState.InProgress
+ ): State =
+ State(
+ insertItem = insertItem,
+ getItem = getItem,
+ lastInsertedKey = lastInsertedKey,
+ nextSequenceNumber = nextSequenceNumber + 1,
+ )
+
+ fun withInsertCompleted(
+ insertItem: OperationState.Completed
+ ): State =
+ State(
+ insertItem = insertItem,
+ getItem = getItem,
+ lastInsertedKey = insertItem.result.getOrNull() ?: lastInsertedKey,
+ nextSequenceNumber = nextSequenceNumber + 1,
+ )
+
+ fun withGetInProgress(
+ getItem: OperationState.InProgress
+ ): State =
+ State(
+ insertItem = insertItem,
+ getItem = getItem,
+ lastInsertedKey = lastInsertedKey,
+ nextSequenceNumber = nextSequenceNumber + 1,
+ )
+
+ fun withGetCompleted(
+ getItem: OperationState.Completed
+ ): State =
+ State(
+ insertItem = insertItem,
+ getItem = getItem,
+ lastInsertedKey = lastInsertedKey,
+ nextSequenceNumber = nextSequenceNumber + 1,
+ )
+
+ override fun hashCode() = Objects.hash(insertItem, getItem, lastInsertedKey, nextSequenceNumber)
+
+ override fun equals(other: Any?) =
+ other is State &&
+ insertItem == other.insertItem &&
+ getItem == other.getItem &&
+ lastInsertedKey == other.lastInsertedKey &&
+ nextSequenceNumber == other.nextSequenceNumber
+
+ override fun toString() =
+ "State(" +
+ "insertItem=$insertItem, " +
+ "getItem=$getItem, " +
+ "lastInsertedKey=$lastInsertedKey, " +
+ "sequenceNumber=$nextSequenceNumber)"
+
+ sealed interface OperationState {
+ data object New : OperationState
+
+ sealed interface SequencedOperationState :
+ OperationState {
+ val sequenceNumber: Long
+ }
+
+ data class InProgress(
+ override val sequenceNumber: Long,
+ val variables: Variables,
+ val job: Deferred,
+ ) : SequencedOperationState
+
+ data class Completed(
+ override val sequenceNumber: Long,
+ val variables: Variables,
+ val result: Result,
+ ) : SequencedOperationState
+ }
+ }
+
+ companion object {
+ private const val TAG = "MainActivityViewModel"
+
+ val Factory: ViewModelProvider.Factory = viewModelFactory {
+ initializer { MainActivityViewModel(this[APPLICATION_KEY] as MyApplication) }
+ }
+ }
+}
diff --git a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MyApplication.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MyApplication.kt
new file mode 100644
index 00000000000..eb70e8af475
--- /dev/null
+++ b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MyApplication.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2024 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.google.firebase.dataconnect.minimaldemo
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.util.Log
+import com.google.firebase.dataconnect.FirebaseDataConnect
+import com.google.firebase.dataconnect.LogLevel
+import com.google.firebase.dataconnect.logLevel
+import com.google.firebase.dataconnect.minimaldemo.connector.Ctry3q3tp6kzxConnector
+import com.google.firebase.dataconnect.minimaldemo.connector.instance
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+class MyApplication : Application() {
+
+ /**
+ * A [CoroutineScope] whose lifetime matches that of this [Application] object.
+ *
+ * Namely, the scope will be cancelled when [onTerminate] is called.
+ *
+ * This scope's [Job] is a [SupervisorJob], and, therefore, uncaught exceptions will _not_
+ * terminate the application.
+ */
+ val coroutineScope =
+ CoroutineScope(
+ SupervisorJob() +
+ CoroutineName("MyApplication@${System.identityHashCode(this@MyApplication)}") +
+ CoroutineExceptionHandler { context, throwable ->
+ val coroutineName = context[CoroutineName]?.name
+ Log.w(
+ TAG,
+ "WARNING: ignoring uncaught exception thrown from coroutine " +
+ "named \"$coroutineName\": $throwable " +
+ "(error code 8xrn9vvddd)",
+ throwable,
+ )
+ }
+ )
+
+ private val initialLogLevel = FirebaseDataConnect.logLevel
+ private val connectorMutex = Mutex()
+ private var connector: Ctry3q3tp6kzxConnector? = null
+
+ override fun onCreate() {
+ super.onCreate()
+
+ coroutineScope.launch {
+ if (getDataConnectDebugLoggingEnabled()) {
+ FirebaseDataConnect.logLevel = LogLevel.DEBUG
+ }
+ }
+ }
+
+ suspend fun getConnector(): Ctry3q3tp6kzxConnector {
+ connectorMutex.withLock {
+ val oldConnector = connector
+ if (oldConnector !== null) {
+ return oldConnector
+ }
+
+ val newConnector = Ctry3q3tp6kzxConnector.instance
+
+ if (getUseDataConnectEmulator()) {
+ newConnector.dataConnect.useEmulator()
+ }
+
+ connector = newConnector
+ return newConnector
+ }
+ }
+
+ private suspend fun getSharedPreferences(): SharedPreferences =
+ withContext(Dispatchers.IO) {
+ getSharedPreferences("MyApplicationSharedPreferences", MODE_PRIVATE)
+ }
+
+ suspend fun getDataConnectDebugLoggingEnabled(): Boolean =
+ getSharedPreferences().all[SharedPrefsKeys.IS_DATA_CONNECT_LOGGING_ENABLED] as? Boolean ?: false
+
+ suspend fun setDataConnectDebugLoggingEnabled(enabled: Boolean) {
+ FirebaseDataConnect.logLevel = if (enabled) LogLevel.DEBUG else initialLogLevel
+ editSharedPreferences { putBoolean(SharedPrefsKeys.IS_DATA_CONNECT_LOGGING_ENABLED, enabled) }
+ }
+
+ suspend fun getUseDataConnectEmulator(): Boolean =
+ getSharedPreferences().all[SharedPrefsKeys.IS_USE_DATA_CONNECT_EMULATOR] as? Boolean ?: true
+
+ suspend fun setUseDataConnectEmulator(enabled: Boolean) {
+ val requiresRestart = getUseDataConnectEmulator() != enabled
+ editSharedPreferences { putBoolean(SharedPrefsKeys.IS_USE_DATA_CONNECT_EMULATOR, enabled) }
+
+ if (requiresRestart) {
+ connectorMutex.withLock {
+ val oldConnector = connector
+ connector = null
+ oldConnector?.dataConnect?.close()
+ }
+ }
+ }
+
+ private suspend fun editSharedPreferences(block: SharedPreferences.Editor.() -> Unit) {
+ val prefs = getSharedPreferences()
+ withContext(Dispatchers.IO) {
+ val editor = prefs.edit()
+ block(editor)
+ if (!editor.commit()) {
+ Log.w(
+ TAG,
+ "WARNING: failed to save changes to SharedPreferences; " +
+ "ignoring the failure (error code wzy99s7jmy)",
+ )
+ }
+ }
+ }
+
+ override fun onTerminate() {
+ coroutineScope.cancel("MyApplication.onTerminate() called")
+ super.onTerminate()
+ }
+
+ private object SharedPrefsKeys {
+ const val IS_DATA_CONNECT_LOGGING_ENABLED = "isDataConnectDebugLoggingEnabled"
+ const val IS_USE_DATA_CONNECT_EMULATOR = "useDataConnectEmulator"
+ }
+
+ companion object {
+ private const val TAG = "MyApplication"
+ }
+}
diff --git a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/arbs.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/arbs.kt
new file mode 100644
index 00000000000..a77016f9659
--- /dev/null
+++ b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/arbs.kt
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2024 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.google.firebase.dataconnect.minimaldemo
+
+import android.annotation.SuppressLint
+import com.google.firebase.Timestamp
+import com.google.firebase.dataconnect.LocalDate
+import com.google.firebase.dataconnect.OptionalVariable
+import com.google.firebase.dataconnect.minimaldemo.connector.InsertItemMutation
+import com.google.firebase.dataconnect.toJavaLocalDate
+import io.kotest.property.Arb
+import io.kotest.property.RandomSource
+import io.kotest.property.Sample
+import io.kotest.property.arbitrary.boolean
+import io.kotest.property.arbitrary.double
+import io.kotest.property.arbitrary.enum
+import io.kotest.property.arbitrary.filterNot
+import io.kotest.property.arbitrary.int
+import io.kotest.property.arbitrary.long
+import io.kotest.property.arbitrary.map
+import io.kotest.property.arbitrary.next
+import io.kotest.property.arbs.fooddrink.iceCreamFlavors
+import io.kotest.property.asSample
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.Month
+import java.time.Year
+import java.time.ZoneOffset
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
+
+fun Arb.Companion.insertItemVariables(): Arb =
+ InsertItemMutationVariablesArb()
+
+private class InsertItemMutationVariablesArb(
+ private val string: Arb = Arb.iceCreamFlavors().map { it.value },
+ private val int: Arb = Arb.int(),
+ private val int64: Arb = Arb.long(),
+ private val float: Arb = Arb.double().filterNot { it.isNaN() || it.isInfinite() },
+ private val boolean: Arb = Arb.boolean(),
+ private val date: Arb = Arb.dataConnectLocalDate(),
+ private val timestamp: Arb = Arb.firebaseTimestamp(),
+) : Arb() {
+ override fun edgecase(rs: RandomSource): InsertItemMutation.Variables =
+ InsertItemMutation.Variables(
+ string = string.optionalEdgeCase(rs),
+ int = int.optionalEdgeCase(rs),
+ int64 = int64.optionalEdgeCase(rs),
+ float = float.optionalEdgeCase(rs),
+ boolean = boolean.optionalEdgeCase(rs),
+ date = date.optionalEdgeCase(rs),
+ timestamp = timestamp.optionalEdgeCase(rs),
+ any = OptionalVariable.Undefined,
+ )
+
+ override fun sample(rs: RandomSource): Sample =
+ InsertItemMutation.Variables(
+ string = OptionalVariable.Value(string.next(rs)),
+ int = OptionalVariable.Value(int.next(rs)),
+ int64 = OptionalVariable.Value(int64.next(rs)),
+ float = OptionalVariable.Value(float.next(rs)),
+ boolean = OptionalVariable.Value(boolean.next(rs)),
+ date = OptionalVariable.Value(date.next(rs)),
+ timestamp = OptionalVariable.Value(timestamp.next(rs)),
+ any = OptionalVariable.Undefined,
+ )
+ .asSample()
+}
+
+fun Arb.Companion.dataConnectLocalDate(): Arb = DataConnectLocalDateArb()
+
+private class DataConnectLocalDateArb : Arb() {
+
+ private val yearArb: Arb = Arb.int(MIN_YEAR..MAX_YEAR).map { Year.of(it) }
+ private val monthArb: Arb = Arb.enum()
+ private val dayArbByMonthLengthLock = ReentrantLock()
+ private val dayArbByMonthLength = mutableMapOf>()
+
+ override fun edgecase(rs: RandomSource): LocalDate {
+ val year = yearArb.maybeEdgeCase(rs, edgeCaseProbability = 0.33f)
+ val month = monthArb.maybeEdgeCase(rs, edgeCaseProbability = 0.33f)
+ val day = Arb.dayOfMonth(year, month).maybeEdgeCase(rs, edgeCaseProbability = 0.33f)
+ return LocalDate(year = year.value, month = month.value, day = day)
+ }
+
+ override fun sample(rs: RandomSource): Sample {
+ val year = yearArb.sample(rs).value
+ val month = monthArb.sample(rs).value
+ val day = Arb.dayOfMonth(year, month).sample(rs).value
+ return LocalDate(year = year.value, month = month.value, day = day).asSample()
+ }
+
+ private fun Arb.Companion.dayOfMonth(year: Year, month: Month): Arb {
+ val monthLength = year.atMonth(month).lengthOfMonth()
+ return dayArbByMonthLengthLock.withLock {
+ dayArbByMonthLength.getOrPut(monthLength) { Arb.int(1..monthLength) }
+ }
+ }
+
+ companion object {
+ const val MIN_YEAR = 1583
+ const val MAX_YEAR = 9999
+ }
+}
+
+fun Arb.Companion.firebaseTimestamp(): Arb = FirebaseTimestampArb()
+
+private class FirebaseTimestampArb : Arb() {
+
+ private val localDateArb = Arb.dataConnectLocalDate()
+ private val hourArb = Arb.int(1..23)
+ private val minuteArb = Arb.int(1..59)
+ private val secondArb = Arb.int(1..59)
+ private val nanosecondArb = Arb.int(0..999_999_999)
+
+ override fun edgecase(rs: RandomSource) =
+ localDateArb
+ .maybeEdgeCase(rs, edgeCaseProbability = 0.2f)
+ .toTimestampAtTime(
+ hour = hourArb.maybeEdgeCase(rs, edgeCaseProbability = 0.2f),
+ minute = minuteArb.maybeEdgeCase(rs, edgeCaseProbability = 0.2f),
+ second = secondArb.maybeEdgeCase(rs, edgeCaseProbability = 0.2f),
+ nanosecond = nanosecondArb.maybeEdgeCase(rs, edgeCaseProbability = 0.2f),
+ )
+
+ override fun sample(rs: RandomSource) =
+ localDateArb
+ .next(rs)
+ .toTimestampAtTime(
+ hour = hourArb.next(rs),
+ minute = minuteArb.next(rs),
+ second = secondArb.next(rs),
+ nanosecond = nanosecondArb.next(rs),
+ )
+ .asSample()
+
+ companion object {
+
+ // Suppress the spurious "Call requires API level 26" warning, which can be safely ignored
+ // because this application uses "desugaring" to ensure access to the java.time APIs even in
+ // Android API versions less than 26.
+ // See https://developer.android.com/studio/write/java8-support-table for details.
+ @SuppressLint("NewApi")
+ private fun LocalDate.toTimestampAtTime(
+ hour: Int,
+ minute: Int,
+ second: Int,
+ nanosecond: Int,
+ ): Timestamp {
+ val localDateTime: LocalDateTime = toJavaLocalDate().atTime(hour, minute, second, nanosecond)
+ val instant: Instant = localDateTime.toInstant(ZoneOffset.UTC)
+ return Timestamp(instant)
+ }
+ }
+}
+
+private fun Arb.optionalEdgeCase(rs: RandomSource): OptionalVariable {
+ val discriminator = rs.random.nextFloat()
+ return if (discriminator < 0.25f) {
+ OptionalVariable.Undefined
+ } else if (discriminator < 0.50f) {
+ OptionalVariable.Value(null)
+ } else {
+ OptionalVariable.Value(edgecase(rs) ?: next(rs))
+ }
+}
+
+private fun Arb.maybeEdgeCase(rs: RandomSource, edgeCaseProbability: Float = 0.5f): T {
+ require(edgeCaseProbability >= 0.0 && edgeCaseProbability < 1.0) {
+ "invalid edgeCaseProbability: $edgeCaseProbability"
+ }
+ return if (rs.random.nextFloat() >= edgeCaseProbability) {
+ sample(rs).value
+ } else {
+ edgecase(rs) ?: sample(rs).value
+ }
+}
diff --git a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/strings.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/strings.kt
new file mode 100644
index 00000000000..3ddf838ed2d
--- /dev/null
+++ b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/strings.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2024 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.google.firebase.dataconnect.minimaldemo
+
+import android.annotation.SuppressLint
+import com.google.firebase.Timestamp
+import com.google.firebase.dataconnect.AnyValue
+import com.google.firebase.dataconnect.LocalDate
+import com.google.firebase.dataconnect.OptionalVariable
+import com.google.firebase.dataconnect.minimaldemo.connector.GetItemByKeyQuery
+import com.google.firebase.dataconnect.minimaldemo.connector.InsertItemMutation
+import com.google.firebase.dataconnect.toJavaLocalDate
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.util.Locale
+
+fun InsertItemMutation.Variables.toDisplayString(): String =
+ displayStringForItem(
+ string = string,
+ int = int,
+ int64 = int64,
+ float = float,
+ boolean = boolean,
+ date = date,
+ timestamp = timestamp,
+ any = any,
+ )
+
+fun GetItemByKeyQuery.Data.Item.toDisplayString(): String =
+ displayStringForItem(
+ string = OptionalVariable.Value(string),
+ int = OptionalVariable.Value(int),
+ int64 = OptionalVariable.Value(int64),
+ float = OptionalVariable.Value(float),
+ boolean = OptionalVariable.Value(boolean),
+ date = OptionalVariable.Value(date),
+ timestamp = OptionalVariable.Value(timestamp),
+ any = OptionalVariable.Value(any),
+ )
+
+fun displayStringForItem(
+ string: OptionalVariable,
+ int: OptionalVariable,
+ int64: OptionalVariable,
+ float: OptionalVariable,
+ boolean: OptionalVariable,
+ date: OptionalVariable,
+ timestamp: OptionalVariable,
+ any: OptionalVariable,
+) = buildString {
+ append("string=").append(string).appendLine()
+ append("int=").append(int).appendLine()
+ append("int64=").append(int64).appendLine()
+ append("float=").append(float).appendLine()
+ append("boolean=").append(boolean).appendLine()
+ append("date=").append(date.toDisplayString { it.toDisplayString() }).appendLine()
+ append("timestamp=").append(timestamp.toDisplayString { it.toDisplayString() }).appendLine()
+ append("any=").append(any)
+}
+
+private fun OptionalVariable.toDisplayString(stringer: (T) -> String): String =
+ when (this) {
+ is OptionalVariable.Undefined -> toString()
+ is OptionalVariable.Value -> value?.let(stringer) ?: "null"
+ }
+
+private fun LocalDate.toDisplayString(): String =
+ toJavaLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG))
+
+@SuppressLint("NewApi")
+private fun Timestamp.toDisplayString(): String =
+ DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)
+ .withLocale(Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+ .format(toInstant())
diff --git a/firebase-dataconnect/demo/src/main/res/layout/activity_main.xml b/firebase-dataconnect/demo/src/main/res/layout/activity_main.xml
new file mode 100644
index 00000000000..0b3fd7d5931
--- /dev/null
+++ b/firebase-dataconnect/demo/src/main/res/layout/activity_main.xml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+