From 70f9906e7204ce17e014e3a66e100cf803c941c7 Mon Sep 17 00:00:00 2001 From: appellet <160597306+appellet@users.noreply.github.com> Date: Thu, 19 Dec 2024 16:51:43 +0100 Subject: [PATCH] Feature/push notification model (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature: add notification request when start the app Asked permission to receive notification on the device when start the app. * feature: add notification token on Profile Added token notification on the Firestore db on the first connection after requesting notification permission. * feature: cloud function to fetch tokens Created a cloud function to fetch notification tokens to send a notification. * fix: permission notification on debug mode Added a boolean to don't ask notification permission on each tests, but only if the app is not on debug mode. * fix: deleted "2" error in index.ts Deleted compilation error in index.ts * feature: add cloud function for push notif Added a cloud function for push notification * feature: add cloud function for push notif Added a cloud function for push notification * fix: modify fetch notification firestore Modified the function sendNotification. * fix: resolve console errors with MulticastMessage Modified the function sendNotification changing with a multicast * fix: fix the multicast message and use new FCM API * feature: added push notification with item notification Added a push notification when you add a friend, add someone to a travel or change the role of a participant. * test: adapte tests to push notification * test(notification): add unit tests for sendNotificationToUser method * test(notification): fix test to verify addNotification is called at least once * chore: disable clear text traffic for security * chore: avoid code duplication Deleted duplicated code with refactored function * doc: add credits to modified files Added LLM credits for code genereted by llm * chore: delete duplicated description Deleted credits llm on old files --------- Co-authored-by: Sylvain NĂ©risson Co-authored-by: RemIsMyWaifuu <160653991+RemIsMyWaifuu@users.noreply.github.com> --- app/build.gradle.kts | 7 ++ .../ui/notification/NotificationItemTest.kt | 6 ++ .../ui/travel/ParticipantListScreenTest.kt | 5 +- app/src/main/AndroidManifest.xml | 7 ++ .../com/github/se/travelpouch/MainActivity.kt | 2 + .../notifications/NotificationViewModel.kt | 23 ++++++ .../push/PushNotificationService.kt | 17 +++++ .../model/profile/ProfileModelView.kt | 19 +++++ .../model/profile/ProfileRepository.kt | 15 ++++ .../profile/ProfileRepositoryFirebase.kt | 24 ++++++ .../model/profile/ProfileRepositoryMock.kt | 9 +++ .../se/travelpouch/ui/home/TravelList.kt | 51 ++++++++++++- .../ui/notifications/NotificationItem.kt | 35 +++++++-- .../ui/profile/ModifyingProfileScreen.kt | 11 ++- .../ui/travel/ParticipantListScreen.kt | 20 ++++- cloud-functions/firestore.rules | 4 + cloud-functions/functions/src/index.ts | 54 +++++++++++++ .../functions/src/pushNotification.ts | 76 +++++++++++++++++++ gradle/libs.versions.toml | 15 ++++ 19 files changed, 383 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/github/se/travelpouch/model/notifications/push/PushNotificationService.kt create mode 100644 cloud-functions/functions/src/pushNotification.ts diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 219d79bf0..29d0a66a6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -197,7 +197,9 @@ dependencies { implementation(libs.androidx.runtime.livedata) implementation(libs.androidx.navigation.testing) implementation(libs.play.services.location) + implementation(libs.firebase.messaging.ktx) implementation(libs.androidx.datastore.preferences) + testImplementation(libs.junit) globalTestImplementation(libs.androidx.junit) globalTestImplementation(libs.androidx.espresso.core) @@ -213,6 +215,10 @@ dependencies { androidTestImplementation(libs.hilt.android.testing) kaptAndroidTest(libs.hilt.android.compiler) + implementation(libs.retrofit) + implementation(libs.converter.gson) + implementation(libs.logging.interceptor) + implementation(libs.converter.scalars) // Google Service and Maps implementation(libs.play.services.maps) @@ -376,3 +382,4 @@ tasks.named("ktfmtCheckTest") { dependsOn("ktfmtFormatTest") mustRunAfter("ktfmtFormatTest") } + diff --git a/app/src/androidTest/java/com/github/se/travelpouch/ui/notification/NotificationItemTest.kt b/app/src/androidTest/java/com/github/se/travelpouch/ui/notification/NotificationItemTest.kt index 0c903e902..e21efb10c 100644 --- a/app/src/androidTest/java/com/github/se/travelpouch/ui/notification/NotificationItemTest.kt +++ b/app/src/androidTest/java/com/github/se/travelpouch/ui/notification/NotificationItemTest.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider import com.github.se.travelpouch.model.activity.ActivityRepository import com.github.se.travelpouch.model.activity.ActivityViewModel import com.github.se.travelpouch.model.documents.DocumentRepository @@ -32,6 +33,7 @@ import com.github.se.travelpouch.ui.notifications.DeclineButton import com.github.se.travelpouch.ui.notifications.InvitationButtons import com.github.se.travelpouch.ui.notifications.NotificationMessage import com.github.se.travelpouch.ui.notifications.NotificationTimestamp +import com.google.firebase.FirebaseApp import org.junit.Before import org.junit.Rule import org.junit.Test @@ -78,6 +80,9 @@ class NotificationItemTest { @Before fun setUp() { + + FirebaseApp.initializeApp(ApplicationProvider.getApplicationContext()) + travelRepository = mock(TravelRepository::class.java) notificationRepository = mock(NotificationRepository::class.java) profileRepository = mock(ProfileRepository::class.java) @@ -93,6 +98,7 @@ class NotificationItemTest { activityViewModel = ActivityViewModel(activityRepository) documentViewModel = DocumentViewModel(documentRepository, documentsManager, mock()) eventViewModel = EventViewModel(eventRepository) + } @Test diff --git a/app/src/androidTest/java/com/github/se/travelpouch/ui/travel/ParticipantListScreenTest.kt b/app/src/androidTest/java/com/github/se/travelpouch/ui/travel/ParticipantListScreenTest.kt index 982fd834f..b0c0a8585 100644 --- a/app/src/androidTest/java/com/github/se/travelpouch/ui/travel/ParticipantListScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/travelpouch/ui/travel/ParticipantListScreenTest.kt @@ -35,9 +35,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.doAnswer import org.mockito.Mockito.doThrow import org.mockito.Mockito.mock +import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.kotlin.any @@ -670,8 +672,7 @@ class ParticipantListScreenTest { .updateTravel(any(), any(), anyOrNull(), any(), any(), anyOrNull()) composeTestRule.onNodeWithTag("addUserButton").performClick() verify(profileRepository).getFsUidByEmail(anyOrNull(), anyOrNull(), anyOrNull()) - verify(notificationRepository).addNotification(anyOrNull()) - + verify(notificationRepository, atLeastOnce()).addNotification(anyOrNull()) // throw impossible exception doAnswer { invocation -> val email = invocation.getArgument(0) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8aab47c3e..245e7f6dd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,6 +28,13 @@ + + + + + diff --git a/app/src/main/java/com/github/se/travelpouch/MainActivity.kt b/app/src/main/java/com/github/se/travelpouch/MainActivity.kt index 06b325e20..ffbad4a25 100644 --- a/app/src/main/java/com/github/se/travelpouch/MainActivity.kt +++ b/app/src/main/java/com/github/se/travelpouch/MainActivity.kt @@ -47,6 +47,7 @@ import com.github.se.travelpouch.ui.profile.ProfileScreen import com.github.se.travelpouch.ui.theme.SampleAppTheme import com.github.se.travelpouch.ui.travel.EditTravelSettingsScreen import com.github.se.travelpouch.ui.travel.ParticipantListScreen +import com.google.firebase.FirebaseApp import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -68,6 +69,7 @@ class MainActivity : ComponentActivity() { } } } + FirebaseApp.initializeApp(this) } @Composable diff --git a/app/src/main/java/com/github/se/travelpouch/model/notifications/NotificationViewModel.kt b/app/src/main/java/com/github/se/travelpouch/model/notifications/NotificationViewModel.kt index 063535580..779f45e9d 100644 --- a/app/src/main/java/com/github/se/travelpouch/model/notifications/NotificationViewModel.kt +++ b/app/src/main/java/com/github/se/travelpouch/model/notifications/NotificationViewModel.kt @@ -1,7 +1,11 @@ // Portions of this code were generated and or inspired by the help of GitHub Copilot or Chatgpt package com.github.se.travelpouch.model.notifications +import android.util.Log import androidx.lifecycle.ViewModel +import com.google.firebase.Firebase +import com.google.firebase.functions.FirebaseFunctions +import com.google.firebase.functions.functions import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -22,6 +26,8 @@ constructor(private val notificationRepository: NotificationRepository) : ViewMo private val _notifications = MutableStateFlow>(emptyList()) val notifications: StateFlow> = _notifications.asStateFlow() + private val functions = FirebaseFunctions.getInstance("europe-west9") + fun getNewUid(): String { return notificationRepository.getNewUid() } @@ -72,4 +78,21 @@ constructor(private val notificationRepository: NotificationRepository) : ViewMo ) { notificationRepository.deleteAllNotificationsForUser(userUid, onSuccess, onFailure) } + + fun sendNotificationToUser(userId: String, notificationContent: NotificationContent) { + val data = hashMapOf( + "userId" to userId, + "message" to notificationContent.toDisplayString(), + ) + + functions + .getHttpsCallable("sendNotification") + .call(data) + .addOnSuccessListener { result -> + Log.d("Notification", "Success: ${result.data}") + } + .addOnFailureListener { e -> + Log.e("Notification", "Error: ${e.message}") + } + } } diff --git a/app/src/main/java/com/github/se/travelpouch/model/notifications/push/PushNotificationService.kt b/app/src/main/java/com/github/se/travelpouch/model/notifications/push/PushNotificationService.kt new file mode 100644 index 000000000..eb0ff8db3 --- /dev/null +++ b/app/src/main/java/com/github/se/travelpouch/model/notifications/push/PushNotificationService.kt @@ -0,0 +1,17 @@ +package com.github.se.travelpouch.model.notifications.push + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage + +class PushNotificationService : FirebaseMessagingService() { + + @Override + override fun onNewToken(token: String) { + super.onNewToken(token) + } + + @Override + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileModelView.kt b/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileModelView.kt index d4a99f784..7b05302e1 100644 --- a/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileModelView.kt +++ b/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileModelView.kt @@ -4,6 +4,7 @@ package com.github.se.travelpouch.model.profile import android.content.Context import android.util.Log import android.widget.Toast +import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -27,6 +28,9 @@ class ProfileModelView @Inject constructor(private val repository: ProfileReposi private val profile_ = MutableStateFlow(ErrorProfile.errorProfile) val profile: StateFlow = profile_.asStateFlow() + private var _isTokenUpdated = mutableStateOf(false) + val isTokenUpdated: Boolean get() = _isTokenUpdated.value + /** The initialisation function of the profile model view. It fetches the profile of the user */ suspend fun initAfterLogin(onSuccess: () -> Unit) { repository.initAfterLogin { @@ -87,6 +91,21 @@ class ProfileModelView @Inject constructor(private val repository: ProfileReposi }) } + private fun addNotificationTokenToProfile(token: String) { + repository.addNotificationTokenToProfile(token, profile_.value.fsUid, { + Log.d("Notification token added", "Notification token added") + }, { + Log.e(onFailureTag, "Failed to add notification token", it) + }) + } + + fun updateNotificationTokenIfNeeded(token: String) { + if (!_isTokenUpdated.value) { + addNotificationTokenToProfile(token) + _isTokenUpdated.value = true // Mark the token as updated for this session + } + } + /** * This function sends to notification to add a friend * diff --git a/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepository.kt b/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepository.kt index 6a3a06d49..ad56c2e97 100644 --- a/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepository.kt +++ b/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepository.kt @@ -57,6 +57,21 @@ interface ProfileRepository { onFailure: (Exception) -> Unit ) + /** + * Adds a notification token to the user's profile in the Firestore database. + * + * @param token The notification token to be added to the profile. + * @param user The user identifier to whom the token belongs. + * @param onSuccess A callback function that is invoked when the token is successfully added. + * @param onFailure A callback function that is invoked with an Exception if an error occurs during the operation. + */ + fun addNotificationTokenToProfile( + token: String, + user: String, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) + /** * This function sends to notification to add a friend * diff --git a/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepositoryFirebase.kt b/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepositoryFirebase.kt index 2d1fba3f8..f3384ca95 100644 --- a/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepositoryFirebase.kt +++ b/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepositoryFirebase.kt @@ -9,6 +9,7 @@ import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.auth import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.tasks.await @@ -328,6 +329,29 @@ class ProfileRepositoryFirebase(private val db: FirebaseFirestore) : ProfileRepo onFailure(e) } } + + /** + * Adds a notification token to the user's profile in the Firestore database. + * + * @param token The notification token to be added to the profile. + * @param user The user identifier to whom the token belongs. + * @param onSuccess A callback function that is invoked when the token is successfully added. + * @param onFailure A callback function that is invoked with an Exception if an error occurs during the operation. + */ + override fun addNotificationTokenToProfile( + token: String, + user: String, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + performFirestoreOperation( + db.collection(collectionPath) + .document(user) + .update("notificationTokens", FieldValue.arrayUnion(token)), + onSuccess, + onFailure + ) + } } /** This class is used to convert a document to a profile, across the project */ diff --git a/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepositoryMock.kt b/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepositoryMock.kt index de3801b5d..64473fb21 100644 --- a/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepositoryMock.kt +++ b/app/src/main/java/com/github/se/travelpouch/model/profile/ProfileRepositoryMock.kt @@ -57,6 +57,15 @@ class ProfileRepositoryMock : ProfileRepository { TODO("Not yet implemented") } + override fun addNotificationTokenToProfile( + token: String, + user: String, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + TODO("Not yet implemented") + } + override fun sendFriendNotification( email: String, onSuccess: (String) -> Unit, diff --git a/app/src/main/java/com/github/se/travelpouch/ui/home/TravelList.kt b/app/src/main/java/com/github/se/travelpouch/ui/home/TravelList.kt index 382df26a2..ade412da5 100644 --- a/app/src/main/java/com/github/se/travelpouch/ui/home/TravelList.kt +++ b/app/src/main/java/com/github/se/travelpouch/ui/home/TravelList.kt @@ -2,7 +2,10 @@ package com.github.se.travelpouch.ui.home import android.annotation.SuppressLint +import android.app.Activity +import android.content.pm.PackageManager import android.icu.text.SimpleDateFormat +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -58,6 +61,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -65,6 +69,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight @@ -72,6 +77,9 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.github.se.travelpouch.BuildConfig import com.github.se.travelpouch.model.activity.ActivityViewModel import com.github.se.travelpouch.model.documents.DocumentViewModel import com.github.se.travelpouch.model.events.EventViewModel @@ -81,12 +89,13 @@ import com.github.se.travelpouch.model.travels.TravelContainer import com.github.se.travelpouch.ui.navigation.NavigationActions import com.github.se.travelpouch.ui.navigation.Screen import com.github.se.travelpouch.ui.navigation.TopLevelDestinations +import com.google.firebase.Firebase +import com.google.firebase.messaging.messaging import com.github.se.travelpouch.ui.theme.logoutIconDark import com.github.se.travelpouch.ui.theme.logoutIconLight import com.github.se.travelpouch.ui.theme.logoutRedDark import com.github.se.travelpouch.ui.theme.logoutRedLight -import com.google.firebase.auth.ktx.auth -import com.google.firebase.ktx.Firebase +import com.google.firebase.auth.auth import java.util.Locale import kotlinx.coroutines.launch @@ -112,6 +121,9 @@ fun TravelListScreen( documentViewModel: DocumentViewModel, profileModelView: ProfileModelView ) { + // Ask for notification permission + RequestNotificationPermission(profileModelView) + // Fetch travels when the screen is launched LaunchedEffect(Unit) { listTravelViewModel.getTravels() @@ -523,3 +535,38 @@ private fun resizeFromDragMotion( } } } + +@Composable +fun RequestNotificationPermission(profileViewModel: ProfileModelView) { + val context = LocalContext.current + + // Check notification permission + val hasPermission = remember { BuildConfig.DEBUG } || run { + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasPermission) { + ActivityCompat.requestPermissions( + context as Activity, + arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + 0 + ) + } + + // Only call Firestore logic if not already updated + if (hasPermission && !profileViewModel.isTokenUpdated) { + LaunchedEffect(Unit) { + Firebase.messaging.token.addOnCompleteListener { + if (it.isSuccessful) { + val token = it.result + profileViewModel.updateNotificationTokenIfNeeded(token) + } + } + } + } +} + diff --git a/app/src/main/java/com/github/se/travelpouch/ui/notifications/NotificationItem.kt b/app/src/main/java/com/github/se/travelpouch/ui/notifications/NotificationItem.kt index 8e89f664d..135375ad9 100644 --- a/app/src/main/java/com/github/se/travelpouch/ui/notifications/NotificationItem.kt +++ b/app/src/main/java/com/github/se/travelpouch/ui/notifications/NotificationItem.kt @@ -225,17 +225,27 @@ fun handleInvitationResponse( if (isAccepted) NotificationType.ACCEPTED else NotificationType.DECLINED val responseMessage = if (isAccepted) "ACCEPTED" else "DECLINED" + + val responseNotification = + NotificationContent.InvitationResponseNotification( + profileViewModel.profile.value.username, + travel!!.title, isAccepted) val invitationResponse = Notification( notification.notificationUid, profileViewModel.profile.value.fsUid, notification.senderUid, notification.travelUid, - NotificationContent.InvitationResponseNotification( - profileViewModel.profile.value.username, travel!!.title, isAccepted), + responseNotification, responseType, sector = notification.sector) + notificationViewModel.sendNotificationToUser( + notification.senderUid, + responseNotification + ) + + notificationViewModel.sendNotification(invitationResponse) if (isAccepted) { listTravelViewModel.addUserToTravel( @@ -264,36 +274,49 @@ fun handleInvitationResponse( profileViewModel.addFriend( notification.senderUid, onSuccess = { + val firendNotification = + NotificationContent.FriendInvitationResponseNotification( + profileViewModel.profile.value.email, true) val invitationResponse = Notification( notification.notificationUid, profileViewModel.profile.value.fsUid, notification.senderUid, notification.travelUid, - NotificationContent.FriendInvitationResponseNotification( - profileViewModel.profile.value.email, true), + firendNotification, NotificationType.ACCEPTED, sector = notification.sector) notificationViewModel.sendNotification(invitationResponse) + notificationViewModel.sendNotificationToUser( + notification.senderUid, + firendNotification + ) Toast.makeText(context, "Friend added", Toast.LENGTH_LONG).show() }, onFailure = { e -> Toast.makeText(context, e.message!!, Toast.LENGTH_LONG).show() }) } else { + val firendNotification = + NotificationContent.FriendInvitationResponseNotification( + profileViewModel.profile.value.email, false) val invitationResponse = Notification( notification.notificationUid, profileViewModel.profile.value.fsUid, notification.senderUid, notification.travelUid, - NotificationContent.FriendInvitationResponseNotification( - profileViewModel.profile.value.email, false), + firendNotification, NotificationType.DECLINED, sector = notification.sector) notificationViewModel.sendNotification(invitationResponse) + notificationViewModel.sendNotificationToUser( + notification.senderUid, + firendNotification + ) + Toast.makeText(context, "Request declined", Toast.LENGTH_LONG).show() } } diff --git a/app/src/main/java/com/github/se/travelpouch/ui/profile/ModifyingProfileScreen.kt b/app/src/main/java/com/github/se/travelpouch/ui/profile/ModifyingProfileScreen.kt index d4ec6793c..d46da87e5 100644 --- a/app/src/main/java/com/github/se/travelpouch/ui/profile/ModifyingProfileScreen.kt +++ b/app/src/main/java/com/github/se/travelpouch/ui/profile/ModifyingProfileScreen.kt @@ -254,19 +254,22 @@ fun ModifyingProfileScreen( onSuccess = { friendUid -> Toast.makeText(context, "Invitation sent", Toast.LENGTH_LONG) .show() - + val notificationContent = + NotificationContent.FriendInvitationNotification( + profile.value.email) notificationViewModel.sendNotification( Notification( notificationViewModel.getNewUid(), senderUid = profile.value.fsUid, receiverUid = friendUid, travelUid = null, - content = - NotificationContent.FriendInvitationNotification( - profile.value.email), + notificationContent, notificationType = NotificationType.INVITATION, status = NotificationStatus.UNREAD, sector = NotificationSector.PROFILE)) + notificationViewModel.sendNotificationToUser( + friendUid, + notificationContent) openDialog = false }, diff --git a/app/src/main/java/com/github/se/travelpouch/ui/travel/ParticipantListScreen.kt b/app/src/main/java/com/github/se/travelpouch/ui/travel/ParticipantListScreen.kt index 4aacf1b18..e7717d0b6 100644 --- a/app/src/main/java/com/github/se/travelpouch/ui/travel/ParticipantListScreen.kt +++ b/app/src/main/java/com/github/se/travelpouch/ui/travel/ParticipantListScreen.kt @@ -381,16 +381,22 @@ private fun inviteUserToTravelViaFsuid( Toast.makeText(context, "Error: User already added", Toast.LENGTH_SHORT).show() } else if (fsUid != null) { try { + val invitationNotification = + NotificationContent.InvitationNotification( + profileViewModel.profile.value.name, + selectedTravel.title, + Role.PARTICIPANT) notificationViewModel.sendNotification( Notification( notificationViewModel.getNewUid(), profileViewModel.profile.value.fsUid, fsUid, selectedTravel!!.fsUid, - NotificationContent.InvitationNotification( - profileViewModel.profile.value.name, selectedTravel!!.title, Role.PARTICIPANT), + invitationNotification, notificationType = NotificationType.INVITATION, sector = NotificationSector.TRAVEL)) + + notificationViewModel.sendNotificationToUser(fsUid, invitationNotification) Toast.makeText(context, "Invitation sent", Toast.LENGTH_SHORT).show() } catch (e: Exception) { Log.e("NotificationError", "Failed to send notification: ${e.message}") @@ -431,15 +437,23 @@ fun handleRoleChange( if (oldRole == Role.OWNER) { // Actual role change logic if (participant.key != profileViewModel.profile.value.fsUid) { + val roleChangeNotification = + NotificationContent.RoleChangeNotification( + selectedTravel.title, + newRole) notificationViewModel.sendNotification( Notification( notificationViewModel.getNewUid(), profileViewModel.profile.value.fsUid, participant.key, selectedTravel.fsUid, - NotificationContent.RoleChangeNotification(selectedTravel.title, newRole), + roleChangeNotification, NotificationType.ROLE_UPDATE, sector = NotificationSector.TRAVEL)) + notificationViewModel.sendNotificationToUser( + participant.key, + roleChangeNotification + ) } val participantMap = selectedTravel.allParticipants.toMutableMap() participantMap[Participant(participant.key)] = newRole diff --git a/cloud-functions/firestore.rules b/cloud-functions/firestore.rules index 7a763bfd3..9bb4f19fa 100644 --- a/cloud-functions/firestore.rules +++ b/cloud-functions/firestore.rules @@ -15,5 +15,9 @@ service cloud.firestore { match /{document=**} { allow read, write: if request.time < timestamp.date(2024, 11, 6); } + match /userslist/{userId} { + allow read: if request.auth != null && request.auth.uid == userId; + allow write: if request.auth != null && request.auth.uid == userId; + } } } \ No newline at end of file diff --git a/cloud-functions/functions/src/index.ts b/cloud-functions/functions/src/index.ts index ba359827f..442df197e 100644 --- a/cloud-functions/functions/src/index.ts +++ b/cloud-functions/functions/src/index.ts @@ -10,6 +10,8 @@ import { import {storeFile} from "./storage.js"; import {generateThumbnailForDocument} from "./thumbnailing.js"; +import {fetchNotificationTokens, sendPushNotification} from "./pushNotification.js"; + initializeApp(); /** @@ -83,3 +85,55 @@ export const generateThumbnailHttp = onRequest( } res.json({success: true}); }); + + +export const sendNotification = onCall( + {region: "europe-west9"}, + async (req) => { + if (!req.data.userId || !req.data.message) { + throw new HttpsError("invalid-argument", "Missing parameters: userId or message"); + } + try { + const userId = req.data.userId; + const message = req.data.message; + + const tokens = await fetchNotificationTokens(userId); + if (tokens.length === 0) { + logger.warn(`No notification tokens found for user: ${userId}`); + return {success: false, message: "No tokens found"}; + } + + await sendPushNotification(tokens, message); + return {success: true, message: "Notification sent successfully"}; + } catch (err) { + logger.error("Error sending notification", err); + throw new HttpsError("internal", "Error sending notification"); + } + }); + +export const sendNotificationHttp = onRequest( + {region: "europe-west9"}, + async (req, res) => { + if (!req.body.userId || !req.body.message) { + res.status(400).json({success: false, message: "Missing parameters"}); + return; + } + try { + const userId = req.body.userId; + const message = req.body.message; + + const tokens = await fetchNotificationTokens(userId); + if (tokens.length === 0) { + logger.warn(`No notification tokens found for user: ${userId}`); + res.status(400).json({success: false, message: "No tokens found"}); + return; + } + + await sendPushNotification(tokens, message); + } catch (err) { + logger.error("Error sending notification", err); + res.status(500).json({error: "internal", message: "Error sending notification"}); + return; + } + res.json({success: true}); + }); diff --git a/cloud-functions/functions/src/pushNotification.ts b/cloud-functions/functions/src/pushNotification.ts new file mode 100644 index 000000000..c83812b59 --- /dev/null +++ b/cloud-functions/functions/src/pushNotification.ts @@ -0,0 +1,76 @@ +// parts of this file was generated using Github Copilot or ChatGPT + +import {getFirestore} from "firebase-admin/firestore"; +import {getMessaging} from "firebase-admin/messaging"; +import * as logger from "firebase-functions/logger"; + +/** + * Fetches notification tokens for a specific user. + * @param {string} userId The Firebase UID of the user. + * @return {Promise} The list of notification tokens. + */ +export async function fetchNotificationTokens(userId: string): Promise { + const firestore = getFirestore(); + const userDoc = await firestore.collection("userslist").doc(userId).get(); + + if (!userDoc.exists) { + throw new Error(`User with ID ${userId} not found`); + } + + const userData = userDoc.data(); + return userData?.notificationTokens || []; +} + +/** + * Send push notification using Firebase Cloud Messaging (FCM) to multiple tokens. + * + * @param {string[]} tokens - Array of FCM tokens. + * @param {string} message - Notification message payload. + * @return {Promise} - Resolves when the notification is sent. + * @throws Will throw an error if sending notification fails. + */ +export async function sendPushNotification(tokens: string[], message: string): Promise { + if (tokens.length === 0) { + logger.warn("No tokens provided for push notification."); + return; + } + + const messaging = getMessaging(); + + // Construct the MulticastMessage payload + const multicastMessage = { + notification: { + title: "TravelPouch", + body: message, + }, + tokens, + }; + + logger.debug("Multicast message: ", multicastMessage); + + try { + // Send the multicast message + const response = await messaging.sendEachForMulticast(multicastMessage); + + logger.debug(`Successfully sent notification: ${response.successCount} successful, ${response.failureCount} failed.`); + + // Handle failed tokens + if (response.failureCount > 0) { + const failedTokens: string[] = []; + response.responses.forEach((res, index) => { + if (!res.success) { + logger.warn(`Failed to send notification to token: ${tokens[index]} because ${res.error?.message ?? "unknown reason"}`); + failedTokens.push(tokens[index]); + } + }); + + logger.warn("Failed tokens:", failedTokens); + + // Optionally: Remove invalid tokens from Firestore + // Implement token cleanup logic if required + } + } catch (err) { + logger.error("Error sending multicast notification", err); + throw new Error("Error sending push notification"); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bcbb5c83b..8aaa622a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.3.2" concurrentFutures = "1.2.0" +converterMoshi = "latest.version" coreTesting = "2.2.0" guava = "33.0.0-android" hiltAndroidCompiler = "2.51.1" @@ -26,6 +27,12 @@ gms = "4.4.2" mockkVersion = "1.13.7" navigationCompose = "2.8.2" +converterGson = "2.9.0" +firebaseMessaging = "24.1.0" +loggingInterceptor = "4.9.0" +retrofit = "2.9.0" +firebaseMessagingKtx = "24.1.0" + # Google Service and Maps playServicesAuth = "21.2.0" playServicesMaps = "19.0.0" @@ -80,6 +87,7 @@ androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = androidx-hilt-lifecycle-viewmodel = { module = "androidx.hilt:hilt-lifecycle-viewmodel", version.ref = "hiltLifecycleViewmodel" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationComposeVersion" } androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "uiTestJunit4" } +converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshi" } guava = { module = "com.google.guava:guava", version.ref = "guava" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroidCompiler" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompiler" } @@ -122,11 +130,18 @@ firebase-ui-auth = { module = "com.firebaseui:firebase-ui-auth", version.ref = " firebase-common-ktx = { group = "com.google.firebase", name = "firebase-common-ktx", version.ref = "firebaseCommonKtx" } firebase-storage-ktx = { group = "com.google.firebase", name = "firebase-storage-ktx", version.ref = "firebaseCommonKtx" } +converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } +converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "converterGson" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessaging" } + #GOOGLE SERVICES & MAPS maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" } maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "mapsComposeUtils" } play-services-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "playServicesAuth" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" } +logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +firebase-messaging-ktx = { group = "com.google.firebase", name = "firebase-messaging-ktx", version.ref = "firebaseMessagingKtx" } # Mockito mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoAndroid" }