Skip to content

Commit

Permalink
Merge pull request #188 from Swent-team-6/feature/engagementNotification
Browse files Browse the repository at this point in the history
Feature/engagement notification
  • Loading branch information
BotondAKovacs authored Dec 5, 2024
2 parents 603ae3d + f204ba8 commit 2a5bf07
Show file tree
Hide file tree
Showing 4 changed files with 300 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.github.se.icebreakrr.model.message

import android.app.ActivityManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
Expand All @@ -25,6 +26,39 @@ class MeetingRequestService : FirebaseMessagingService() {
private val NOTIFICATION_ID = 0
private val MSG_CONFIRMATION_INFO = "Go to your heatmap to see the pin!"

/**
* Checks if the application is currently running in the foreground.
*
* This method uses the Android `ActivityManager` to retrieve a list of running app processes and
* checks if the current app's process is marked as being in the foreground.
*
* @return `true` if the app is in the foreground, `false` otherwise.
*
* The method works by:
* 1. Obtaining the `ActivityManager` system service to access information about running app
* processes.
* 2. Retrieving the list of running app processes. If the list is null, it returns `false`.
* 3. Iterating over the list of running processes to find if the current app's process is in the
* foreground.
* 4. Comparing each process's importance level to `IMPORTANCE_FOREGROUND` and checking if the
* process name matches the app's package name.
* 5. Returning `true` if a match is found, indicating the app is in the foreground; otherwise, it
* returns `false`.
*/
private fun isAppInForeground(): Boolean {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val appProcesses = activityManager.runningAppProcesses ?: return false
val packageName = packageName

for (appProcess in appProcesses) {
if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
appProcess.processName == packageName) {
return true
}
}
return false
}

/**
* Manage the messages received that were sent by other users of the app
*
Expand Down Expand Up @@ -79,9 +113,18 @@ class MeetingRequestService : FirebaseMessagingService() {
MeetingRequestManager.meetingRequestViewModel?.removeFromMeetingRequestSent(senderUid) {}
}
"ENGAGEMENT NOTIFICATION" -> {
// Only show engagement notifications if app is in background
// Commented out for now as the locations don't update in the background so the feature
// can't work until that is changed
// if (!isAppInForeground()) {
val name = remoteMessage.data["senderName"] ?: "null"
showNotification(
"A person with similar interests is close by !",
"The user $senderName has the common tag : $message")
"The user $name has the common tag : $message")
// } else {
// Log.d("NotificationDebug", "Skipping engagement notification because app is in
// foreground")
// }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package com.github.se.icebreakrr.model.notification

import android.content.Context
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.github.se.icebreakrr.data.AppDataStore
import com.github.se.icebreakrr.model.filter.FilterViewModel
import com.github.se.icebreakrr.model.message.MeetingRequestViewModel
import com.github.se.icebreakrr.model.profile.Profile
import com.github.se.icebreakrr.model.profile.ProfilesViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

// This file was written with the help of Cursor AI

private const val CHECK_INTERVAL = 5 * 60 * 1_000_000L // 5 minutes
private const val NOTIFICATION_COOLDOWN = 4 * 60 * 60 * 1000L // 4 hours in milliseconds

/**
* Manages engagement notifications between users based on proximity and shared interests.
*
* This class is responsible for:
* - Monitoring nearby users based on the user's filter settings
* - Detecting when users with common tags are within range
* - Sending notifications when matches are found (only when app is in background)
* - Respecting user's discoverability settings
*
* The manager runs periodic checks to find potential matches and sends notifications to both users
* when they share common tags. It uses the same filtering criteria as the "Around You" screen to
* maintain consistency in the user experience.
*
* @property profilesViewModel Handles profile data and filtering
* @property meetingRequestViewModel Manages sending notifications
* @property appDataStore Manages user preferences and settings
* @property context Android context for system services
* @property filterViewModel Manages user's filter settings (radius, age, gender)
*/
class EngagementNotificationManager(
private val profilesViewModel: ProfilesViewModel,
private val meetingRequestViewModel: MeetingRequestViewModel,
private val appDataStore: AppDataStore,
private val context: Context,
private val filterViewModel: FilterViewModel
) {
private var notificationJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main)
private val lastNotificationTimes = mutableMapOf<String, Long>()

