diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc3b5fa..b273990 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,6 +116,8 @@ dependencies { implementation(libs.google.id) // Firebase Analytics implementation(libs.firebase.analytics) + // Firebase Messaging + implementation(libs.firebase.messaging) // Coil Image Loading implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1bf2fe8..e7263e2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/data/remote/HustleFirebaseMessagingService.kt b/app/src/main/java/com/cornellappdev/hustle/data/remote/HustleFirebaseMessagingService.kt new file mode 100644 index 0000000..b6d2f75 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/remote/HustleFirebaseMessagingService.kt @@ -0,0 +1,98 @@ +package com.cornellappdev.hustle.data.remote + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import com.cornellappdev.hustle.MainActivity +import com.cornellappdev.hustle.R +import com.cornellappdev.hustle.data.repository.FcmTokenRepository +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class HustleFirebaseMessagingService : FirebaseMessagingService() { + + @Inject + lateinit var fcmTokenRepository: FcmTokenRepository + + @Inject + lateinit var applicationScope: CoroutineScope + + private val notificationManager by lazy { + getSystemService(NOTIFICATION_SERVICE) as NotificationManager + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + applicationScope.launch { + fcmTokenRepository.updateFcmToken(token) + } + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + //TODO: Handle data messages for different notification types (eg. chat) + val title = message.notification?.title ?: message.data["title"] ?: "Hustle" + val body = message.notification?.body ?: message.data["body"] ?: "" + + showNotification(title, body, message.data) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Hustle Notifications", + NotificationManager.IMPORTANCE_HIGH + ).apply { + enableVibration(true) + } + notificationManager.createNotificationChannel(channel) + } + } + + private fun showNotification(title: String, body: String, data: Map) { + val intent = Intent(this, MainActivity::class.java).apply { + // new task for launching activity from service and clear top to avoid multiple activity instances + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + data.forEach { (key, value) -> putExtra(key, value) } + } + + // update current to ensure each notification has correct data and immutable for security best practices + val pendingIntent = PendingIntent.getActivity( + this, + data.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // TODO: Replace with actual app icon + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(R.drawable.ic_google) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .build() + + notificationManager.notify(System.currentTimeMillis().toInt(), notification) + } + + //TODO: Add more channels for different notification types + companion object { + private const val CHANNEL_ID = "hustle_notifications_general" + } +} diff --git a/app/src/main/java/com/cornellappdev/hustle/data/repository/FcmTokenRepository.kt b/app/src/main/java/com/cornellappdev/hustle/data/repository/FcmTokenRepository.kt new file mode 100644 index 0000000..9934600 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/repository/FcmTokenRepository.kt @@ -0,0 +1,26 @@ +package com.cornellappdev.hustle.data.repository + +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +interface FcmTokenRepository { + suspend fun getFcmToken(): Result + suspend fun updateFcmToken(token: String): Result +} + +@Singleton +class FcmTokenRepositoryImpl @Inject constructor( + private val firebaseMessaging: FirebaseMessaging, + // TODO: Add your API service to send token to backend +) : FcmTokenRepository { + + override suspend fun getFcmToken(): Result = runCatching { + firebaseMessaging.token.await() + } + + override suspend fun updateFcmToken(token: String): Result = runCatching { + // TODO: Send token to backend + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/di/AppModule.kt b/app/src/main/java/com/cornellappdev/hustle/di/AppModule.kt index 13124a2..ce082d4 100644 --- a/app/src/main/java/com/cornellappdev/hustle/di/AppModule.kt +++ b/app/src/main/java/com/cornellappdev/hustle/di/AppModule.kt @@ -6,15 +6,21 @@ import com.cornellappdev.hustle.data.repository.AuthRepository import com.cornellappdev.hustle.data.repository.AuthRepositoryImpl import com.cornellappdev.hustle.data.repository.ExampleRepository import com.cornellappdev.hustle.data.repository.ExampleRepositoryImpl +import com.cornellappdev.hustle.data.repository.FcmTokenRepository +import com.cornellappdev.hustle.data.repository.FcmTokenRepositoryImpl import com.google.firebase.Firebase import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.auth +import com.google.firebase.messaging.FirebaseMessaging import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -30,6 +36,12 @@ abstract class AppModule { authRepositoryImpl: AuthRepositoryImpl ): AuthRepository + @Binds + abstract fun bindFcmTokenRepository( + notificationRepositoryImpl: FcmTokenRepositoryImpl + ): FcmTokenRepository + + companion object { @Provides @Singleton @@ -40,5 +52,17 @@ abstract class AppModule { fun provideCredentialManager(@ApplicationContext context: Context): CredentialManager { return CredentialManager.create(context) } + + @Provides + @Singleton + fun provideFirebaseMessaging(): FirebaseMessaging { + return FirebaseMessaging.getInstance() + } + + @Provides + @Singleton + fun provideApplicationScope(): CoroutineScope { + return CoroutineScope(SupervisorJob() + Dispatchers.IO) + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 906ede8..8c84f19 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,6 +53,7 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-auth = { group = "com.google.firebase", name = "firebase-auth" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } credential-manager = { group = "androidx.credentials", name = "credentials", version.ref = "credentialManager" } credential-manager-play-services = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentialManager" } google-id = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleId" }