diff --git a/firebase-dataconnect/demo/firebase/dataconnect/connector/operations.gql b/firebase-dataconnect/demo/firebase/dataconnect/connector/operations.gql index 9eedec56a7a..31dda056ed3 100644 --- a/firebase-dataconnect/demo/firebase/dataconnect/connector/operations.gql +++ b/firebase-dataconnect/demo/firebase/dataconnect/connector/operations.gql @@ -49,6 +49,26 @@ query GetItemByKey( } } +query GetAllItems @auth(level: PUBLIC) { + items: zwda6x9zyys { + id + string + int + int64 + float + boolean + date + timestamp + any + } +} + +mutation DeleteItemByKey( + $key: zwda6x9zyy_Key! +) @auth(level: PUBLIC) { + zwda6x9zyy_delete(key: $key) +} + # This mutation exists only as a workaround for b/382688278 where the following # compiler error will otherwise result when compiling the generated code: # Serializer has not been found for type 'java.util.UUID'. To use context diff --git a/firebase-dataconnect/demo/src/main/AndroidManifest.xml b/firebase-dataconnect/demo/src/main/AndroidManifest.xml index 157258a1a8b..084754492ca 100644 --- a/firebase-dataconnect/demo/src/main/AndroidManifest.xml +++ b/firebase-dataconnect/demo/src/main/AndroidManifest.xml @@ -16,13 +16,12 @@ See the License for the specific language governing permissions and limitations under the License. --> - + @@ -32,6 +31,11 @@ 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 new file mode 100644 index 00000000000..72c6ef546e2 --- /dev/null +++ b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/ListItemsActivity.kt @@ -0,0 +1,117 @@ +/* + * 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 new file mode 100644 index 00000000000..c08ed3aa037 --- /dev/null +++ b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/ListItemsViewModel.kt @@ -0,0 +1,117 @@ +/* + * 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 06c51458c25..a855bd8dbcf 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,15 +15,17 @@ */ package com.google.firebase.dataconnect.minimaldemo +import android.content.Intent import android.os.Bundle -import android.view.View.OnClickListener +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.MainActivityViewModel.State.OperationState +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 @@ -32,7 +34,7 @@ class MainActivity : AppCompatActivity() { private lateinit var myApplication: MyApplication private lateinit var viewBinding: ActivityMainBinding - private val viewModel: MainActivityViewModel by viewModels { MainActivityViewModel.Factory } + private val viewModel: MainViewModel by viewModels { MainViewModel.Factory } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -41,16 +43,33 @@ class MainActivity : AppCompatActivity() { viewBinding = ActivityMainBinding.inflate(layoutInflater) setContentView(viewBinding.root) - viewBinding.insertItemButton.setOnClickListener(insertButtonOnClickListener) - viewBinding.getItemButton.setOnClickListener(getItemButtonOnClickListener) + 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.state.flowWithLifecycle(lifecycle).collectLatest(::collectViewModelState) + viewModel.stateSequenceNumber.flowWithLifecycle(lifecycle).collectLatest { + onViewModelStateChange() + } } } + 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 { @@ -59,67 +78,15 @@ class MainActivity : AppCompatActivity() { } } - 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 + private fun onViewModelStateChange() { + viewBinding.progressText.text = viewModel.progressText + viewBinding.insertItemButton.isEnabled = !viewModel.isInsertOperationInProgress 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 - } + viewModel.isGetOperationRunnable && !viewModel.isGetOperationInProgress + viewBinding.deleteItemButton.isEnabled = + viewModel.isDeleteOperationRunnable && !viewModel.isDeleteOperationInProgress } - 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 @@ -135,4 +102,71 @@ class MainActivity : AppCompatActivity() { } 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/MainActivityViewModel.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainActivityViewModel.kt deleted file mode 100644 index 2cfffe725df..00000000000 --- a/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainActivityViewModel.kt +++ /dev/null @@ -1,318 +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.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/MainViewModel.kt b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainViewModel.kt new file mode 100644 index 00000000000..1769a9154b8 --- /dev/null +++ b/firebase-dataconnect/demo/src/main/kotlin/com/google/firebase/dataconnect/minimaldemo/MainViewModel.kt @@ -0,0 +1,224 @@ +/* + * 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.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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class MainViewModel(private val app: MyApplication) : 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++ + } + } + } + } + + @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++ + + // 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 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) + } + + viewModelScope.launch { + if (getState === inProgressOperationState) { + getState = OperationState.Completed(_stateSequenceNumber.value, key, result) + _stateSequenceNumber.value++ + } + } + } + } + + 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 + } + + // 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 + } + + val result = + if (exception !== null) { + Log.w(TAG, "WARNING: Deleting item with key=$key FAILED: $exception", exception) + Result.failure(exception) + } 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++ + } + } + } + } + + 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/res/layout/activity_list_items.xml b/firebase-dataconnect/demo/src/main/res/layout/activity_list_items.xml new file mode 100644 index 00000000000..1bf7af38ab6 --- /dev/null +++ b/firebase-dataconnect/demo/src/main/res/layout/activity_list_items.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + 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 0b3fd7d5931..f37798c01ce 100644 --- a/firebase-dataconnect/demo/src/main/res/layout/activity_main.xml +++ b/firebase-dataconnect/demo/src/main/res/layout/activity_main.xml @@ -64,12 +64,22 @@ limitations under the License. app:layout_constraintRight_toRightOf="parent" /> +