Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.analytics)
implementation(libs.firebase.messaging)

implementation("androidx.browser:browser:1.8.0")
implementation("androidx.credentials:credentials:1.5.0-rc01")
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 @@ -66,6 +66,13 @@
android:pathPrefix="/notifications" />
</intent-filter>
</activity>
<service
android:name=".data.messaging.HackersPubMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
Expand Down
35 changes: 35 additions & 0 deletions app/src/main/graphql/pub/hackers/android/operations.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -894,3 +894,38 @@ mutation PublishArticleDraft($id: ID!, $slug: String!, $language: Locale!, $allo
}
}
}

mutation RegisterFcmDeviceToken($input: RegisterFcmDeviceTokenInput!) {
registerFcmDeviceToken(input: $input) {
... on RegisterFcmDeviceTokenPayload {
deviceToken
created
updated
}
... on RegisterFcmDeviceTokenFailedError {
message
limit
}
... on InvalidInputError {
inputPath
}
... on NotAuthenticatedError {
notAuthenticated
}
}
}

mutation UnregisterFcmDeviceToken($input: UnregisterFcmDeviceTokenInput!) {
unregisterFcmDeviceToken(input: $input) {
... on UnregisterFcmDeviceTokenPayload {
deviceToken
unregistered
}
... on InvalidInputError {
inputPath
}
... on NotAuthenticatedError {
notAuthenticated
}
}
}
50 changes: 50 additions & 0 deletions app/src/main/graphql/pub/hackers/android/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,8 @@ type Mutation {

registerApnsDeviceToken(input: RegisterApnsDeviceTokenInput!): RegisterApnsDeviceTokenResult!

registerFcmDeviceToken(input: RegisterFcmDeviceTokenInput!): RegisterFcmDeviceTokenResult!

removeFollower(input: RemoveFollowerInput!): RemoveFollowerResult!

removeReactionFromPost(input: RemoveReactionFromPostInput!): RemoveReactionFromPostResult!
Expand All @@ -943,6 +945,8 @@ type Mutation {

unregisterApnsDeviceToken(input: UnregisterApnsDeviceTokenInput!): UnregisterApnsDeviceTokenResult!

unregisterFcmDeviceToken(input: UnregisterFcmDeviceTokenInput!): UnregisterFcmDeviceTokenResult!

unsharePost(input: UnsharePostInput!): UnsharePostResult!

updateAccount(input: UpdateAccountInput!): UpdateAccountPayload!
Expand Down Expand Up @@ -1678,6 +1682,33 @@ type RegisterApnsDeviceTokenPayload {

union RegisterApnsDeviceTokenResult = InvalidInputError|NotAuthenticatedError|RegisterApnsDeviceTokenFailedError|RegisterApnsDeviceTokenPayload

type RegisterFcmDeviceTokenFailedError {
limit: Int!

message: String!
}

input RegisterFcmDeviceTokenInput {
clientMutationId: ID

"""
The FCM device token.
"""
deviceToken: String!
}

type RegisterFcmDeviceTokenPayload {
clientMutationId: ID

created: DateTime!

deviceToken: String!

updated: DateTime!
}

union RegisterFcmDeviceTokenResult = InvalidInputError|NotAuthenticatedError|RegisterFcmDeviceTokenFailedError|RegisterFcmDeviceTokenPayload

input RemoveFollowerInput {
actorId: ID!

Expand Down Expand Up @@ -1931,6 +1962,25 @@ type UnregisterApnsDeviceTokenPayload {

union UnregisterApnsDeviceTokenResult = InvalidInputError|NotAuthenticatedError|UnregisterApnsDeviceTokenPayload

input UnregisterFcmDeviceTokenInput {
clientMutationId: ID

"""
The FCM device token.
"""
deviceToken: String!
}

type UnregisterFcmDeviceTokenPayload {
clientMutationId: ID

deviceToken: String!

unregistered: Boolean!
}

union UnregisterFcmDeviceTokenResult = InvalidInputError|NotAuthenticatedError|UnregisterFcmDeviceTokenPayload

input UnsharePostInput {
clientMutationId: ID

Expand Down
14 changes: 7 additions & 7 deletions app/src/main/java/pub/hackers/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import pub.hackers.android.data.local.SessionManager
import pub.hackers.android.navigation.HackersPubRoute
Expand Down Expand Up @@ -87,12 +86,13 @@ class MainActivity : ComponentActivity() {
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
lifecycleScope.launch {
val isLoggedIn = sessionManager.isLoggedIn.first()
if (isLoggedIn && ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
sessionManager.isLoggedIn.collect { isLoggedIn ->
if (isLoggedIn && ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package pub.hackers.android.data.messaging

import android.util.Log
import com.apollographql.apollo.ApolloClient
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.tasks.await
import pub.hackers.android.data.local.SessionManager
import pub.hackers.android.graphql.RegisterFcmDeviceTokenMutation
import pub.hackers.android.graphql.UnregisterFcmDeviceTokenMutation
import pub.hackers.android.graphql.type.RegisterFcmDeviceTokenInput
import pub.hackers.android.graphql.type.UnregisterFcmDeviceTokenInput
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class FcmTokenManager @Inject constructor(
private val apolloClient: ApolloClient,
private val sessionManager: SessionManager,
) {
companion object {
private const val TAG = "FcmTokenManager"
}

suspend fun registerCurrentToken() {
val isLoggedIn = sessionManager.isLoggedIn.first()
if (!isLoggedIn) return

val token = try {
FirebaseMessaging.getInstance().token.await()
} catch (e: Exception) {
Log.w(TAG, "Failed to get FCM token", e)
return
}
registerToken(token)
}

suspend fun registerToken(token: String) {
val isLoggedIn = sessionManager.isLoggedIn.first()
if (!isLoggedIn) return

try {
val response = apolloClient.mutation(
RegisterFcmDeviceTokenMutation(
RegisterFcmDeviceTokenInput(deviceToken = token)
)
).execute()

if (response.hasErrors()) {
Log.w(TAG, "FCM token registration returned errors: ${response.errors}")
return
}

val result = response.data?.registerFcmDeviceToken
when {
result?.onRegisterFcmDeviceTokenPayload != null ->
Log.d(TAG, "FCM token registered")
result?.onRegisterFcmDeviceTokenFailedError != null ->
Log.w(TAG, "FCM token registration failed: ${result.onRegisterFcmDeviceTokenFailedError.message}")
result?.onInvalidInputError != null ->
Log.w(TAG, "FCM token registration invalid input: ${result.onInvalidInputError.inputPath}")
result?.onNotAuthenticatedError != null ->
Log.w(TAG, "FCM token registration not authenticated")
else ->
Log.w(TAG, "FCM token registration unexpected result")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to register FCM token", e)
}
}

suspend fun unregisterCurrentToken() {
val token = try {
FirebaseMessaging.getInstance().token.await()
} catch (e: Exception) {
return
}

try {
val response = apolloClient.mutation(
UnregisterFcmDeviceTokenMutation(
UnregisterFcmDeviceTokenInput(deviceToken = token)
)
).execute()

if (response.hasErrors()) {
Log.w(TAG, "FCM token unregistration returned errors: ${response.errors}")
return
}

val result = response.data?.unregisterFcmDeviceToken
when {
result?.onUnregisterFcmDeviceTokenPayload != null ->
Log.d(TAG, "FCM token unregistered")
result?.onInvalidInputError != null ->
Log.w(TAG, "FCM token unregistration invalid input: ${result.onInvalidInputError.inputPath}")
result?.onNotAuthenticatedError != null ->
Log.w(TAG, "FCM token unregistration not authenticated")
else ->
Log.w(TAG, "FCM token unregistration unexpected result")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to unregister FCM token", e)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package pub.hackers.android.data.messaging

import android.Manifest
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import pub.hackers.android.HackersPubApplication
import pub.hackers.android.MainActivity
import pub.hackers.android.R
import pub.hackers.android.data.local.NotificationStateManager
import javax.inject.Inject

@AndroidEntryPoint
class HackersPubMessagingService : FirebaseMessagingService() {

@Inject
lateinit var fcmTokenManager: FcmTokenManager

@Inject
lateinit var notificationStateManager: NotificationStateManager

private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

override fun onNewToken(token: String) {
serviceScope.launch {
fcmTokenManager.registerToken(token)
}
}

override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
if (data.isEmpty()) return

val alert = data["alert"] ?: return
val notificationId = data["notificationId"] ?: return

kotlinx.coroutines.runBlocking {
notificationStateManager.updateLastPolledId(notificationId)
}

if (hasNotificationPermission()) {
showNotification(alert)
}
}

private fun hasNotificationPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
}

private fun showNotification(text: String) {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("navigate_to", "notifications")
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

val notification = NotificationCompat.Builder(this, HackersPubApplication.NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Hackers' Pub")
.setContentText(text)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()

val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(System.currentTimeMillis().toInt(), notification)
}
}
Loading