diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26dadbf5..d3c09e9a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,6 +104,7 @@ dependencies { implementation(libs.lifecycle.ktx) implementation(libs.lifecycle.compose) implementation(libs.lifecycle.runtime.compose) + implementation(libs.lifecycle.process) ksp(libs.room.compiler) implementation(libs.room.ktx) diff --git a/app/src/main/java/com/google/android/samples/socialite/SocialApp.kt b/app/src/main/java/com/google/android/samples/socialite/SocialApp.kt index d340cd02..ce773e6f 100644 --- a/app/src/main/java/com/google/android/samples/socialite/SocialApp.kt +++ b/app/src/main/java/com/google/android/samples/socialite/SocialApp.kt @@ -16,7 +16,19 @@ package com.google.android.samples.socialite import android.app.Application +import androidx.lifecycle.ProcessLifecycleOwner +import com.google.android.samples.socialite.repository.ChatReplyHelper import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject @HiltAndroidApp -class SocialApp : Application() +class SocialApp : Application() { + + @Inject + lateinit var chatReplyHelper: ChatReplyHelper + + override fun onCreate() { + super.onCreate() + chatReplyHelper.start(ProcessLifecycleOwner.get().lifecycle) + } +} diff --git a/app/src/main/java/com/google/android/samples/socialite/data/MessageDao.kt b/app/src/main/java/com/google/android/samples/socialite/data/MessageDao.kt index 7d242d41..d8b5b938 100644 --- a/app/src/main/java/com/google/android/samples/socialite/data/MessageDao.kt +++ b/app/src/main/java/com/google/android/samples/socialite/data/MessageDao.kt @@ -42,4 +42,7 @@ interface MessageDao { @Query("DELETE FROM Message") suspend fun clearAll() + + @Query("SELECT * FROM Message WHERE senderId = 0 ORDER BY timestamp DESC LIMIT 1") + fun latestOutgoingMessage(): Flow } diff --git a/app/src/main/java/com/google/android/samples/socialite/di/DatabaseModule.kt b/app/src/main/java/com/google/android/samples/socialite/di/DatabaseModule.kt index 3114c732..3ef73b80 100644 --- a/app/src/main/java/com/google/android/samples/socialite/di/DatabaseModule.kt +++ b/app/src/main/java/com/google/android/samples/socialite/di/DatabaseModule.kt @@ -32,14 +32,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import java.util.concurrent.Executors -import javax.inject.Qualifier import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.asCoroutineDispatcher - -@Qualifier -annotation class AppCoroutineScope @Module @InstallIn(SingletonComponent::class) @@ -63,13 +56,6 @@ object DatabaseModule { @Provides fun providesContactDao(database: AppDatabase): ContactDao = database.contactDao() - - @Provides - @Singleton - @AppCoroutineScope - fun providesApplicationCoroutineScope(): CoroutineScope = CoroutineScope( - Executors.newSingleThreadExecutor().asCoroutineDispatcher(), - ) } @Module diff --git a/app/src/main/java/com/google/android/samples/socialite/repository/ChatReplyHelper.kt b/app/src/main/java/com/google/android/samples/socialite/repository/ChatReplyHelper.kt new file mode 100644 index 00000000..9ed2a4d9 --- /dev/null +++ b/app/src/main/java/com/google/android/samples/socialite/repository/ChatReplyHelper.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * 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.android.samples.socialite.repository + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.google.android.samples.socialite.model.Message +import javax.inject.Inject +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class ChatReplyHelper @Inject constructor( + private val repository: ChatRepository, +) { + + fun start(lifecycle: Lifecycle) { + lifecycle.coroutineScope.launch { + var first = true + // Prevents the same outgoing message to be processed twice. + var previousMessageId = 0L + // The Flow from Room is collected every time the table is modified. The same outgoing + // message can be passed multiple times. + repository.getLatestOutgoingMessage().collect { message -> + if (first) { + // Ignore the first message because it is an existing latest message. + first = false + } else if (previousMessageId != message.id) { + handleNewMessage(message) + previousMessageId = message.id + } + } + } + } + + /** + * message: A [Message] sent by the user. + */ + private suspend fun handleNewMessage(message: Message) { + // The person is typing... + delay(5000L) + val replier = repository.findReplier(message) ?: return + repository.receiveMessage( + replier.reply(message.text).apply { chatId = message.chatId }.build(), + ) + } +} diff --git a/app/src/main/java/com/google/android/samples/socialite/repository/ChatRepository.kt b/app/src/main/java/com/google/android/samples/socialite/repository/ChatRepository.kt index fd7d57dd..ae28d7a5 100644 --- a/app/src/main/java/com/google/android/samples/socialite/repository/ChatRepository.kt +++ b/app/src/main/java/com/google/android/samples/socialite/repository/ChatRepository.kt @@ -17,26 +17,19 @@ package com.google.android.samples.socialite.repository import com.google.android.samples.socialite.data.ChatDao -import com.google.android.samples.socialite.data.ContactDao import com.google.android.samples.socialite.data.MessageDao -import com.google.android.samples.socialite.di.AppCoroutineScope import com.google.android.samples.socialite.model.ChatDetail +import com.google.android.samples.socialite.model.Contact import com.google.android.samples.socialite.model.Message import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch @Singleton class ChatRepository @Inject internal constructor( private val chatDao: ChatDao, private val messageDao: MessageDao, - private val contactDao: ContactDao, private val notificationHelper: NotificationHelper, - @AppCoroutineScope - private val coroutineScope: CoroutineScope, ) { private var currentChat: Long = 0L @@ -56,6 +49,18 @@ class ChatRepository @Inject internal constructor( return messageDao.allByChatId(chatId) } + fun getLatestOutgoingMessage(): Flow { + return messageDao.latestOutgoingMessage() + } + + suspend fun findReplier(message: Message): Contact? { + // Find the chat room. + val detail = chatDao.loadDetailById(message.chatId) ?: return null + // Take the first contact in the chat room to reply. + // TODO: Take group chat into account. + return detail.firstContact + } + suspend fun sendMessage( chatId: Long, text: String, @@ -76,25 +81,22 @@ class ChatRepository @Inject internal constructor( ), ) notificationHelper.pushShortcut(detail.firstContact, PushReason.OutgoingMessage) - // Simulate a response from the peer. - // The code here is just for demonstration purpose in this sample. - // Real apps will use their server backend and Firebase Cloud Messaging to deliver messages. - coroutineScope.launch { - // The person is typing... - delay(5000L) - // Receive a reply. - messageDao.insert( - detail.firstContact.reply(text).apply { this.chatId = chatId }.build(), + } + + suspend fun receiveMessage( + message: Message, + ) { + val detail = chatDao.loadDetailById(message.chatId) ?: return + // Receive a reply. + messageDao.insert(message) + notificationHelper.pushShortcut(detail.firstContact, PushReason.IncomingMessage) + // Show notification if the chat is not on the foreground. + if (message.chatId != currentChat) { + notificationHelper.showNotification( + detail.firstContact, + messageDao.loadAll(message.chatId), + false, ) - notificationHelper.pushShortcut(detail.firstContact, PushReason.IncomingMessage) - // Show notification if the chat is not on the foreground. - if (chatId != currentChat) { - notificationHelper.showNotification( - detail.firstContact, - messageDao.loadAll(chatId), - false, - ) - } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e1c01c44..6a67782d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,6 +81,7 @@ hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-com junit = { group = "junit", name = "junit", version.ref = "junit" } lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" } media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "media3" }