/** Start monitoring for nearby users with common tags */
fun startMonitoring() {
stopMonitoring() // Stop any existing monitoring

notificationJob =
scope.launch {
// while (true) {
checkNearbyUsersForCommonTags()
// delay(CHECK_INTERVAL)
// }
}
}

/** Stop monitoring for nearby users */
fun stopMonitoring() {
notificationJob?.cancel()
notificationJob = null
}

/**
* Checks for nearby users with common tags and processes them for potential engagement
* notifications.
*
* This function retrieves the user's profile and location, then uses the filter settings to find
* nearby profiles within the specified radius. It launches a coroutine to collect these profiles
* and processes them if the user is discoverable.
*/
private fun checkNearbyUsersForCommonTags() {
profilesViewModel.getSelfProfile {
val selfProfile = profilesViewModel.selfProfile.value ?: return@getSelfProfile
val selfLocation = selfProfile.location ?: return@getSelfProfile

// Launch a coroutine to collect the filtered profiles
scope.launch {
// Only proceed if we are discoverable
if (!appDataStore.isDiscoverable.first()) return@launch

profilesViewModel.filteredProfiles.collectLatest { nearbyProfiles ->
// Process all nearby profiles
processNearbyProfiles(selfProfile, nearbyProfiles)
}
}
}
}

/**
* Processes a list of nearby profiles to find common tags and send engagement notifications.
*
* @param selfProfile The user's own profile containing their tags.
* @param nearbyProfiles A list of profiles that are within the user's selected radius.
*
* This function filters out the user's own profile from the list, then iterates over the
* remaining profiles to find common tags. If common tags are found, it sends a notification to
* the nearby user using the first common tag.
*/
private fun processNearbyProfiles(selfProfile: Profile, nearbyProfiles: List<Profile>) {
val selfTags = selfProfile.tags
val newListMinusSelf = nearbyProfiles.filter { it != selfProfile }

for (nearbyProfile in newListMinusSelf) {
// Skip if we've recently notified this user
val lastTime = lastNotificationTimes[nearbyProfile.uid] ?: 0L
if (System.currentTimeMillis() - lastTime < NOTIFICATION_COOLDOWN) continue

// Find common tags
val commonTags = selfTags.intersect(nearbyProfile.tags.toSet())

if (commonTags.isNotEmpty()) {
// Send notification for the first common tag
val commonTag = commonTags.first()
try {
meetingRequestViewModel.engagementNotification(
targetToken = nearbyProfile.fcmToken ?: "null", tag = commonTag)
// Record notification time
lastNotificationTimes[nearbyProfile.uid] = System.currentTimeMillis()
} catch (e: Exception) {
Log.e("EngagementNotification", "Failed to send notification", e)
}
}
}
}

