From 5f3ad6fa5c235f50207d66bad5f71e923379033f Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Fri, 27 Jun 2025 19:43:01 +0000 Subject: [PATCH 01/32] Utf8Performance{Unit/Integration}Test.kt added --- firebase-firestore/firebase-firestore.gradle | 8 +++ .../Utf8PerformanceIntegrationTest.kt | 62 +++++++++++++++++++ .../google/firebase/firestore/util/Util.java | 10 +++ .../firestore/util/Utf8PerformanceUnitTest.kt | 61 ++++++++++++++++++ .../firebase/firestore/util/UtilTest.java | 1 + 5 files changed, 142 insertions(+) create mode 100644 firebase-firestore/src/androidTest/java/com/google/firebase/firestore/Utf8PerformanceIntegrationTest.kt create mode 100644 firebase-firestore/src/test/java/com/google/firebase/firestore/util/Utf8PerformanceUnitTest.kt diff --git a/firebase-firestore/firebase-firestore.gradle b/firebase-firestore/firebase-firestore.gradle index 806babf6236..83cf57cbcb0 100644 --- a/firebase-firestore/firebase-firestore.gradle +++ b/firebase-firestore/firebase-firestore.gradle @@ -162,6 +162,10 @@ dependencies { testImplementation libs.hamcrest.junit testImplementation libs.mockito.core testImplementation libs.robolectric + testImplementation libs.kotest.assertions + testImplementation libs.kotest.property + testImplementation libs.kotest.property.arbs + testImplementation libs.kotlin.coroutines.test testCompileOnly libs.protobuf.java @@ -176,6 +180,10 @@ dependencies { androidTestImplementation libs.junit androidTestImplementation libs.mockito.android androidTestImplementation libs.mockito.core + androidTestImplementation libs.kotest.assertions + androidTestImplementation libs.kotest.property + androidTestImplementation libs.kotest.property.arbs + androidTestImplementation libs.kotlin.coroutines.test } gradle.projectsEvaluated { diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/Utf8PerformanceIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/Utf8PerformanceIntegrationTest.kt new file mode 100644 index 00000000000..af7c24e731b --- /dev/null +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/Utf8PerformanceIntegrationTest.kt @@ -0,0 +1,62 @@ +package com.google.firebase.firestore + +import com.google.firebase.firestore.util.Util +import io.kotest.property.Arb +import io.kotest.property.arbitrary.of +import io.kotest.property.checkAll +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.math.roundToLong +import kotlin.time.Duration + +const val ITERATION_COUNT = 300 +const val LIST_SIZE = 20_000 + +class Utf8PerformanceIntegrationTest { + + @Test + fun test() = runTest(timeout = Duration.INFINITE) { + val originalTimes = mutableListOf() + val slowTimes = mutableListOf() + val newTimes = mutableListOf() + val timesArb = Arb.of(originalTimes, slowTimes, newTimes) + + checkAll(ITERATION_COUNT, timesArb) { list -> + val startTimeNs = System.nanoTime() + + if (list === originalTimes) { + doTest { s1, s2 -> Util.compareUtf8StringsOriginal(s1, s2) } + } else if (list === slowTimes) { + doTest { s1, s2 -> Util.compareUtf8StringsSlow(s1, s2) } + } else if (list === newTimes) { + doTest { s1, s2 -> Util.compareUtf8Strings(s1, s2) } + } else { + throw Exception("unknown list: $list [hgxsq8tnwd]") + } + + val endTimeNs = System.nanoTime() + val elapsedTimeNs = endTimeNs - startTimeNs + list.add(elapsedTimeNs) + } + + logTimes("original", originalTimes) + logTimes("new-slow", slowTimes) + logTimes("new-fast", newTimes) + } + + private inline fun doTest(crossinline compareFunc: (s1: String, s2: String) -> Int) { + strings.sortedWith({ s1, s2 -> compareFunc(s1, s2) }) + } + + private companion object { + + val strings = List(LIST_SIZE) { "/projects/asdfhasdkfjk/database/asdfsadf/items/item$it" } + + fun logTimes(name: String, list: List) { + val averageMs = list.average().roundToLong() / 1_000_000 + println("$name: ${averageMs}ms (n=${list.size})") + } + + } + +} \ No newline at end of file diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java index 2cc39337002..f887ae95e75 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java @@ -85,6 +85,16 @@ public static int compareIntegers(int i1, int i2) { } } + public static int compareUtf8StringsOriginal(String left, String right) { + return left.compareTo(right); + } + + public static int compareUtf8StringsSlow(String left, String right) { + ByteString leftBytes = ByteString.copyFromUtf8(left); + ByteString rightBytes = ByteString.copyFromUtf8(right); + return compareByteStrings(leftBytes, rightBytes); + } + /** Compare strings in UTF-8 encoded byte order */ public static int compareUtf8Strings(String left, String right) { int i = 0; diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/util/Utf8PerformanceUnitTest.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/util/Utf8PerformanceUnitTest.kt new file mode 100644 index 00000000000..996faee27fb --- /dev/null +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/util/Utf8PerformanceUnitTest.kt @@ -0,0 +1,61 @@ +package com.google.firebase.firestore.util + +import io.kotest.property.Arb +import io.kotest.property.arbitrary.of +import io.kotest.property.checkAll +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.math.roundToLong +import kotlin.time.Duration + +const val ITERATION_COUNT = 500 +const val LIST_SIZE = 500_000 + +class Utf8PerformanceUnitTest { + + @Test + fun test() = runTest(timeout = Duration.INFINITE) { + val originalTimes = mutableListOf() + val slowTimes = mutableListOf() + val newTimes = mutableListOf() + val timesArb = Arb.of(originalTimes, slowTimes, newTimes) + + checkAll(ITERATION_COUNT, timesArb) { list -> + val startTimeNs = System.nanoTime() + + if (list === originalTimes) { + doTest { s1, s2 -> Util.compareUtf8StringsOriginal(s1, s2) } + } else if (list === slowTimes) { + doTest { s1, s2 -> Util.compareUtf8StringsSlow(s1, s2) } + } else if (list === newTimes) { + doTest { s1, s2 -> Util.compareUtf8Strings(s1, s2) } + } else { + throw Exception("unknown list: $list [hgxsq8tnwd]") + } + + val endTimeNs = System.nanoTime() + val elapsedTimeNs = endTimeNs - startTimeNs + list.add(elapsedTimeNs) + } + + logTimes("original", originalTimes) + logTimes("new-slow", slowTimes) + logTimes("new-fast", newTimes) + } + + private inline fun doTest(crossinline compareFunc: (s1: String, s2: String) -> Int) { + strings.sortedWith({ s1, s2 -> compareFunc(s1, s2) }) + } + + private companion object { + + val strings = List(LIST_SIZE) { "/projects/asdfhasdkfjk/database/asdfsadf/items/item$it" } + + fun logTimes(name: String, list: List) { + val averageMs = list.average().roundToLong() / 1_000_000 + println("$name: ${averageMs}ms (n=${list.size})") + } + + } + +} \ No newline at end of file diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/util/UtilTest.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/util/UtilTest.java index ccd88854ba7..25b59499aef 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/util/UtilTest.java +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/util/UtilTest.java @@ -35,6 +35,7 @@ @RunWith(JUnit4.class) public class UtilTest { + @Test public void testToDebugString() { assertEquals("", Util.toDebugString(ByteString.EMPTY)); From 3d231d3a55d1eb61612c218fe5c4144f173c0c84 Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Fri, 27 Jun 2025 19:43:36 +0000 Subject: [PATCH 02/32] new algorithm skeleton added --- .../Utf8PerformanceIntegrationTest.kt | 6 +++++- .../google/firebase/firestore/util/Util.java | 20 +++++++++++++++++++ .../firestore/util/Utf8PerformanceUnitTest.kt | 6 +++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/Utf8PerformanceIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/Utf8PerformanceIntegrationTest.kt index af7c24e731b..ede62fb1be9 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/Utf8PerformanceIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/Utf8PerformanceIntegrationTest.kt @@ -19,7 +19,8 @@ class Utf8PerformanceIntegrationTest { val originalTimes = mutableListOf() val slowTimes = mutableListOf() val newTimes = mutableListOf() - val timesArb = Arb.of(originalTimes, slowTimes, newTimes) + val denverTimes = mutableListOf() + val timesArb = Arb.of(originalTimes, slowTimes, newTimes, denverTimes) checkAll(ITERATION_COUNT, timesArb) { list -> val startTimeNs = System.nanoTime() @@ -30,6 +31,8 @@ class Utf8PerformanceIntegrationTest { doTest { s1, s2 -> Util.compareUtf8StringsSlow(s1, s2) } } else if (list === newTimes) { doTest { s1, s2 -> Util.compareUtf8Strings(s1, s2) } + } else if (list === denverTimes) { + doTest { s1, s2 -> Util.compareUtf8StringsDenver(s1, s2) } } else { throw Exception("unknown list: $list [hgxsq8tnwd]") } @@ -42,6 +45,7 @@ class Utf8PerformanceIntegrationTest { logTimes("original", originalTimes) logTimes("new-slow", slowTimes) logTimes("new-fast", newTimes) + logTimes("new-denver", denverTimes) } private inline fun doTest(crossinline compareFunc: (s1: String, s2: String) -> Int) { diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java index f887ae95e75..c50f364664b 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/Util.java @@ -95,6 +95,26 @@ public static int compareUtf8StringsSlow(String left, String right) { return compareByteStrings(leftBytes, rightBytes); } + public static int compareUtf8StringsDenver(String left, String right) { + int i = 0; + while (i < left.length() && i < right.length()) { + if (left.charAt(i) != right.charAt(i)) { + break; + } + i++; + } + + if (i == left.length() && i == right.length()) { + return 0; + } else if (i == left.length()) { + return -1; + } else if (i == right.length()) { + return 1; + } + + return left.charAt(i) < right.charAt(i) ? -1 : 1; + } + /** Compare strings in UTF-8 encoded byte order */ public static int compareUtf8Strings(String left, String right) { int i = 0; diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/util/Utf8PerformanceUnitTest.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/util/Utf8PerformanceUnitTest.kt index 996faee27fb..e60fa300276 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/util/Utf8PerformanceUnitTest.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/util/Utf8PerformanceUnitTest.kt @@ -18,7 +18,8 @@ class Utf8PerformanceUnitTest { val originalTimes = mutableListOf() val slowTimes = mutableListOf() val newTimes = mutableListOf() - val timesArb = Arb.of(originalTimes, slowTimes, newTimes) + val denverTimes = mutableListOf() + val timesArb = Arb.of(originalTimes, slowTimes, newTimes, denverTimes) checkAll(ITERATION_COUNT, timesArb) { list -> val startTimeNs = System.nanoTime() @@ -29,6 +30,8 @@ class Utf8PerformanceUnitTest { doTest { s1, s2 -> Util.compareUtf8StringsSlow(s1, s2) } } else if (list === newTimes) { doTest { s1, s2 -> Util.compareUtf8Strings(s1, s2) } + } else if (list === denverTimes) { + doTest { s1, s2 -> Util.compareUtf8StringsDenver(s1, s2) } } else { throw Exception("unknown list: $list [hgxsq8tnwd]") } @@ -41,6 +44,7 @@ class Utf8PerformanceUnitTest { logTimes("original", originalTimes) logTimes("new-slow", slowTimes) logTimes("new-fast", newTimes) + logTimes("new-denver", denverTimes) } private inline fun doTest(crossinline compareFunc: (s1: String, s2: String) -> Int) { From ab55ee33c2f5391af4b8ae5606dbe5e1f11a0c02 Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Fri, 27 Jun 2025 16:39:08 -0400 Subject: [PATCH 03/32] add code to fdc demo app so it can be built in release mode --- .../demo/src/main/AndroidManifest.xml | 6 - .../minimaldemo/ListItemsActivity.kt | 117 ---------- .../minimaldemo/ListItemsViewModel.kt | 117 ---------- .../dataconnect/minimaldemo/MainActivity.kt | 137 +---------- .../dataconnect/minimaldemo/MainViewModel.kt | 221 ++++-------------- .../dataconnect/minimaldemo/MyApplication.kt | 153 ------------ .../firebase/dataconnect/minimaldemo/arbs.kt | 190 --------------- .../dataconnect/minimaldemo/strings.kt | 89 ------- .../src/main/res/layout/activity_main.xml | 53 +---- firebase-firestore/firebase-firestore.gradle | 1 - .../Utf8PerformanceIntegrationTest.kt | 2 +- 11 files changed, 53 insertions(+), 1033 deletions(-) delete mode 100644 firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/ListItemsActivity.kt delete mode 100644 firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/ListItemsViewModel.kt delete mode 100644 firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MyApplication.kt delete mode 100644 firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/arbs.kt delete mode 100644 firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/strings.kt diff --git a/firebase-dataconnect/demo/src/main/AndroidManifest.xml b/firebase-dataconnect/demo/src/main/AndroidManifest.xml index 084754492ca..4168d15f712 100644 --- a/firebase-dataconnect/demo/src/main/AndroidManifest.xml +++ b/firebase-dataconnect/demo/src/main/AndroidManifest.xml @@ -19,7 +19,6 @@ limitations under the License. @@ -31,11 +30,6 @@ limitations under the License. - - diff --git a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/ListItemsActivity.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/ListItemsActivity.kt deleted file mode 100644 index 72c6ef546e2..00000000000 --- a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/ListItemsActivity.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.firebase.dataconnect.minimaldemo.connector.GetAllItemsQuery -import com.google.firebase.dataconnect.minimaldemo.databinding.ActivityListItemsBinding -import com.google.firebase.dataconnect.minimaldemo.databinding.ListItemBinding -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class ListItemsActivity : AppCompatActivity() { - - private lateinit var myApplication: MyApplication - private lateinit var viewBinding: ActivityListItemsBinding - private val viewModel: ListItemsViewModel by viewModels { ListItemsViewModel.Factory } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - myApplication = application as MyApplication - - viewBinding = ActivityListItemsBinding.inflate(layoutInflater) - viewBinding.recyclerView.also { - val linearLayoutManager = LinearLayoutManager(this) - it.layoutManager = linearLayoutManager - val dividerItemDecoration = DividerItemDecoration(this, linearLayoutManager.layoutDirection) - it.addItemDecoration(dividerItemDecoration) - } - setContentView(viewBinding.root) - - lifecycleScope.launch { - if (viewModel.loadingState == ListItemsViewModel.LoadingState.NotStarted) { - viewModel.getItems() - } - viewModel.stateSequenceNumber.flowWithLifecycle(lifecycle).collectLatest { - onViewModelStateChange() - } - } - } - - private fun onViewModelStateChange() { - val items = viewModel.result?.getOrNull() - val exception = viewModel.result?.exceptionOrNull() - val loadingState = viewModel.loadingState - - if (loadingState == ListItemsViewModel.LoadingState.InProgress) { - viewBinding.statusText.text = "Loading Items..." - viewBinding.statusText.visibility = View.VISIBLE - viewBinding.recyclerView.visibility = View.GONE - viewBinding.recyclerView.adapter = null - } else if (items !== null) { - viewBinding.statusText.text = null - viewBinding.statusText.visibility = View.GONE - viewBinding.recyclerView.visibility = View.VISIBLE - val oldAdapter = viewBinding.recyclerView.adapter as? RecyclerViewAdapterImpl - if (oldAdapter === null || oldAdapter.items !== items) { - viewBinding.recyclerView.adapter = RecyclerViewAdapterImpl(items) - } - } else if (exception !== null) { - viewBinding.statusText.text = "Loading items FAILED: $exception" - viewBinding.statusText.visibility = View.VISIBLE - viewBinding.recyclerView.visibility = View.GONE - viewBinding.recyclerView.adapter = null - } else { - viewBinding.statusText.text = null - viewBinding.statusText.visibility = View.GONE - viewBinding.recyclerView.visibility = View.GONE - } - } - - private class RecyclerViewAdapterImpl(val items: List) : - RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerViewViewHolderImpl { - val binding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return RecyclerViewViewHolderImpl(binding) - } - - override fun getItemCount() = items.size - - override fun onBindViewHolder(holder: RecyclerViewViewHolderImpl, position: Int) { - holder.bindTo(items[position]) - } - } - - private class RecyclerViewViewHolderImpl(private val binding: ListItemBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bindTo(item: GetAllItemsQuery.Data.ItemsItem) { - binding.id.text = item.id.toString() - binding.name.text = item.string - } - } -} diff --git a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/ListItemsViewModel.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/ListItemsViewModel.kt deleted file mode 100644 index c08ed3aa037..00000000000 --- a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/ListItemsViewModel.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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.annotation.MainThread -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.GetAllItemsQuery -import com.google.firebase.dataconnect.minimaldemo.connector.execute -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -class ListItemsViewModel(private val app: MyApplication) : ViewModel() { - - // Threading Note: _state and the variables below it may ONLY be accessed (read from and/or - // written to) by the main thread; otherwise a race condition and undefined behavior will result. - private val _stateSequenceNumber = MutableStateFlow(111999L) - val stateSequenceNumber: StateFlow = _stateSequenceNumber.asStateFlow() - - var result: Result>? = null - private set - - private var job: Job? = null - val loadingState: LoadingState = - job.let { - if (it === null) { - LoadingState.NotStarted - } else if (it.isCancelled || it.isCompleted) { - LoadingState.Completed - } else { - LoadingState.InProgress - } - } - - enum class LoadingState { - NotStarted, - InProgress, - Completed, - } - - @OptIn(ExperimentalCoroutinesApi::class) - @MainThread - fun getItems() { - // If there is already a "get items" operation in progress, then just return and let the - // in-progress operation finish. - if (loadingState == LoadingState.InProgress) { - return - } - - // Start a new coroutine to perform the "get items" operation. - val job: Deferred> = - viewModelScope.async { app.getConnector().getAllItems.execute().data.items } - - this.result = null - this.job = job - _stateSequenceNumber.value++ - - // Update the internal state once the "get items" operation has completed. - job.invokeOnCompletion { exception -> - // Don't log CancellationException, as documented by invokeOnCompletion(). - if (exception is CancellationException) { - return@invokeOnCompletion - } - - val result = - if (exception !== null) { - Log.w(TAG, "WARNING: Getting all items FAILED: $exception", exception) - Result.failure(exception) - } else { - val items = job.getCompleted() - Log.i(TAG, "Retrieved all items ${items.size} items") - Result.success(items) - } - - viewModelScope.launch { - if (this@ListItemsViewModel.job === job) { - this@ListItemsViewModel.result = result - this@ListItemsViewModel.job = null - _stateSequenceNumber.value++ - } - } - } - } - - companion object { - private const val TAG = "ListItemsViewModel" - - val Factory: ViewModelProvider.Factory = viewModelFactory { - initializer { ListItemsViewModel(this[APPLICATION_KEY] as MyApplication) } - } - } -} 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 index a855bd8dbcf..d3a26bc1ba2 100644 --- 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 @@ -15,158 +15,41 @@ */ package com.google.firebase.dataconnect.minimaldemo -import android.content.Intent import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -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.MainViewModel.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: MainViewModel by viewModels { MainViewModel.Factory } + private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - myApplication = application as MyApplication viewBinding = ActivityMainBinding.inflate(layoutInflater) setContentView(viewBinding.root) - viewBinding.insertItemButton.setOnClickListener { viewModel.insertItem() } - viewBinding.getItemButton.setOnClickListener { viewModel.getItem() } - viewBinding.deleteItemButton.setOnClickListener { viewModel.deleteItem() } - viewBinding.useEmulatorCheckBox.setOnCheckedChangeListener(useEmulatorOnCheckedChangeListener) - viewBinding.debugLoggingCheckBox.setOnCheckedChangeListener(debugLoggingOnCheckedChangeListener) - lifecycleScope.launch { - viewModel.stateSequenceNumber.flowWithLifecycle(lifecycle).collectLatest { - onViewModelStateChange() + viewModel.state.flowWithLifecycle(lifecycle).collectLatest { + onViewModelStateChange(it) } } } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_main, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - R.id.action_list -> { - startActivity(Intent(this, ListItemsActivity::class.java)) - true - } - else -> super.onOptionsItemSelected(item) - } - - override fun onResume() { - super.onResume() - lifecycleScope.launch { - viewBinding.useEmulatorCheckBox.isChecked = myApplication.getUseDataConnectEmulator() - viewBinding.debugLoggingCheckBox.isChecked = myApplication.getDataConnectDebugLoggingEnabled() - } - } - - private fun onViewModelStateChange() { - viewBinding.progressText.text = viewModel.progressText - viewBinding.insertItemButton.isEnabled = !viewModel.isInsertOperationInProgress - viewBinding.getItemButton.isEnabled = - viewModel.isGetOperationRunnable && !viewModel.isGetOperationInProgress - viewBinding.deleteItemButton.isEnabled = - viewModel.isDeleteOperationRunnable && !viewModel.isDeleteOperationInProgress - } - - private val debugLoggingOnCheckedChangeListener = OnCheckedChangeListener { _, isChecked -> - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - return@OnCheckedChangeListener - } - myApplication.coroutineScope.launch { - myApplication.setDataConnectDebugLoggingEnabled(isChecked) + private fun onViewModelStateChange(newState: MainViewModel.State) { + val newText: String = when (newState) { + is MainViewModel.State.NotStarted -> "not started" + is MainViewModel.State.Running -> "running" + is MainViewModel.State.Finished -> "finished (error=${newState.error})" } + viewBinding.progressText.text = newText + println("zzyzx $newText") } - private val useEmulatorOnCheckedChangeListener = OnCheckedChangeListener { _, isChecked -> - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - return@OnCheckedChangeListener - } - myApplication.coroutineScope.launch { myApplication.setUseDataConnectEmulator(isChecked) } - } - - companion object { - - private val MainViewModel.isInsertOperationInProgress: Boolean - get() = insertState is OperationState.InProgress - - private val MainViewModel.isGetOperationInProgress: Boolean - get() = getState is OperationState.InProgress - - private val MainViewModel.isDeleteOperationInProgress: Boolean - get() = deleteState is OperationState.InProgress - - private val MainViewModel.isGetOperationRunnable: Boolean - get() = lastInsertedKey !== null - - private val MainViewModel.isDeleteOperationRunnable: Boolean - get() = lastInsertedKey !== null - - private val MainViewModel.progressText: String? - get() { - // Save properties to local variables to enable Kotlin's type narrowing in the "if" blocks - // below. - val insertState = insertState - val getState = getState - val deleteState = deleteState - val state = - listOfNotNull(insertState, getState, deleteState).maxByOrNull { it.sequenceNumber } - - return if (state === null) { - null - } else if (state === insertState) { - when (insertState) { - is OperationState.InProgress -> - "Inserting item: ${insertState.variables.toDisplayString()}" - is OperationState.Completed -> - insertState.result.fold( - onSuccess = { - "Inserted item with id=${it.id}:\n${insertState.variables.toDisplayString()}" - }, - onFailure = { "Inserting item ${insertState.variables} FAILED: $it" }, - ) - } - } else if (state === getState) { - when (getState) { - is OperationState.InProgress -> "Retrieving item with ID ${getState.variables.id}..." - is OperationState.Completed -> - getState.result.fold( - onSuccess = { - "Retrieved item with ID ${getState.variables.id}:\n${it?.toDisplayString()}" - }, - onFailure = { "Retrieving item with ID ${getState.variables.id} FAILED: $it" }, - ) - } - } else if (state === deleteState) { - when (deleteState) { - is OperationState.InProgress -> "Deleting item with ID ${deleteState.variables.id}..." - is OperationState.Completed -> - deleteState.result.fold( - onSuccess = { "Deleted item with ID ${deleteState.variables.id}" }, - onFailure = { "Deleting item with ID ${deleteState.variables.id} FAILED: $it" }, - ) - } - } else { - throw RuntimeException("internal error: unknown state: $state (error code vp4rjptx6r)") - } - } - } } diff --git a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainViewModel.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainViewModel.kt index 1769a9154b8..3f6839611d0 100644 --- a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainViewModel.kt +++ b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainViewModel.kt @@ -15,210 +15,71 @@ */ package com.google.firebase.dataconnect.minimaldemo -import android.util.Log -import androidx.annotation.MainThread +import androidx.annotation.AnyThread 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 kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds -class MainViewModel(private val app: MyApplication) : ViewModel() { +class MainViewModel : ViewModel() { - private val rs = RandomSource.default() - - // Threading Note: _state and the variables below it may ONLY be accessed (read from and/or - // written to) by the main thread; otherwise a race condition and undefined behavior will result. - private val _stateSequenceNumber = MutableStateFlow(111999L) - val stateSequenceNumber: StateFlow = _stateSequenceNumber.asStateFlow() - - var insertState: OperationState? = null - private set - - var getState: OperationState? = null - private set - - var deleteState: OperationState? = null - private set - - var lastInsertedKey: Zwda6x9zyyKey? = null - private set - - @OptIn(ExperimentalCoroutinesApi::class) - @MainThread - fun insertItem() { - val arb = Arb.insertItemVariables() - val variables = if (rs.random.nextFloat() < 0.333f) arb.edgecase(rs)!! else arb.next(rs) - - // If there is already an "insert" in progress, then just return and let the in-progress - // operation finish. - if (insertState is OperationState.InProgress) { - return - } - - // Start a new coroutine to perform the "insert" operation. - Log.i(TAG, "Inserting item: $variables") - val job: Deferred = - viewModelScope.async { app.getConnector().insertItem.ref(variables).execute().data.key } - val inProgressOperationState = - OperationState.InProgress(_stateSequenceNumber.value, variables, job) - insertState = inProgressOperationState - _stateSequenceNumber.value++ - - // Update the internal state once the "insert" operation has completed. - job.invokeOnCompletion { exception -> - // Don't log CancellationException, as documented by invokeOnCompletion(). - if (exception is CancellationException) { - return@invokeOnCompletion - } - - 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) - } - - viewModelScope.launch { - if (insertState === inProgressOperationState) { - insertState = OperationState.Completed(_stateSequenceNumber.value, variables, result) - result.onSuccess { lastInsertedKey = it } - _stateSequenceNumber.value++ - } - } - } + sealed interface State { + data object NotStarted : State + data class Running(val job: Job) : State + data class Finished(val error: Throwable?) : State } - @OptIn(ExperimentalCoroutinesApi::class) - fun getItem() { - // 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 = lastInsertedKey ?: return - - // If there is already a "get" in progress, then just return and let the in-progress operation - // finish. - if (getState is OperationState.InProgress) { - return - } - - // Start a new coroutine to perform the "get" operation. - Log.i(TAG, "Retrieving item with key: $key") - val job: Deferred = - viewModelScope.async { app.getConnector().getItemByKey.execute(key).data.item } - val inProgressOperationState = OperationState.InProgress(_stateSequenceNumber.value, key, job) - getState = inProgressOperationState - _stateSequenceNumber.value++ + private val _state = MutableStateFlow(State.NotStarted) - // Update the internal state once the "get" operation has completed. - job.invokeOnCompletion { exception -> - // Don't log CancellationException, as documented by invokeOnCompletion(). - if (exception is CancellationException) { - return@invokeOnCompletion - } + val state: StateFlow = _state.asStateFlow() - val result = - if (exception !== null) { - Log.w(TAG, "WARNING: Retrieving item with key=$key FAILED: $exception", exception) - Result.failure(exception) - } else { - val item = job.getCompleted() - Log.i(TAG, "Retrieved item with key: $key (item=${item})") - Result.success(item) - } + init { + startTest() + } - viewModelScope.launch { - if (getState === inProgressOperationState) { - getState = OperationState.Completed(_stateSequenceNumber.value, key, result) - _stateSequenceNumber.value++ + @AnyThread + fun startTest() { + val newState = _state.updateAndGet { currentState -> + when (currentState) { + is State.Running -> currentState + else -> { + State.Running(createLazyJob()) } } } - } - - fun deleteItem() { - // If there is no previous successful "insert" operation, then we don't know any ID's to delete, - // so just do nothing. - val key: Zwda6x9zyyKey = lastInsertedKey ?: return - // If there is already a "delete" in progress, then just return and let the in-progress - // operation finish. - if (deleteState is OperationState.InProgress) { - return + if (newState is State.Running) { + newState.job.start() } + } - // Start a new coroutine to perform the "delete" operation. - Log.i(TAG, "Deleting item with key: $key") - val job: Deferred = - viewModelScope.async { app.getConnector().deleteItemByKey.execute(key) } - val inProgressOperationState = OperationState.InProgress(_stateSequenceNumber.value, key, job) - deleteState = inProgressOperationState - _stateSequenceNumber.value++ - - // Update the internal state once the "delete" operation has completed. - job.invokeOnCompletion { exception -> - // Don't log CancellationException, as documented by invokeOnCompletion(). - if (exception is CancellationException) { - return@invokeOnCompletion + private fun createLazyJob(): Job { + val job = viewModelScope.launch(Dispatchers.IO, CoroutineStart.LAZY) { + repeat(5) { + println("zzyzx $it") + delay(1.seconds) } - - val result = - if (exception !== null) { - Log.w(TAG, "WARNING: Deleting item with key=$key FAILED: $exception", exception) - Result.failure(exception) + } + job.invokeOnCompletion { throwable -> + _state.update { currentState -> + if (currentState is State.Running && currentState.job === job) { + State.Finished(throwable) } else { - Log.i(TAG, "Deleted item with key: $key") - Result.success(Unit) - } - - viewModelScope.launch { - if (deleteState === inProgressOperationState) { - deleteState = OperationState.Completed(_stateSequenceNumber.value, key, result) - _stateSequenceNumber.value++ + currentState } } } + return job } - sealed interface OperationState { - val sequenceNumber: Long - - data class InProgress( - override val sequenceNumber: Long, - val variables: Variables, - val job: Deferred, - ) : OperationState - - data class Completed( - override val sequenceNumber: Long, - val variables: Variables, - val result: Result, - ) : OperationState - } - - companion object { - private const val TAG = "MainViewModel" - - val Factory: ViewModelProvider.Factory = viewModelFactory { - initializer { MainViewModel(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 deleted file mode 100644 index 1b6360efb58..00000000000 --- a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MyApplication.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * 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.value - private val connectorMutex = Mutex() - private var connector: Ctry3q3tp6kzxConnector? = null - - override fun onCreate() { - super.onCreate() - - coroutineScope.launch { - if (getDataConnectDebugLoggingEnabled()) { - FirebaseDataConnect.logLevel.value = 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.value = 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 deleted file mode 100644 index a77016f9659..00000000000 --- a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/arbs.kt +++ /dev/null @@ -1,190 +0,0 @@ -/* - * 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 deleted file mode 100644 index 3ddf838ed2d..00000000000 --- a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/strings.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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 index f37798c01ce..2d3a5118cf2 100644 --- a/firebase-dataconnect/demo/src/main/res/layout/activity_main.xml +++ b/firebase-dataconnect/demo/src/main/res/layout/activity_main.xml @@ -44,63 +44,12 @@ limitations under the License. android:paddingBottom="16dp" app:layout_behavior="@string/appbar_scrolling_view_behavior" > -