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