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
53 changes: 42 additions & 11 deletions .github/scripts/functions-notification.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import vm from "node:vm";

const source = await readFile(new URL("../../functions/index.js", import.meta.url), "utf8");

function fixture({ tokens = ["private-token"], sendError, readError, failed = 0 } = {}) {
function fixture({ watchers = [{ id: "recipient-uid", token: "private-token" }], sendError, readError, failed = 0 } = {}) {
const messages = [];
const logs = [];
const codes = [];
Expand All @@ -20,15 +20,15 @@ function fixture({ tokens = ["private-token"], sendError, readError, failed = 0
assert.equal(nested, "tokens");
return { get: async () => {
if (readError) throw readError;
return { forEach: (visit) => tokens.forEach((fcmToken) => visit({ data: () => ({ fcmToken }) })) };
return { forEach: (visit) => watchers.forEach(({ id, token, userId }) => visit({ id, data: () => ({ fcmToken: token, userId }) })) };
} };
} };
} };
} }),
messaging: () => ({ sendEachForMulticast: async (message) => {
messaging: () => ({ sendEach: async (message) => {
messages.push(JSON.parse(JSON.stringify(message)));
if (sendError) throw sendError;
return { successCount: message.tokens.length - failed, failureCount: failed };
return { successCount: message.length - failed, failureCount: failed };
} }),
};
vm.runInNewContext(source, {
Expand Down Expand Up @@ -63,7 +63,11 @@ for (const [name, before, after, title] of [
const f = fixture();
await f.handle(change(before, after));
assert.deepEqual(f.codes, ["ABC123"]);
assert.deepEqual(f.messages, [{ notification: { title, body: (after || before).title }, tokens: ["private-token"] }]);
assert.deepEqual(f.messages, [[{
token: "private-token",
data: { type: "shared_schedule", schemaVersion: "1", recipientUid: "recipient-uid", shareCode: "ABC123", scheduleId: "schedule-id", title, body: (after || before).title },
android: { priority: "high" },
}]]);
const logged = JSON.stringify(f.logs);
assert.doesNotMatch(logged, /private-token|private schedule|updated title|ABC123/);
});
Expand All @@ -76,23 +80,24 @@ test("missing event, unshared schedule and no recipients do not send", async ()
assert.equal(f.messages.length, 0);
assert.equal(f.codes.length, 0);
}
const f = fixture({ tokens: [undefined, "", " ", 42] });
const f = fixture({ watchers: [undefined, "", " ", 42].map((token) => ({ id: "recipient", token })) });
assert.equal(await f.handle(change(undefined, schedule)), null);
assert.equal(f.messages.length, 0);
});

test("deduplicates registration tokens and splits at the real multicast limit", async () => {
test("deduplicates UID-token pairs and splits at the real sendEach limit", async () => {
const tokens = Array.from({ length: 1001 }, (_, index) => `token-${index}`);
const f = fixture({ tokens: [...tokens, tokens[0], tokens[1000]] });
const watchers = tokens.map((token, index) => ({ id: `uid-${index}`, token }));
const f = fixture({ watchers: [...watchers, watchers[0], watchers[1000]] });
const result = await f.handle(change(undefined, schedule));
assert.deepEqual(f.messages.map((message) => message.tokens.length), [500, 500, 1]);
assert.deepEqual(f.messages.flatMap((message) => message.tokens), tokens);
assert.deepEqual(f.messages.map((message) => message.length), [500, 500, 1]);
assert.deepEqual(f.messages.flat().map((message) => message.token), tokens);
assert.equal(result.successCount, 1001);
assert.equal(result.failureCount, 0);
});

test("records partial delivery counts without exposing token or payload", async () => {
const f = fixture({ tokens: ["one", "two"], failed: 1 });
const f = fixture({ watchers: ["one", "two"].map((token) => ({ id: "uid", token })), failed: 1 });
const result = await f.handle(change(undefined, schedule));
assert.equal(result.successCount, 1);
assert.equal(result.failureCount, 1);
Expand All @@ -106,3 +111,29 @@ test("Firestore and total FCM failures reject instead of claiming success", asyn
assert.equal(f.logs.length, 0);
}
});


test("same token under two UIDs preserves both recipients and ignores forged userId fields", async () => {
const f = fixture({ watchers: [
{ id: "old-uid", token: "same-token", userId: "new-uid" },
{ id: "new-uid", token: "same-token", userId: "old-uid" },
{ id: "new-uid", token: "same-token" },
] });
await f.handle(change(undefined, schedule));
assert.deepEqual(f.messages.flat().map(({ data }) => data.recipientUid), ["old-uid", "new-uid"]);
assert.equal(f.messages.flat().length, 2);
});

test("missing schedule ID, invalid code or invalid watcher UID fails closed", async () => {
for (const event of [
{ ...change(undefined, schedule), params: {} },
change(undefined, { ...schedule, sharedCode: " " }),
change(undefined, { ...schedule, sharedCode: 123 }),
]) {
const f = fixture();
assert.equal(await f.handle(event), null);
assert.equal(f.messages.length, 0);
}
const f = fixture({ watchers: [undefined, "", " ", 7].map((id) => ({ id, token: "valid" })) });
assert.equal(await f.handle(change(undefined, schedule)), null);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package com.example.slowclock.notification

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.ContextWrapper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.example.slowclock.data.remote.repository.AuthRepository
import com.example.slowclock.data.remote.repository.SettingsRepository
import com.google.android.gms.tasks.Tasks
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
import com.google.firebase.auth.FirebaseAuth
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.TimeUnit

/** Opt in on a disposable emulator with -e sharedAuthEmulatorPort 9096. Never connects to production Auth. */
@RunWith(AndroidJUnit4::class)
class SharedScheduleNotificationTest {
private lateinit var firebaseApp: FirebaseApp
private lateinit var auth: AuthRepository
private lateinit var settings: SettingsRepository
private lateinit var notifier: SharedScheduleNotifier
private lateinit var manager: NotificationManager
private lateinit var context: Context
private lateinit var message: SharedScheduleMessage

@Before
fun setUp() {
val port = InstrumentationRegistry.getArguments().getString("sharedAuthEmulatorPort")?.toIntOrNull()
assumeTrue("Requires an explicit local Auth emulator fixture", port != null)
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
context =
object : ContextWrapper(targetContext) {
override fun getSharedPreferences(
name: String,
mode: Int,
) = super.getSharedPreferences("fcm_fixture_$name", mode)
}
manager = context.getSystemService(NotificationManager::class.java)
firebaseApp = FirebaseApp.getApps(context).firstOrNull { it.name == "fcm-notification-fixture" }
?: FirebaseApp.initializeApp(
context,
FirebaseOptions
.Builder()
.setProjectId("demo-slowclock-fcm")
.setApplicationId("1:123456789:android:abcdef")
.setApiKey("fake-api-key-for-local-emulator-only")
.build(),
"fcm-notification-fixture",
)
val firebaseAuth = FirebaseAuth.getInstance(firebaseApp)
firebaseAuth.useEmulator("10.0.2.2", port!!)
Tasks.await(firebaseAuth.signInAnonymously(), 20, TimeUnit.SECONDS)
auth = AuthRepository(firebaseAuth)
settings = SettingsRepository(context)
settings.setShareCode("CODE01")
notifier = SharedScheduleNotifier(context, auth, settings)
message = SharedScheduleMessage(auth.currentUid!!, "CODE01", "id", "Fixture change", "Fixture schedule")
notifier.changeSession {}
}

@After
fun tearDown() {
if (::notifier.isInitialized) {
notifier.changeSession {
settings.clearShareCode()
auth.signOut()
}
}
if (::manager.isInitialized) manager.cancel("fcm-fixture-alarm", 704)
}

@Test
fun currentSessionOnlyAndDistinctScheduleIds() {
assertTrue(manager.areNotificationsEnabled())
assertTrue(notifier.show(message))
assertFalse(notifier.show(message.copy(recipientUid = "other")))
assertFalse(notifier.show(message.copy(shareCode = "OTHER1")))
awaitSharedCount(1)
assertTrue(notifier.show(message.copy(title = "Updated")))
awaitSharedCount(1)
assertTrue(notifier.show(message.copy(scheduleId = "Aa")))
assertTrue(notifier.show(message.copy(scheduleId = "BB")))
awaitSharedCount(3)
}

@Test
fun logoutClearsNewAndLegacySharedNotificationsButKeepsAlarm() {
assertTrue(notifier.show(message))
manager.createNotificationChannel(NotificationChannel("fcm-fixture-alarm", "Fixture alarm", NotificationManager.IMPORTANCE_DEFAULT))
manager.createNotificationChannel(
NotificationChannel("fcm_fallback_notification_channel", "Legacy fixture", NotificationManager.IMPORTANCE_DEFAULT),
)

fun notification(channel: String) =
Notification
.Builder(context, channel)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Fixture")
.build()
manager.notify("fcm-fixture-alarm", 704, notification("fcm-fixture-alarm"))
manager.notify(0, notification("schedule_channel"))
manager.notify("FCM-Notification:fixture", 0, notification("fcm_fallback_notification_channel"))
awaitCondition { manager.activeNotifications.size == 4 }
notifier.changeSession {
settings.clearShareCode()
auth.signOut()
}
awaitCondition { manager.activeNotifications.size == 1 }
assertEquals("fcm-fixture-alarm", manager.activeNotifications.single().tag)
assertFalse(notifier.show(message))
awaitSharedCount(0)
}

@Test
fun codeReplacementAndUnregisterRemovePreviousNotifications() {
assertTrue(notifier.show(message))
awaitSharedCount(1)
assertTrue(notifier.replaceShareCode(notifier.snapshot(), "NEW002"))
awaitSharedCount(0)
assertFalse(notifier.show(message))
assertTrue(notifier.show(message.copy(shareCode = "NEW002")))
awaitSharedCount(1)
assertTrue(notifier.replaceShareCode(notifier.snapshot(), null))
awaitSharedCount(0)
assertFalse(notifier.show(message.copy(shareCode = "NEW002")))
}

@Test
fun permissionDeniedDoesNotPost() {
assumeTrue(
"Run separately with POST_NOTIFICATIONS revoked",
InstrumentationRegistry.getArguments().getString("sharedPermissionDenied") == "true",
)
assertFalse(manager.areNotificationsEnabled())
assertFalse(notifier.show(message))
awaitSharedCount(0)
}

private fun awaitSharedCount(count: Int) =
awaitCondition {
manager.activeNotifications.count { it.tag?.startsWith(SharedScheduleNotifier.TAG_PREFIX) == true } == count
}

private fun awaitCondition(condition: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (!condition() && System.nanoTime() < deadline) Thread.sleep(50)
assertTrue(condition())
}
}
4 changes: 4 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Local emulator fixtures only; release retains the platform HTTPS-only default. -->
<application android:networkSecurityConfig="@xml/debug_network_security_config" />
</manifest>
8 changes: 8 additions & 0 deletions app/src/debug/res/xml/debug_network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
5 changes: 4 additions & 1 deletion core/alarm/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ dependencies {
testImplementation(libs.mockk)
implementation(project(":core:data")) // Notifier 인터페이스 구현 (역결합 제거)
implementation(libs.androidx.core.ktx)
implementation(libs.firebase.messaging)
// FCMService의 공개 superclass/메시지 인자와 Hilt service 계층을 앱 lint에서도 해석한다.
// 전이 SDK의 R 클래스는 Preview compile classpath에 노출하지 않고 runtime에는 모두 유지한다.
api(libs.firebase.messaging) { isTransitive = false }
runtimeOnly(libs.firebase.messaging)
implementation(libs.firebase.auth)
}
Original file line number Diff line number Diff line change
@@ -1,80 +1,30 @@
package com.example.slowclock.notification

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.example.slowclock.core.alarm.R
import com.example.slowclock.data.remote.repository.UserRepository
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class FCMService : FirebaseMessagingService() {
@Inject lateinit var sharedScheduleNotifier: SharedScheduleNotifier

@Inject lateinit var userRepository: UserRepository

override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
val title = remoteMessage.notification?.title ?: "일정 알림"
val body = remoteMessage.notification?.body ?: "새로운 알림이 있습니다."
android.util.Log.d("FCMService", "onMessageReceived: title=$title, body=$body, data=${remoteMessage.data}")
sendNotification(title, body)
// notification payload는 background에서 SDK가 직접 표시하므로 서버도 data-only로 바꾼다.
// https://firebase.google.com/docs/cloud-messaging/android/receive-messages
if (remoteMessage.notification != null) return
val message = SharedScheduleMessage.fromData(remoteMessage.data) ?: return
sharedScheduleNotifier.show(message)
}

override fun onNewToken(token: String) {
super.onNewToken(token)
// Firestore에 FCM 토큰 저장
val user =
com.google.firebase.auth.FirebaseAuth
.getInstance()
.currentUser
user?.let {
val db =
com.google.firebase.firestore.FirebaseFirestore
.getInstance()
db
.collection("users")
.document(it.uid)
.update("fcmToken", token)
.addOnSuccessListener { android.util.Log.d("FCM", "토큰 Firestore 저장 성공") }
.addOnFailureListener { e -> android.util.Log.e("FCM", "토큰 Firestore 저장 실패", e) }
sharedScheduleNotifier.withCurrentSession { session ->
userRepository.updateFcmRegistration(token, session.userId.orEmpty(), session.shareCode)
}
}

private fun sendNotification(
title: String,
messageBody: String,
) {
val intent =
Intent().setClassName(this, "com.example.slowclock.MainActivity").apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}

val pendingIntent =
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE,
)

val channelId = "schedule_channel"
val notificationBuilder =
NotificationCompat
.Builder(this, channelId)
.setSmallIcon(R.drawable.baseline_access_alarm_24)
.setContentTitle(title)
.setContentText(messageBody)
.setAutoCancel(true)
.setContentIntent(pendingIntent)

// 여기서 Context.NOTIFICATION_SERVICE 대신 NOTIFICATION_SERVICE만 써보기
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager

val channel =
NotificationChannel(
channelId,
"일정 알림",
NotificationManager.IMPORTANCE_HIGH,
)
notificationManager.createNotificationChannel(channel)
notificationManager.notify(0, notificationBuilder.build())
}
}
Loading
Loading