Skip to content
Merged
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ Minimum expectations for any change:
- Run `./gradlew :app:lintDebug` before pushing.
- Do not introduce new `!!` in production code.
- For paged post feeds, keep using the existing Paging overlay and deduplication patterns.
- When resolving merge conflicts, fast-forward local `main` from `origin/main` first (otherwise the merge silently uses a stale base), then after the merge inspect files touched by both branches against `main` directly — git's 3-way auto-merge can drop one side's hunks without surfacing a conflict marker.
16 changes: 16 additions & 0 deletions CONVENTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,22 @@ assertEquals(expected, outer.sharedPost!!.field) // setUp guarantees sharedPost

New or substantially refactored ViewModels ship with tests in the same PR. Reference tests live in `app/src/test/java/pub/hackers/android/ui/screens/**/*ViewModelTest.kt`.

### §9.4 Build real `ApolloResponse` instead of mocking it

`ApolloResponse` exposes `data`, `errors`, and `requestUuid` as `@JvmField`s, so mockk cannot intercept the field reads — `every { response.data } returns ...` fails with "missing mocked calls" because there is no getter to record. Construct real instances via the public Builder:

```kotlin
import com.apollographql.apollo.api.ApolloResponse
import com.benasher44.uuid.uuid4

val response = ApolloResponse.Builder(operation = mutation, requestUuid = uuid4())
.data(data)
.errors(errors)
.build()
```

Mock the `ApolloCall` chain (`apolloClient.mutation(any())`) and have `coEvery { call.execute() } returns response`. Reference: `app/src/test/java/pub/hackers/android/data/messaging/FcmTokenManagerTest.kt`.

---

## §10 Build and variants
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
defaultConfig {
applicationId = "pub.hackers.android"
minSdk = 26
targetSdk = 36

Check warning on line 31 in app/build.gradle.kts

View workflow job for this annotation

GitHub Actions / lint

Not targeting the latest versions of Android; compatibility modes apply. Consider testing and updating this version. Consult the `android.os.Build.VERSION_CODES` javadoc for details. [OldTargetApi]
versionCode = 10
versionName = "1.5.0"

Expand Down Expand Up @@ -128,6 +128,7 @@
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.analytics)
implementation(libs.firebase.messaging)

implementation(libs.androidx.browser)
implementation(libs.androidx.credentials)
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@
android:pathPrefix="/tags/" />
</intent-filter>
</activity>
<service
android:name=".data.messaging.HackersPubMessagingService"
android:exported="false"
tools:ignore="Instantiatable">
<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 @@ -975,3 +975,38 @@ mutation UpdateAccount($input: UpdateAccountInput!) {
mutation MarkNotificationsAsRead {
markNotificationsAsRead
}

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 @@ -940,6 +940,8 @@ type Mutation {

registerApnsDeviceToken(input: RegisterApnsDeviceTokenInput!): RegisterApnsDeviceTokenResult!

registerFcmDeviceToken(input: RegisterFcmDeviceTokenInput!): RegisterFcmDeviceTokenResult!

removeFollower(input: RemoveFollowerInput!): RemoveFollowerResult!

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

unregisterApnsDeviceToken(input: UnregisterApnsDeviceTokenInput!): UnregisterApnsDeviceTokenResult!

unregisterFcmDeviceToken(input: UnregisterFcmDeviceTokenInput!): UnregisterFcmDeviceTokenResult!

unsharePost(input: UnsharePostInput!): UnsharePostResult!

updateAccount(input: UpdateAccountInput!): UpdateAccountPayload!
Expand Down Expand Up @@ -1718,6 +1722,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 @@ -1987,6 +2018,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
23 changes: 16 additions & 7 deletions app/src/main/java/pub/hackers/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import pub.hackers.android.data.local.PreferencesManager
import pub.hackers.android.data.local.SessionManager
Expand Down Expand Up @@ -143,12 +146,18 @@ 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)
repeatOnLifecycle(Lifecycle.State.STARTED) {
sessionManager.isLoggedIn
.distinctUntilChanged()
.filter { it }
.collect {
if (ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
Comment thread
dahlia marked this conversation as resolved.
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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")
if (!sessionManager.isLoggedIn.first()) {
Log.w(TAG, "Session ended during registration; rolling back")
unregisterToken(token)
}
}
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")
}
Comment thread
dahlia marked this conversation as resolved.
} catch (e: Exception) {
Log.w(TAG, "Failed to register FCM token", e)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

suspend fun unregisterCurrentToken() {
val token = try {
FirebaseMessaging.getInstance().token.await()
} catch (e: Exception) {
Comment thread
dahlia marked this conversation as resolved.
Log.w(TAG, "Failed to get FCM token", e)
return
}
unregisterToken(token)
}

suspend fun unregisterToken(token: String) {
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)
}
}
}
Loading
Loading