Skip to content

Commit

Permalink
Feature/push notification model (#239)
Browse files Browse the repository at this point in the history
* 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 <sylvain.nerisson@epfl.ch>
Co-authored-by: RemIsMyWaifuu <160653991+RemIsMyWaifuu@users.noreply.github.com>
  • Loading branch information
3 people authored Dec 19, 2024
1 parent d8627ec commit 70f9906
Show file tree
Hide file tree
Showing 19 changed files with 383 additions and 17 deletions.
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -376,3 +382,4 @@ tasks.named("ktfmtCheckTest") {
dependsOn("ktfmtFormatTest")
mustRunAfter("ktfmtFormatTest")
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -93,6 +98,7 @@ class NotificationItemTest {
activityViewModel = ActivityViewModel(activityRepository)
documentViewModel = DocumentViewModel(documentRepository, documentsManager, mock())
eventViewModel = EventViewModel(eventRepository)

}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>(0)
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".model.notifications.push.PushNotificationService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/github/se/travelpouch/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -68,6 +69,7 @@ class MainActivity : ComponentActivity() {
}
}
}
FirebaseApp.initializeApp(this)
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,6 +26,8 @@ constructor(private val notificationRepository: NotificationRepository) : ViewMo
private val _notifications = MutableStateFlow<List<Notification>>(emptyList())
val notifications: StateFlow<List<Notification>> = _notifications.asStateFlow()

private val functions = FirebaseFunctions.getInstance("europe-west9")

fun getNewUid(): String {
return notificationRepository.getNewUid()
}
Expand Down Expand Up @@ -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}")
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +28,9 @@ class ProfileModelView @Inject constructor(private val repository: ProfileReposi
private val profile_ = MutableStateFlow<Profile>(ErrorProfile.errorProfile)
val profile: StateFlow<Profile> = 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 {
Expand Down Expand Up @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 49 additions & 2 deletions app/src/main/java/com/github/se/travelpouch/ui/home/TravelList.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,20 +61,25 @@ 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
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
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
Expand All @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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)
}
}
}
}
}

Loading

0 comments on commit 70f9906

Please sign in to comment.