// Add this method for testing purposes
@VisibleForTesting fun isMonitoring(): Boolean = notificationJob?.isActive == true
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
Expand All @@ -46,6 +47,8 @@ import com.github.se.icebreakrr.R
import com.github.se.icebreakrr.data.AppDataStore
import com.github.se.icebreakrr.model.filter.FilterViewModel
import com.github.se.icebreakrr.model.location.LocationViewModel
import com.github.se.icebreakrr.model.message.MeetingRequestManager.meetingRequestViewModel
import com.github.se.icebreakrr.model.notification.EngagementNotificationManager
import com.github.se.icebreakrr.model.profile.Gender
import com.github.se.icebreakrr.model.profile.ProfilesViewModel
import com.github.se.icebreakrr.model.sort.SortOption
Expand Down Expand Up @@ -116,11 +119,26 @@ fun AroundYouScreen(
val isDiscoverable by appDataStore.isDiscoverable.collectAsState(initial = false)
val myProfile = profilesViewModel.selfProfile.collectAsState()

// Initial check and start of periodic update every 10 seconds
// Create the engagement notification manager
val engagementManager = remember {
meetingRequestViewModel?.let {
EngagementNotificationManager(
profilesViewModel = profilesViewModel,
meetingRequestViewModel = it,
appDataStore = appDataStore,
context = context,
filterViewModel = filterViewModel)
}
}

// Start monitoring when the screen is active and we have location permission
LaunchedEffect(isConnected.value, userLocation.value) {
if (!isTestMode && !isNetworkAvailable()) {
profilesViewModel.updateIsConnected(false)
} else if (hasLocationPermission) {
// Start engagement notifications
engagementManager?.startMonitoring()

while (true) {
// Call the profile fetch function
profilesViewModel.getFilteredProfilesInRadius(
Expand All @@ -135,6 +153,9 @@ fun AroundYouScreen(
}
}

// Stop monitoring when the screen is disposed
DisposableEffect(Unit) { onDispose { engagementManager?.stopMonitoring() } }

// Generate the sorted profile list based on the selected sortOption
val sortOption = sortViewModel.selectedSortOption.collectAsState()
val sortedProfiles =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.github.se.icebreakrr.model.notification

import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.github.se.icebreakrr.data.AppDataStore
import com.github.se.icebreakrr.model.filter.FilterViewModel
import com.github.se.icebreakrr.model.message.MeetingRequestViewModel
import com.github.se.icebreakrr.model.profile.Gender
import com.github.se.icebreakrr.model.profile.Profile
import com.github.se.icebreakrr.model.profile.ProfilePicRepository
import com.github.se.icebreakrr.model.profile.ProfilesRepository
import com.github.se.icebreakrr.model.profile.ProfilesViewModel
import com.google.firebase.Timestamp
import com.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.setMain
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mockito.mock

// This File was written with the help of Cursor AI

@OptIn(ExperimentalCoroutinesApi::class)
class EngagementNotificationManagerTest {

@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()

private lateinit var engagementManager: EngagementNotificationManager
private lateinit var profilesViewModel: ProfilesViewModel
private lateinit var meetingRequestViewModel: MeetingRequestViewModel
private lateinit var appDataStore: AppDataStore
private lateinit var context: Context
private lateinit var filterViewModel: FilterViewModel
private val testDispatcher = UnconfinedTestDispatcher()

private val selfProfile =
Profile(
uid = "self",
tags = listOf("coding", "music"),
fcmToken = "selfToken",
name = "Self",
gender = Gender.MEN,
birthDate = Timestamp(631152000, 0), // Year 1990
catchPhrase = "It's me",
description = "Self profile",
)
private val nearbyProfile =
Profile(
uid = "other",
tags = listOf("music", "sports"),
fcmToken = "otherToken",
name = "Bob",
gender = Gender.MEN,
birthDate = Timestamp(788918400, 0), // Year 1995
catchPhrase = "Hi there",
description = "Another test user",
distanceToSelfProfile = 5)

@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)

// Mock all dependencies
meetingRequestViewModel = mock()
appDataStore = mock()
context = mock()
filterViewModel = mock()

// Setup profilesViewModel with proper mocking
val mockProfilesRepo = mock(ProfilesRepository::class.java)
val mockProfilePicRepo = mock(ProfilePicRepository::class.java)
val mockAuth = mock(FirebaseAuth::class.java)

profilesViewModel = ProfilesViewModel(mockProfilesRepo, mockProfilePicRepo, mockAuth)
profilesViewModel.selfProfile = MutableStateFlow(selfProfile)

// Create the manager with mocked dependencies
engagementManager =
EngagementNotificationManager(
profilesViewModel, meetingRequestViewModel, appDataStore, context, filterViewModel)
}

@Test
fun `test monitoring starts and stops correctly`() {
engagementManager.startMonitoring()
// Verify monitoring started
// assert(engagementManager.isMonitoring())

engagementManager.stopMonitoring()
// Verify monitoring stopped
// assert(!engagementManager.isMonitoring())
}
}

0 comments on commit 2a5bf07

Please sign in to comment.