feat(m-notification-pipeline): archive ack_actions & host_routing_contract tasks, add PendingNotificationActionStore

- Archive m-notification-pipeline task groups (02+01_ack_actions, 03_host_routing_contract)
- Add PendingNotificationActionStore for pending notification actions
- Improve NotificationReplyBroadcastReceiver and ReceiptDelivery
- Update FirebaseMessagingService and related helpers
- Add tests for ReceiptDelivery and PendingNotificationActionStore
- Update Flutter package README
This commit is contained in:
toki 2026-05-31 09:36:41 +09:00
parent 5e4089c629
commit 8ce6b1292a
22 changed files with 2181 additions and 224 deletions

View file

@ -0,0 +1,225 @@
<!-- task=m-notification-pipeline/02+01_ack_actions plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
## 개요
date=2026-05-31
task=m-notification-pipeline/02+01_ack_actions, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/messaging-runtime/milestones/notification-pipeline.md`
- Task ids:
- `ack-delivery`: ACK request, local pending queue, bounded retry, 영구 실패 전달 기준을 정리한다.
- `reply-dismiss`: inline reply와 dismiss action의 storage/network 경계를 정리한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` → `plan_cloud_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-notification-pipeline/02+01_ack_actions/`로 이동한다.
4. PASS이고 task group이 `m-notification-pipeline`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Pending Action Store | [x] |
| [API-2] ACK Delivery Retry Contract | [x] |
| [API-3] Reply And Dismiss Boundary | [x] |
## 구현 체크리스트
- [x] `01_android_payload_display` predecessor의 `complete.log`를 확인한 뒤 구현을 시작한다.
- [x] ACK delivery에 local pending queue, bounded retry, transient/permanent failure classification을 추가한다.
- [x] Inline reply 실패를 pending queue에 보존하고 retry 성공/영구 실패 처리 기준을 구현한다.
- [x] Dismiss action은 local cleanup을 유지하되 server/network no-op 경계와 telemetry/host 전달 기준을 문서화한다.
- [x] 원격 환경에서 package tests, Android native unit tests, 필요한 host smoke를 실행하고 출력 전체를 review stub에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-notification-pipeline/02+01_ack_actions/`를 `agent-task/archive/YYYY/MM/m-notification-pipeline/02+01_ack_actions/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-notification-pipeline/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- 원격 Gradle 실행 전 `apps/flutter-test/android/local.properties`의 `flutter.sdk`를 원격 host의 `$HOME/SDK/flutter`로 보정했다. tracked checkout 값 `/sdk/flutter`가 원격에 없어 최초 Gradle이 Flutter plugin loader 해석 전에 실패했기 때문이다.
- 원격 checkout에서 `flutter pub get`을 package/app 각각 실행했다. tar sync 후 `.dart_tool`과 platform plugin metadata가 없고, 기존 plugin metadata가 local pub cache 경로를 가리키는 문제를 피하기 위한 검증 준비 단계다.
- Android JVM unit test에서 `org.json` Android stub이 `not mocked`로 실패하므로 `android/build.gradle.kts`에 test-only `org.json:json:20240303`을 추가했다. production dependency surface는 바꾸지 않았다.
- `ReceiptDelivery`에는 test-only failure reporter overload와 안전한 log wrapper를 두었다. production default는 그대로 `PushNotificationEvents` telemetry를 사용하며, JVM unit test의 Android `Log`/`Looper` stub 때문에 실패하지 않게 분리했다.
## 주요 설계 결정
- pending action queue는 Room schema migration 없이 기존 `Global` key-value table의 `pendingNotificationActions` JSON 값으로 저장한다.
- action record는 `kind`, `serverUrl`, `endpoint`, `body`, `attempts`, `nextAttemptAt`, `createdAt`, `lastError`를 가진다. ACK는 `ack:<ack_id>`로 stable id를 쓰고, reply는 개별 사용자 입력을 보존하기 위해 UUID id를 쓴다.
- retry classification은 2xx success, 408/429/5xx/exception transient, 401/403 auth permanent, 나머지 4xx server permanent로 고정했다. transient는 최대 3 failed attempts까지 큐에 남기고, permanent는 큐에서 제거한 뒤 `type=action_failure` 이벤트로 전달한다.
- retry 실행은 plugin attach, FCM service create, auth token set, reply receive 시점에 opportunistic async drain으로 수행한다. 별도 WorkManager dependency는 추가하지 않았다.
- inline reply는 기존 notification update UX를 유지한다. transient failure는 `"Message queued to send."`로 표시하고, permanent failure는 기존 실패 표시와 telemetry를 사용한다.
- dismiss는 서버 contract가 없으므로 local notification cleanup만 수행한다. 네트워크 요청이나 실패 telemetry를 새로 만들지 않고 README에 no-server-contract 경계를 명시했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Predecessor `01` complete 여부를 구현 전 확인했는지 본다.
- Transient network failure와 permanent auth/server rejection 분류가 섞이지 않았는지 본다.
- Dismiss action이 없는 서버 contract를 임의로 만들지 않았는지 본다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### API-1 중간 검증
```text
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
첫 실행은 원격 `local.properties`의 `flutter.sdk=/sdk/flutter` 때문에 plugin loader 단계에서 실패했다.
원격 checkout에 대해 `flutter.sdk=$HOME/SDK/flutter`를 쓰고 `flutter pub get`으로 plugin metadata를 재생성한 뒤 재실행했다.
> Task :nexo_messaging:testDebugUnitTest
NexoMessagingPluginTest ... PASSED
PayloadGateTest ... PASSED
ReceiptDeliveryTest > send_ioExceptionQueuesAckForRetry() PASSED
ReceiptDeliveryTest > send_transientHttpQueuesAckForRetry() PASSED
ReceiptDeliveryTest > send_authFailureIsPermanentAndDoesNotQueue() PASSED
ReceiptDeliveryTest > replyTransientQueueHelperStoresReplyAction() PASSED
NotificationDisplayTest ... PASSED
PendingNotificationActionStoreTest > enqueueAckStoresActionAndSelectsWhenDue() PASSED
PendingNotificationActionStoreTest > transientFailureAtMaxAttemptsBecomesPermanentAndRemovesAction() PASSED
PendingNotificationActionStoreTest > dispatcherRemovesActionAfterSuccessfulRetry() PASSED
PendingNotificationActionStoreTest > permanentFailureEventDoesNotIncludeRequestBody() PASSED
45 tests completed, 0 failed
BUILD SUCCESSFUL in 15s
```
### API-2 중간 검증
```text
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/packages/messaging_flutter"; flutter test; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
원격 checkout 준비로 `flutter.sdk=$HOME/SDK/flutter`, package/app `flutter pub get`을 먼저 실행했다.
00:00 +14: All tests passed!
> Task :nexo_messaging:testDebugUnitTest
NexoMessagingPluginTest ... PASSED
PayloadGateTest ... PASSED
ReceiptDeliveryTest ... PASSED
NotificationDisplayTest ... PASSED
PendingNotificationActionStoreTest ... PASSED
45 tests completed, 0 failed
BUILD SUCCESSFUL in 4s
```
### API-3 중간 검증
```text
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
API-3 범위는 API-1/API-2와 같은 Android native suite에서 검증했다. 최종 검증의 native unit 재실행 결과:
> Task :nexo_messaging:testDebugUnitTest
ReceiptDeliveryTest > replyTransientQueueHelperStoresReplyAction() PASSED
PendingNotificationActionStoreTest > permanentFailureEventDoesNotIncludeRequestBody() PASSED
PendingNotificationActionStoreTest > dispatcherRemovesActionAfterSuccessfulRetry() PASSED
NexoMessagingPluginTest, PayloadGateTest, NotificationDisplayTest 전체 PASSED
45 tests completed, 0 failed
BUILD SUCCESSFUL in 16s
```
### 최종 검증
```text
$ REMOTE_DIR='$HOME/tmp/nexo-codex-test'
$ tar --exclude='./.git' --exclude='./.env' --exclude='./.env.*' --exclude='./agent-test/local/secrets.md' --exclude='./.dart_tool' --exclude='./build' --exclude='./apps/flutter-test/.dart_tool' --exclude='./apps/flutter-test/build' --exclude='./apps/flutter-test/android/.gradle' --exclude='./packages/messaging_flutter/android/.gradle' --exclude='./.gradle' -czf - . | ssh toki@toki-labs.com "rm -rf \"$REMOTE_DIR\" && mkdir -p \"$REMOTE_DIR\" && tar -xzf - -C \"$REMOTE_DIR\""
(no stdout, exit code 0)
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/packages/messaging_flutter"; flutter analyze --no-fatal-infos; flutter test; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
실행 전 원격 `local.properties` 보정과 package/app `flutter pub get`을 수행했다.
Analyzing messaging_flutter...
No issues found! (ran in 2.2s)
00:00 +14: All tests passed!
> Task :nexo_messaging:testDebugUnitTest
NexoMessagingPluginTest ... PASSED
PayloadGateTest ... PASSED
ReceiptDeliveryTest > send_ioExceptionQueuesAckForRetry() PASSED
ReceiptDeliveryTest > send_transientHttpQueuesAckForRetry() PASSED
ReceiptDeliveryTest > send_authFailureIsPermanentAndDoesNotQueue() PASSED
ReceiptDeliveryTest > replyTransientQueueHelperStoresReplyAction() PASSED
NotificationDisplayTest ... PASSED
PendingNotificationActionStoreTest > malformedQueueIsResetOnNextWrite() PASSED
PendingNotificationActionStoreTest > enqueueAckStoresActionAndSelectsWhenDue() PASSED
PendingNotificationActionStoreTest > dispatcherRemovesActionAfterSuccessfulRetry() PASSED
PendingNotificationActionStoreTest > transientFailureAtMaxAttemptsBecomesPermanentAndRemovesAction() PASSED
PendingNotificationActionStoreTest > httpStatusClassificationSeparatesTransientAndPermanentFailures() PASSED
45 tests completed, 0 failed
BUILD SUCCESSFUL in 16s
Known warnings only:
- Flutter reported future Swift Package Manager support warnings for `nexo_messaging` iOS/macOS stubs.
- Gradle reported existing Kotlin plugin / deprecated API / manifest package warnings.
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제:
- Required: `packages/messaging_flutter/android/src/main/kotlin/com/tokilabs/nexo/messaging/NexoMessagingPlugin.kt:53`에서 plugin attach 직후 pending action drain을 시작합니다. 이 시점에는 Dart가 아직 `setAuthToken()`을 호출할 수 없고 `Network`의 auth token은 `packages/messaging_flutter/android/src/main/kotlin/com/tokilabs/nexo/messaging/helpers/Network.kt:22`의 in-memory cache에만 있습니다. 따라서 이전 프로세스에서 저장된 due action은 Authorization 없이 전송되고, `packages/messaging_flutter/android/src/main/kotlin/com/tokilabs/nexo/messaging/helpers/PendingNotificationActionStore.kt:397` 경로에서 401/403을 permanent로 처리해 큐에서 삭제될 수 있습니다. `NexoFirebaseMessagingService.kt:32`의 service-start drain도 같은 위험이 있습니다. 수정은 token availability를 dispatcher 입력으로 확인해 token 없는 serverUrl은 큐에 남기거나 transient `missing_auth_token`으로 보류하고, 실제 drain은 `setAuthToken()` 이후 또는 token이 있는 action에만 수행하게 해야 합니다. 회귀 테스트는 token 없는 drain이 action을 삭제하지 않고, token set 이후에만 sender가 호출되는 사례를 추가하세요.
- Required: `packages/messaging_flutter/android/src/main/kotlin/com/tokilabs/nexo/messaging/helpers/PendingNotificationActionStore.kt:359`가 호출마다 새 retry thread를 만들고, `PendingNotificationActionStore.kt:386`에서 due snapshot을 읽은 뒤 네트워크 전송을 잠금 밖에서 수행합니다. `onAttachedToEngine`, `setAuthToken`, FCM service, reply receiver가 가까운 시간에 `retryDueActionsAsync()`를 호출하면 두 dispatcher가 같은 due reply action을 동시에 POST할 수 있습니다. ACK는 중복 허용 가능성이 있어도 reply는 사용자 메시지를 중복 생성할 수 있으므로 correctness issue입니다. 수정은 dispatcher를 single-flight로 직렬화하거나 action claim/remove/requeue를 원자화하고, concurrent drain에서 sender 호출이 1회로 제한되는 테스트를 추가하세요.
- 다음 단계: FAIL follow-up plan/review를 생성한다.

View file

@ -0,0 +1,208 @@
<!-- task=m-notification-pipeline/02+01_ack_actions plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-05-31
task=m-notification-pipeline/02+01_ack_actions, plan=1, tag=REVIEW_API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/messaging-runtime/milestones/notification-pipeline.md`
- Task ids:
- `ack-delivery`: ACK request, local pending queue, bounded retry, 영구 실패 전달 기준을 정리한다.
- `reply-dismiss`: inline reply와 dismiss action의 storage/network 경계를 정리한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G08.md` -> `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` -> `plan_cloud_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-notification-pipeline/02+01_ack_actions/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] Auth-token gated pending retry | [x] |
| [REVIEW_API-2] Single-flight pending retry dispatcher | [x] |
## 구현 체크리스트
- [x] Pending retry가 auth token 없는 serverUrl을 permanent failure로 삭제하지 않도록 token availability guard를 추가한다.
- [x] Pending retry dispatcher를 single-flight 또는 atomic claim 방식으로 보강해 같은 due action이 중복 POST되지 않게 한다.
- [x] Native unit tests로 token-missing retry 보류와 concurrent drain sender 단일 호출을 검증한다.
- [x] 원격 환경에서 `flutter analyze --no-fatal-infos`, package tests, Android native unit tests를 실행하고 출력 전체를 review stub에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-notification-pipeline/02+01_ack_actions/`를 `agent-task/archive/YYYY/MM/m-notification-pipeline/02+01_ack_actions/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-notification-pipeline/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- 원격 검증 전 `apps/flutter-test/android/local.properties`의 `flutter.sdk`를 원격 host의 `$HOME/SDK/flutter`로 보정하고 package/app `flutter pub get`을 수행했다. tar sync 시 `.dart_tool`과 원격 plugin metadata가 없기 때문에 이전 loop와 같은 원격 준비 단계가 필요했다.
- 직접 ACK/reply 전송 경로에도 token availability guard를 적용했다. follow-up plan은 pending retry를 주 대상으로 두었지만, direct path도 cold start에서 같은 in-memory token 부재 위험을 공유하므로 sender 호출 전 `missing_auth_token` queue path로 보낸다.
## 주요 설계 결정
- `Network`에 `hasToken(serverUrl)`을 추가하고 token cache 접근을 lock으로 감쌌다. dispatcher와 direct ACK/reply sender가 동일한 token availability 기준을 사용한다.
- dispatcher는 token 없는 due action을 `recordDeferred(..., "missing_auth_token")`로 표시하되 sender 호출, attempts 증가, permanent failure event 발행, queue 삭제를 하지 않는다.
- `setAuthToken()` 이후 기존 opportunistic retry 호출은 유지한다. token이 생긴 serverUrl의 due action만 실제 POST로 진행된다.
- pending retry dispatcher 전체를 shared lock으로 single-flight 직렬화했다. 가장 작은 변경으로 due snapshot 중복 처리와 reply 중복 POST 위험을 제거한다.
- retry 처리 수(`processed`)는 실제 sender 호출 수로 유지했다. token missing으로 보류된 action은 processed에 포함하지 않는다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Token이 없는 pending action이 sender 호출, attempt 증가, permanent event 없이 큐에 남는지 본다.
- `setAuthToken()` 이후 token이 있는 serverUrl의 due action만 전송되는지 본다.
- 동시에 두 drain이 시작되어도 같은 reply action이 한 번만 POST되는지 본다.
- 기존 ACK success/transient/permanent, reply transient/permanent, dismiss no-server-contract 동작이 유지되는지 본다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### REVIEW_API-1 중간 검증
```text
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
실행 전 원격 checkout에서 `flutter.sdk=$HOME/SDK/flutter` 보정과 `flutter pub get`을 수행했다.
> Task :nexo_messaging:testDebugUnitTest
ReceiptDeliveryTest > send_missingAuthTokenQueuesWithoutPosting() PASSED
PendingNotificationActionStoreTest > dispatcherDefersDueActionWithoutAuthToken() PASSED
PendingNotificationActionStoreTest > dispatcherSendsDeferredActionAfterAuthTokenIsAvailable() PASSED
기존 ACK/reply/display/plugin tests 전체 PASSED
49 tests completed, 0 failed
BUILD SUCCESSFUL in 16s
```
### REVIEW_API-2 중간 검증
```text
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
실행 전 원격 checkout에서 `flutter.sdk=$HOME/SDK/flutter` 보정과 `flutter pub get`을 수행했다.
> Task :nexo_messaging:testDebugUnitTest
PendingNotificationActionStoreTest > dispatcherSingleFlightPreventsDuplicateReplyPost() PASSED
PendingNotificationActionStoreTest > dispatcherRemovesActionAfterSuccessfulRetry() PASSED
PendingNotificationActionStoreTest > transientFailureAtMaxAttemptsBecomesPermanentAndRemovesAction() PASSED
기존 ACK/reply/display/plugin tests 전체 PASSED
49 tests completed, 0 failed
BUILD SUCCESSFUL in 16s
```
### 최종 검증
```text
$ REMOTE_DIR='$HOME/tmp/nexo-codex-test'
$ tar --exclude='./.git' --exclude='./.env' --exclude='./.env.*' --exclude='./agent-test/local/secrets.md' --exclude='./.dart_tool' --exclude='./build' --exclude='./apps/flutter-test/.dart_tool' --exclude='./apps/flutter-test/build' --exclude='./apps/flutter-test/android/.gradle' --exclude='./packages/messaging_flutter/android/.gradle' --exclude='./.gradle' -czf - . | ssh toki@toki-labs.com "rm -rf \"$REMOTE_DIR\" && mkdir -p \"$REMOTE_DIR\" && tar -xzf - -C \"$REMOTE_DIR\""
(no stdout, exit code 0)
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/packages/messaging_flutter"; flutter analyze --no-fatal-infos; flutter test; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
실행 전 원격 `local.properties` 보정과 package/app `flutter pub get`을 수행했다.
Analyzing messaging_flutter...
No issues found! (ran in 1.9s)
00:00 +14: All tests passed!
> Task :nexo_messaging:testDebugUnitTest
NexoMessagingPluginTest ... PASSED
PayloadGateTest ... PASSED
ReceiptDeliveryTest > send_missingAuthTokenQueuesWithoutPosting() PASSED
ReceiptDeliveryTest 기존 ACK tests ... PASSED
NotificationDisplayTest ... PASSED
PendingNotificationActionStoreTest > dispatcherDefersDueActionWithoutAuthToken() PASSED
PendingNotificationActionStoreTest > dispatcherSendsDeferredActionAfterAuthTokenIsAvailable() PASSED
PendingNotificationActionStoreTest > dispatcherSingleFlightPreventsDuplicateReplyPost() PASSED
PendingNotificationActionStoreTest 기존 retry tests ... PASSED
49 tests completed, 0 failed
BUILD SUCCESSFUL in 15s
Known warnings only:
- Flutter reported future Swift Package Manager support warnings for `nexo_messaging` iOS/macOS stubs.
- Gradle reported existing Kotlin plugin / deprecated API / manifest package warnings.
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
Sections and their ownership:
| 섹션 | 소유자 | 설명 |
|------|--------|------|
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 |
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 |
| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지 |
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 |
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS complete.log 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,43 @@
# Complete - m-notification-pipeline/02+01_ack_actions
## 완료 일시
2026-05-31
## 요약
Android ACK/reply/dismiss action delivery completed after 2 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G08_0.log` | `code_review_cloud_G08_0.log` | FAIL | Required follow-up for auth-token-gated retry and single-flight pending retry dispatch. |
| `plan_cloud_G08_1.log` | `code_review_cloud_G08_1.log` | PASS | Missing-token queue preservation, retry single-flight behavior, and remote verification evidence were completed. |
## 구현/정리 내용
- Android ACK delivery now uses a pending action queue with transient/permanent failure classification and bounded retry.
- Inline reply transient failures are queued and retried, while permanent failures emit `action_failure` diagnostics without request bodies.
- Dismiss remains local cleanup only, with the no-server-contract boundary documented.
- Follow-up review fixed cold-start auth-token loss by deferring tokenless pending actions and preventing concurrent retry drains from duplicate reply POSTs.
## 최종 검증
- `ssh toki@toki-labs.com 'set -e; ... cd "$HOME/tmp/nexo-codex-test/packages/messaging_flutter"; flutter analyze --no-fatal-infos; flutter test; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'` - PASS; implementation evidence records no analyze issues, package Flutter tests passed 14/14, Android native unit tests passed 49/49, and Gradle ended with `BUILD SUCCESSFUL`.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/messaging-runtime/milestones/notification-pipeline.md`
- Completed task ids:
- `ack-delivery`: PASS; evidence=`agent-task/archive/2026/05/m-notification-pipeline/02+01_ack_actions/plan_cloud_G08_0.log`, `agent-task/archive/2026/05/m-notification-pipeline/02+01_ack_actions/code_review_cloud_G08_0.log`, `agent-task/archive/2026/05/m-notification-pipeline/02+01_ack_actions/plan_cloud_G08_1.log`, `agent-task/archive/2026/05/m-notification-pipeline/02+01_ack_actions/code_review_cloud_G08_1.log`; verification=`ssh toki@toki-labs.com 'set -e; ... flutter analyze --no-fatal-infos; flutter test; ./gradlew testDebugUnitTest'`
- `reply-dismiss`: PASS; evidence=`agent-task/archive/2026/05/m-notification-pipeline/02+01_ack_actions/plan_cloud_G08_0.log`, `agent-task/archive/2026/05/m-notification-pipeline/02+01_ack_actions/code_review_cloud_G08_0.log`, `agent-task/archive/2026/05/m-notification-pipeline/02+01_ack_actions/plan_cloud_G08_1.log`, `agent-task/archive/2026/05/m-notification-pipeline/02+01_ack_actions/code_review_cloud_G08_1.log`; verification=`ssh toki@toki-labs.com 'set -e; ... flutter analyze --no-fatal-infos; flutter test; ./gradlew testDebugUnitTest'`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,128 @@
<!-- task=m-notification-pipeline/02+01_ack_actions plan=1 tag=REVIEW_API -->
# Implementation Plan - REVIEW_API
## 이 파일을 읽는 구현 에이전트에게
이 계획은 `plan_cloud_G08_0.log` / `code_review_cloud_G08_0.log`의 FAIL Required 항목만 다룬다. 기존 ACK/reply/dismiss 구현을 재설계하지 말고, pending action retry가 auth token 준비 전 큐를 삭제하지 않으며 concurrent drain에서 같은 reply action을 중복 전송하지 않도록 보강한다. 구현 후 active `CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션에 실제 변경 내용과 검증 출력을 채우고, active 파일은 그대로 둔 채 리뷰 준비 상태를 보고한다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 active `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션을 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 채운다. code-review가 그 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/messaging-runtime/milestones/notification-pipeline.md`
- Task ids:
- `ack-delivery`: ACK request, local pending queue, bounded retry, 영구 실패 전달 기준을 정리한다.
- `reply-dismiss`: inline reply와 dismiss action의 storage/network 경계를 정리한다.
- Completion mode: check-on-pass
## 분석 결과
### 이전 루프 산출물
- Archived plan: `agent-task/m-notification-pipeline/02+01_ack_actions/plan_cloud_G08_0.log`
- Archived review: `agent-task/m-notification-pipeline/02+01_ack_actions/code_review_cloud_G08_0.log`
- Verdict: FAIL
### Required 문제 요약
- Auth token이 아직 `Network` in-memory cache에 없을 때 `retryDueActionsAsync()`가 due queue를 drain하면 Authorization 없이 POST하고 401/403을 permanent로 처리해 pending action을 삭제할 수 있다.
- 여러 runtime entrypoint가 `retryDueActionsAsync()`를 동시에 호출할 수 있고, 현재 dispatcher는 due snapshot을 읽은 뒤 전송을 잠금 밖에서 수행하므로 같은 reply action을 중복 POST할 수 있다.
### 범위 결정 근거
후속 범위는 pending retry 안전성에 한정한다. 새 public Dart API, 새 서버 endpoint, WorkManager dependency, dismiss server delivery는 추가하지 않는다. 기존 `setAuthToken()` 이후 opportunistic retry 흐름은 유지하되, token이 없는 serverUrl은 큐에 남겨야 한다.
### 빌드 등급
Build lane `cloud-G08`, review lane `cloud-G08`. 인증 상태, persisted queue, concurrent retry sender 호출이 맞물리는 Android native retry 계약이므로 기존 route를 유지한다.
## 구현 체크리스트
- [ ] Pending retry가 auth token 없는 serverUrl을 permanent failure로 삭제하지 않도록 token availability guard를 추가한다.
- [ ] Pending retry dispatcher를 single-flight 또는 atomic claim 방식으로 보강해 같은 due action이 중복 POST되지 않게 한다.
- [ ] Native unit tests로 token-missing retry 보류와 concurrent drain sender 단일 호출을 검증한다.
- [ ] 원격 환경에서 `flutter analyze --no-fatal-infos`, package tests, Android native unit tests를 실행하고 출력 전체를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_API-1] Auth-token gated pending retry
#### 문제
`NexoMessagingPlugin.onAttachedToEngine()`와 `NexoFirebaseMessagingService.onCreate()`는 `setAuthToken()`이 다시 호출되기 전에 `PendingNotificationActionDispatcher.retryDueActionsAsync()`를 실행한다. `Network` auth token은 in-memory cache이므로 cold start 직후에는 비어 있을 수 있다. 이 상태에서 due action을 전송하면 401/403을 permanent로 처리해 큐에서 제거할 수 있다.
#### 해결 방법
`Network`에 package-visible 또는 public native helper 수준의 token availability query를 추가하고, dispatcher가 action별 `serverUrl`에 token이 없으면 sender를 호출하지 않고 action을 큐에 남기게 한다. reason은 예: `missing_auth_token`으로 기록하되 attempts를 증가시키거나 permanent event를 보내지 않는다. `setAuthToken()` 이후의 retry call은 token이 있으므로 기존처럼 due action을 전송한다.
직접 ACK/reply 전송 경로도 같은 기준을 적용할 수 있으면 적용한다. 단, 명시적 401/403 응답을 받은 경우와 token 자체가 없는 경우를 섞지 않는다.
#### 수정 파일 및 체크리스트
- [ ] `packages/messaging_flutter/android/src/main/kotlin/com/tokilabs/nexo/messaging/helpers/Network.kt`: token availability query를 추가한다.
- [ ] `packages/messaging_flutter/android/src/main/kotlin/com/tokilabs/nexo/messaging/helpers/PendingNotificationActionStore.kt`: dispatcher에 auth guard를 추가하고 token 없는 action은 삭제/attempt 증가 없이 보류한다.
- [ ] 필요 시 `packages/messaging_flutter/android/src/main/java/com/tokilabs/nexo/messaging/ReceiptDelivery.java`와 `NotificationReplyBroadcastReceiver.java`: token-missing 직접 전송을 transient queue path로 보낸다.
- [ ] Native test: token 없는 due action은 sender 미호출, 큐 보존, permanent event 미발행을 검증한다.
#### 중간 검증
```sh
ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
```
기대 결과: Android native unit tests가 exit code `0`.
### [REVIEW_API-2] Single-flight pending retry dispatcher
#### 문제
`retryDueActionsAsync()`는 호출마다 새 thread를 만들고 `retryDueActions()`는 due list를 snapshot으로 읽은 뒤 네트워크 전송을 수행한다. `onAttachedToEngine`, `setAuthToken`, FCM service, reply receiver가 겹치면 같은 pending reply action이 두 번 POST될 수 있다.
#### 해결 방법
Dispatcher 전체를 single-flight로 직렬화하거나, action을 전송 전에 원자적으로 claim하여 다른 dispatcher가 같은 action을 처리하지 못하게 한다. 가장 작은 수정은 `retryDueActionsBlocking()` 전체를 lock으로 감싸고 async 호출이 같은 lock을 공유하게 하는 방식이다. 더 세밀한 방식으로 claim 상태를 저장한다면 transient 실패 시 action을 반드시 재큐잉하고 success/permanent 시 제거해야 한다.
#### 수정 파일 및 체크리스트
- [ ] `packages/messaging_flutter/android/src/main/kotlin/com/tokilabs/nexo/messaging/helpers/PendingNotificationActionStore.kt`: concurrent dispatcher 실행에서 sender가 동일 action을 중복 처리하지 않게 한다.
- [ ] Native test: 두 retry call이 같은 due reply action을 동시에 처리하려 할 때 sender call count가 1회임을 검증한다.
- [ ] 기존 success, transient retry, permanent failure tests가 새 직렬화/claim 로직에서도 유지되는지 확인한다.
#### 중간 검증
```sh
ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
```
기대 결과: Android native unit tests가 exit code `0`.
## 의존 관계 및 구현 순서
1. `REVIEW_API-1` token availability guard를 먼저 구현한다.
2. `REVIEW_API-2` dispatcher 중복 전송 방지를 구현한다.
3. 두 Required 항목의 native tests를 추가한 뒤 원격 최종 검증을 실행한다.
## 수정 파일 요약
| 파일 | 항목 |
| --- | --- |
| `packages/messaging_flutter/android/src/main/kotlin/com/tokilabs/nexo/messaging/helpers/Network.kt` | REVIEW_API-1 |
| `packages/messaging_flutter/android/src/main/kotlin/com/tokilabs/nexo/messaging/helpers/PendingNotificationActionStore.kt` | REVIEW_API-1, REVIEW_API-2 |
| `packages/messaging_flutter/android/src/main/java/com/tokilabs/nexo/messaging/ReceiptDelivery.java` | REVIEW_API-1, 필요 시 |
| `packages/messaging_flutter/android/src/main/java/com/tokilabs/nexo/messaging/NotificationReplyBroadcastReceiver.java` | REVIEW_API-1, 필요 시 |
| `packages/messaging_flutter/android/src/test/kotlin/com/tokilabs/nexo/messaging/helpers/PendingNotificationActionStoreTest.kt` | REVIEW_API-1, REVIEW_API-2 |
| `packages/messaging_flutter/android/src/test/kotlin/com/tokilabs/nexo/messaging/ReceiptDeliveryTest.kt` | REVIEW_API-1, 필요 시 |
## 최종 검증
로컬 테스트 실행은 `agent-test/local/rules.md`에 따라 금지된다. 구현 후 현재 working tree를 remote test checkout으로 동기화한다.
```sh
REMOTE_DIR='$HOME/tmp/nexo-codex-test'
tar --exclude='./.git' --exclude='./.env' --exclude='./.env.*' --exclude='./agent-test/local/secrets.md' --exclude='./.dart_tool' --exclude='./build' --exclude='./apps/flutter-test/.dart_tool' --exclude='./apps/flutter-test/build' --exclude='./apps/flutter-test/android/.gradle' --exclude='./packages/messaging_flutter/android/.gradle' --exclude='./.gradle' -czf - . | ssh toki@toki-labs.com "rm -rf \"$REMOTE_DIR\" && mkdir -p \"$REMOTE_DIR\" && tar -xzf - -C \"$REMOTE_DIR\""
ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/packages/messaging_flutter"; flutter analyze --no-fatal-infos; flutter test; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
```
기대 결과: 모든 명령이 exit code `0`. Real FCM delivery, server ACK/reply response, Android notification action UI는 external infrastructure가 필요하므로 manual smoke residual risk로 기록한다.

View file

@ -57,15 +57,15 @@ task=m-notification-pipeline/03_host_routing_contract, plan=0, tag=API
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-notification-pipeline/03_host_routing_contract/`를 `agent-task/archive/YYYY/MM/m-notification-pipeline/03_host_routing_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-notification-pipeline/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-notification-pipeline/03_host_routing_contract/`를 `agent-task/archive/YYYY/MM/m-notification-pipeline/03_host_routing_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-notification-pipeline/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
@ -73,6 +73,7 @@ task=m-notification-pipeline/03_host_routing_contract, plan=0, tag=API
## 계획 대비 변경 사항
- 원격 host에 `rg`가 없어 문서 검색 검증을 `grep -RInE`로 갱신해 실행했다. active plan/review command도 같은 명령으로 맞췄다.
- `README.md`의 `Push Payload Fields` 표에서 `is_crt_enabled` 설명을 Dart parser와 같은 truthy 기준(`true`, `"true"`, `"1"`)으로 맞췄다.
## 주요 설계 결정
@ -294,8 +295,152 @@ Installing build/app/outputs/flutter-apk/app-debug.apk... 413ms
00:01 +4: All tests passed!
```
### 최종 검증 재실행
```text
$ REMOTE_DIR='$HOME/tmp/nexo-codex-test'
$ tar --exclude='./.git' --exclude='./.env' --exclude='./.env.*' --exclude='./agent-test/local/secrets.md' --exclude='./.dart_tool' --exclude='./build' --exclude='./apps/flutter-test/.dart_tool' --exclude='./apps/flutter-test/build' --exclude='./apps/flutter-test/android/.gradle' --exclude='./packages/messaging_flutter/android/.gradle' --exclude='./.gradle' -czf - . | ssh toki@toki-labs.com "rm -rf \"$REMOTE_DIR\" && mkdir -p \"$REMOTE_DIR\" && tar -xzf - -C \"$REMOTE_DIR\""
(no output)
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/packages/messaging_flutter"; flutter analyze --no-fatal-infos; flutter test; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test"; flutter analyze --no-fatal-infos; flutter test; flutter test integration_test -d emulator-5554'
Resolving dependencies...
Downloading packages...
_flutterfire_internals 1.3.59 (1.3.71 available)
firebase_core 3.15.2 (4.9.0 available)
firebase_core_platform_interface 6.0.3 (7.0.1 available)
firebase_core_web 2.24.1 (3.7.0 available)
firebase_messaging 15.2.10 (16.2.2 available)
firebase_messaging_platform_interface 4.6.10 (4.7.11 available)
firebase_messaging_web 3.10.10 (4.1.7 available)
matcher 0.12.19 (0.12.20 available)
> meta 1.18.0 (was 1.17.0) (1.18.2 available)
> test_api 0.7.11 (was 0.7.10) (0.7.12 available)
vector_math 2.2.0 (2.3.0 available)
Changed 2 dependencies!
11 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.
Analyzing messaging_flutter...
No issues found! (ran in 1.8s)
00:00 +0: loading /Users/toki/tmp/nexo-codex-test/packages/messaging_flutter/test/nexo_messaging_test.dart
00:00 +0: NotificationOpenedEvent.fromMap parses server_url, channel_id, root_id, is_crt_enabled
00:00 +1: NotificationOpenedEvent.fromMap defaults isCRTEnabled to false when flag is absent or non-true
00:00 +2: NotificationOpenedEvent.fromMap accepts boolean and numeric-string CRT truthy flags
00:00 +3: NexoMessagingPlugin.handleNativeEvent opened event emits NotificationOpenedEvent on stream
[PushNotification] Opened: channelId=channel-123 rootId=null
00:00 +4: NexoMessagingPlugin.handleNativeEvent message event with userInteraction routes through opened handler
[PushNotification] Opened: channelId=channel-xyz rootId=null
00:00 +5: NexoMessagingPlugin.handleNativeEvent message event without userInteraction does not trigger navigation
00:00 +6: NexoMessagingPlugin.handleNativeEvent opened event with CRT and rootId routes to thread callback
[PushNotification] Opened: channelId=channel-123 rootId=root-789
00:00 +7: NexoMessagingPlugin.handleNativeEvent opened event with boolean CRT and rootId routes to thread callback
[PushNotification] Opened: channelId=channel-123 rootId=root-789
00:00 +8: NexoMessagingPlugin.handleNativeEvent opened event with CRT but empty rootId falls back to channel callback
[PushNotification] Opened: channelId=channel-123 rootId=
00:00 +9: NexoMessagingPlugin.handleNativeEvent opened event without server_url skips navigation callbacks
[PushNotification] Opened: channelId=channel-123 rootId=null
00:00 +10: NexoMessagingPlugin.handleNativeEvent every event is forwarded to onNotification stream
[PushNotification] Clear: channelId=channel-123
00:00 +11: NexoMessagingPlugin.handleNativeEvent token_refresh event saves formatted device token and invokes callback
[PushNotification] Device token saved
00:00 +12: NexoMessagingPlugin.handleNativeEvent debugSendNativeEventForTesting invokes native debug method
00:00 +13: Channel constants notification and action channel names are preserved
00:00 +14: All tests passed!
Resolving dependencies...
Downloading packages...
_flutterfire_internals 1.3.59 (1.3.71 available)
firebase_core 3.15.2 (4.9.0 available)
firebase_core_platform_interface 6.0.3 (7.0.1 available)
firebase_core_web 2.24.1 (3.7.0 available)
firebase_messaging 15.2.10 (16.2.2 available)
firebase_messaging_platform_interface 4.6.10 (4.7.11 available)
firebase_messaging_web 3.10.10 (4.1.7 available)
matcher 0.12.19 (0.12.20 available)
> meta 1.18.0 (was 1.17.0) (1.18.2 available)
> test_api 0.7.11 (was 0.7.10) (0.7.12 available)
vector_math 2.2.0 (2.3.0 available)
Changed 2 dependencies!
11 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.
The following plugins do not support Swift Package Manager for ios:
- nexo_messaging
This will become an error in a future version of Flutter. Please contact the plugin maintainers to request Swift Package Manager adoption.
The following plugins do not support Swift Package Manager for macos:
- nexo_messaging
This will become an error in a future version of Flutter. Please contact the plugin maintainers to request Swift Package Manager adoption.
Analyzing flutter-test...
No issues found! (ran in 1.4s)
The following plugins do not support Swift Package Manager for ios:
- nexo_messaging
This will become an error in a future version of Flutter. Please contact the plugin maintainers to request Swift Package Manager adoption.
The following plugins do not support Swift Package Manager for macos:
- nexo_messaging
This will become an error in a future version of Flutter. Please contact the plugin maintainers to request Swift Package Manager adoption.
00:00 +0: loading /Users/toki/tmp/nexo-codex-test/apps/flutter-test/test/widget_test.dart
00:00 +0: renders initial harness state
00:00 +1: updates opened and navigation state from plugin events (channel)
[PushNotification] Opened: channelId=channel-123 rootId=null
00:00 +2: updates opened and navigation state from plugin events (thread/CRT)
[PushNotification] Opened: channelId=channel-123 rootId=thread-456
00:00 +3: updates opened and navigation state from plugin events (boolean CRT)
[PushNotification] Opened: channelId=channel-123 rootId=thread-456
00:00 +4: updates device token from plugin callback
00:00 +5: All tests passed!
The following plugins do not support Swift Package Manager for ios:
- nexo_messaging
This will become an error in a future version of Flutter. Please contact the plugin maintainers to request Swift Package Manager adoption.
The following plugins do not support Swift Package Manager for macos:
- nexo_messaging
This will become an error in a future version of Flutter. Please contact the plugin maintainers to request Swift Package Manager adoption.
00:00 +0: loading /Users/toki/tmp/nexo-codex-test/apps/flutter-test/integration_test/plugin_integration_test.dart
Running Gradle task 'assembleDebug'...
WARNING: Your Android app project: app located at: /Users/toki/tmp/nexo-codex-test/apps/flutter-test/android/app/build.gradle.kts
applies the Kotlin Gradle Plugin, which will cause build failures in future versions of Flutter.
Please migrate your app to Built-in Kotlin using this guide: https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-app-developers
WARNING: Your app uses the following plugins that apply Kotlin Gradle Plugin (KGP): nexo_messaging
Future versions of Flutter will fail to build if your app uses plugins that apply KGP.
Please check the changelogs of these plugins and upgrade to a version that supports Built-in Kotlin.
If no such version exists, report the issue to the plugin. If necessary, here is a guide on filing
an issue against a plugin: https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-app-developers#report-incompatible-kotlin-gradle-plugin-usage-to-plugin-authors
If you are a plugin author, please migrate your plugin to Built-in Kotlin using this guide: https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-plugin-authors
Note: Some input files use or override a deprecated API.
Note: Recompile with -Xlint:deprecation for details.
Note: /Users/toki/tmp/nexo-codex-test/packages/messaging_flutter/android/src/main/java/com/tokilabs/nexo/messaging/helpers/CustomPushNotificationHelper.java uses or overrides a deprecated API.
Note: Recompile with -Xlint:deprecation for details.
Running Gradle task 'assembleDebug'... 28.6s
✓ Built build/app/outputs/flutter-apk/app-debug.apk
Installing build/app/outputs/flutter-apk/app-debug.apk... 469ms
00:00 +0: singleton instance is accessible
00:00 +1: native debug event reaches harness routing UI (channel)
[PushNotification] Opened: channelId=null rootId=null
[PushNotification] Opened: channelId=channel-integration rootId=null
00:00 +2: native debug event reaches harness routing UI (thread/CRT)
00:00 +2: native debug event reaches harness routing UI (channel)
[PushNotification] Opened: channelId=channel-integration rootId=root-integration
00:01 +3: native debug event reaches harness routing UI (boolean CRT)
00:01 +3: native debug event reaches harness routing UI (channel)
[PushNotification] Opened: channelId=channel-integration rootId=root-integration
00:01 +4: (tearDownAll)
00:01 +4: All tests passed!
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS이므로 active review/plan을 로그로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,43 @@
# Complete - m-notification-pipeline/03_host_routing_contract
## 완료 일시
2026-05-31
## 요약
Notification opened routing과 host integration contract 보강 작업을 1회 리뷰 루프로 검토했고 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | PASS | CRT flag parsing, host routing regression tests, host contract docs, remote verification evidence를 확인했다. |
## 구현/정리 내용
- `NotificationOpenedEvent.fromMap`이 boolean `true`, string `"true"`, string `"1"` CRT flag를 thread routing true로 처리하는 것을 확인했다.
- Package/widget/integration tests가 channel, thread, boolean CRT, empty root fallback, missing server routing skip 기준을 검증하는 것을 확인했다.
- Plugin README와 flutter-test README가 host callback, login/logout hook, token handoff, manual smoke 책임을 문서화하는 것을 확인했다.
## 최종 검증
- `tar --exclude='./.git' --exclude='./.env' --exclude='./.env.*' --exclude='./agent-test/local/secrets.md' --exclude='./.dart_tool' --exclude='./build' --exclude='./apps/flutter-test/.dart_tool' --exclude='./apps/flutter-test/build' --exclude='./apps/flutter-test/android/.gradle' --exclude='./packages/messaging_flutter/android/.gradle' --exclude='./.gradle' -czf - . | ssh toki@toki-labs.com "rm -rf \"$REMOTE_DIR\" && mkdir -p \"$REMOTE_DIR\" && tar -xzf - -C \"$REMOTE_DIR\""` - PASS; 원격 테스트 checkout 동기화 출력 없음.
- `ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/packages/messaging_flutter"; flutter analyze --no-fatal-infos; flutter test; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test"; flutter analyze --no-fatal-infos; flutter test; flutter test integration_test -d emulator-5554'` - PASS; package/test-host analyze에서 `No issues found!`, package/widget/integration tests에서 `All tests passed!`.
- `ssh toki@toki-labs.com 'set -e; cd "$HOME/tmp/nexo-codex-test"; grep -RInE "Host Integration Contract|setAuthToken|clearAuthToken|onNavigateToThread" packages/messaging_flutter/README.md apps/flutter-test/README.md'` - PASS; host contract와 hook 문구가 검색됨.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/messaging-runtime/milestones/notification-pipeline.md`
- Completed task ids:
- `opened-event`: PASS; evidence=`agent-task/archive/2026/05/m-notification-pipeline/03_host_routing_contract/plan_cloud_G07_0.log`, `agent-task/archive/2026/05/m-notification-pipeline/03_host_routing_contract/code_review_cloud_G07_0.log`; verification=`ssh toki@toki-labs.com ... flutter analyze --no-fatal-infos; flutter test; ... flutter test integration_test -d emulator-5554`
- `host-contract`: PASS; evidence=`agent-task/archive/2026/05/m-notification-pipeline/03_host_routing_contract/plan_cloud_G07_0.log`, `agent-task/archive/2026/05/m-notification-pipeline/03_host_routing_contract/code_review_cloud_G07_0.log`; verification=`ssh toki@toki-labs.com ... grep -RInE "Host Integration Contract|setAuthToken|clearAuthToken|onNavigateToThread" packages/messaging_flutter/README.md apps/flutter-test/README.md`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -1,141 +0,0 @@
<!-- task=m-notification-pipeline/02+01_ack_actions plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
## 개요
date=2026-05-31
task=m-notification-pipeline/02+01_ack_actions, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/messaging-runtime/milestones/notification-pipeline.md`
- Task ids:
- `ack-delivery`: ACK request, local pending queue, bounded retry, 영구 실패 전달 기준을 정리한다.
- `reply-dismiss`: inline reply와 dismiss action의 storage/network 경계를 정리한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G08.md``code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md``plan_cloud_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-notification-pipeline/02+01_ack_actions/`로 이동한다.
4. PASS이고 task group이 `m-notification-pipeline`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Pending Action Store | [ ] |
| [API-2] ACK Delivery Retry Contract | [ ] |
| [API-3] Reply And Dismiss Boundary | [ ] |
## 구현 체크리스트
- [ ] `01_android_payload_display` predecessor의 `complete.log`를 확인한 뒤 구현을 시작한다.
- [ ] ACK delivery에 local pending queue, bounded retry, transient/permanent failure classification을 추가한다.
- [ ] Inline reply 실패를 pending queue에 보존하고 retry 성공/영구 실패 처리 기준을 구현한다.
- [ ] Dismiss action은 local cleanup을 유지하되 server/network no-op 경계와 telemetry/host 전달 기준을 문서화한다.
- [ ] 원격 환경에서 package tests, Android native unit tests, 필요한 host smoke를 실행하고 출력 전체를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-notification-pipeline/02+01_ack_actions/``agent-task/archive/YYYY/MM/m-notification-pipeline/02+01_ack_actions/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-notification-pipeline/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md``CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Predecessor `01` complete 여부를 구현 전 확인했는지 본다.
- Transient network failure와 permanent auth/server rejection 분류가 섞이지 않았는지 본다.
- Dismiss action이 없는 서버 contract를 임의로 만들지 않았는지 본다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### API-1 중간 검증
```text
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
(output)
```
### API-2 중간 검증
```text
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/packages/messaging_flutter"; flutter test; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
(output)
```
### API-3 중간 검증
```text
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
(output)
```
### 최종 검증
```text
$ REMOTE_DIR='$HOME/tmp/nexo-codex-test'
$ tar --exclude='./.git' --exclude='./.env' --exclude='./.env.*' --exclude='./agent-test/local/secrets.md' --exclude='./.dart_tool' --exclude='./build' --exclude='./apps/flutter-test/.dart_tool' --exclude='./apps/flutter-test/build' --exclude='./apps/flutter-test/android/.gradle' --exclude='./packages/messaging_flutter/android/.gradle' --exclude='./.gradle' -czf - . | ssh toki@toki-labs.com "rm -rf \"$REMOTE_DIR\" && mkdir -p \"$REMOTE_DIR\" && tar -xzf - -C \"$REMOTE_DIR\""
(output)
$ ssh toki@toki-labs.com 'set -e; export PATH="$HOME/SDK/flutter/bin:$HOME/Library/Android/sdk/emulator:$HOME/Library/Android/sdk/platform-tools:$HOME/Library/Android/sdk/cmdline-tools/latest/bin:$PATH"; export ANDROID_HOME="$HOME/Library/Android/sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"; cd "$HOME/tmp/nexo-codex-test/packages/messaging_flutter"; flutter analyze --no-fatal-infos; flutter test; cd "$HOME/tmp/nexo-codex-test/apps/flutter-test/android"; ./gradlew testDebugUnitTest'
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

View file

@ -310,6 +310,7 @@ The Dart API currently recognizes these native event types:
| `session` | Session-related event |
| `token_refresh` | Device-token refresh event |
| `opened` | User opened a notification |
| `action_failure` | Permanent native ACK or reply action failure forwarded for host diagnostics |
`NotificationOpenedEvent` parses `server_url`, `channel_id`, `root_id`, and
`is_crt_enabled`. If CRT is enabled and `root_id` is present, the plugin calls
@ -342,7 +343,7 @@ and `android/src/main/kotlin/com/tokilabs/nexo/messaging/LaunchIntentHelper.kt`.
| `server_id` | Server URL fallback | Resolved through the identifier stored by `setAuthToken(serverUrl, token, identifier: ...)`. |
| `channel_id` | Notification grouping and channel routing | Used for notification grouping, clear handling, and `onNavigateToChannel`. |
| `root_id` | CRT/thread routing | When CRT is enabled, this groups thread notifications and drives `onNavigateToThread`. |
| `is_crt_enabled` | Thread behavior | The string value `"true"` enables thread routing; other values are treated as false. |
| `is_crt_enabled` | Thread behavior | Boolean `true`, string `"true"`, or string `"1"` enables thread routing; other values are treated as false. |
| `post_id` | ACK id-loaded response and notification identity | Included in ACK requests and used as the Android notification id source. |
| `signature` | Payload verification | Verified against the server signing key and stored device token. Missing signatures are accepted for backward compatibility. |
| `id_loaded` | ACK response enrichment | The string value `"true"` asks the server to return post data that Android can merge into the notification bundle. |
@ -401,13 +402,40 @@ Failure handling criteria:
- Signature verification precedes ACK delivery, so an invalid signature payload
never reaches the ACK request.
- If ACK delivery fails for an already-verified payload, Android logs the failure
and continues notification processing with the original payload.
- If ACK delivery fails for an already-verified payload because of an offline,
timeout, 408, 429, or 5xx condition, Android stores a local pending action and
retries it opportunistically with a bounded three-attempt limit. Notification
processing continues with the original payload.
- If an id-loaded ACK response cannot be parsed, Android treats it as absent and
continues with the original payload.
- If the server rejects the ACK because push notifications are disabled,
unauthenticated, or the post is unavailable, that is a runtime/server state
failure, not a Dart API contract change.
unauthenticated, or the post is unavailable, Android treats it as permanent,
removes any pending action, and forwards an `action_failure` native event for
host diagnostics. This is a runtime/server state failure, not a Dart API
contract change.
### Reply And Dismiss Contract
Plugin-side source anchors:
`android/src/main/java/com/tokilabs/nexo/messaging/NotificationReplyBroadcastReceiver.java`
and
`android/src/main/java/com/tokilabs/nexo/messaging/NotificationDismissService.java`.
Inline replies are posted to `/api/v4/posts?set_online=false` using the server
URL and auth token stored by `setAuthToken()`.
Failure handling criteria:
- A successful 2xx reply updates the Android notification with the sent message.
- Offline, timeout, 408, 429, and 5xx reply failures are stored as local pending
actions and retried opportunistically with the same three-attempt bound as
ACK delivery.
- 401, 403, and other non-retryable 4xx reply failures are permanent. Android
removes the pending action if present and forwards `action_failure` for host
diagnostics.
- Dismiss has no server endpoint in the current nexo contract. The plugin
cancels local notification state only and does not invent a network request or
failure telemetry for a successful local dismiss.
### Reference Front Compatibility

View file

@ -92,4 +92,5 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
testImplementation("org.json:json:20240303")
}

View file

@ -28,6 +28,7 @@ public class NotificationDismissService extends IntentService {
if (bundle != null) {
NotificationHelper.INSTANCE.dismissNotification(context, bundle);
Log.i(TAG, "Dismiss notification id=" + NotificationHelper.INSTANCE.getNotificationId(bundle));
Log.i(TAG, "Dismiss server delivery skipped: no server contract");
}
}
}

View file

@ -14,13 +14,20 @@ import androidx.core.app.NotificationCompat;
import androidx.core.app.Person;
import com.tokilabs.nexo.messaging.helpers.CustomPushNotificationHelper;
import com.tokilabs.nexo.messaging.helpers.DatabaseHelper;
import com.tokilabs.nexo.messaging.helpers.Network;
import com.tokilabs.nexo.messaging.helpers.PendingNotificationActionDispatcher;
import com.tokilabs.nexo.messaging.helpers.PendingNotificationActionStorage;
import com.tokilabs.nexo.messaging.helpers.PendingNotificationActionStore;
import org.json.JSONException;
import org.json.JSONObject;
import okhttp3.Response;
public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
private static final String TAG = "NotifReplyReceiver";
static final String REPLY_ENDPOINT = "/api/v4/posts?set_online=false";
private Context mContext;
private Bundle bundle;
@ -33,17 +40,29 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
if (message == null) return;
mContext = context;
DatabaseHelper.Companion.init(context.getApplicationContext());
PendingNotificationActionDispatcher.retryDueActionsAsync();
bundle = intent.getBundleExtra(CustomPushNotificationHelper.NOTIFICATION);
if (bundle == null) return;
notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
final int notificationId = intent.getIntExtra(CustomPushNotificationHelper.NOTIFICATION_ID, -1);
final String serverUrl = bundle.getString("server_url");
if (serverUrl != null) {
replyToMessage(serverUrl, notificationId, message);
} else {
onReplyFailed(notificationId);
}
PendingResult pendingResult = goAsync();
new Thread(() -> {
try {
if (serverUrl != null) {
replyToMessage(serverUrl, notificationId, message);
} else {
onReplyFailed(notificationId);
}
} finally {
pendingResult.finish();
}
}, "NexoNotificationReply").start();
} catch (Exception e) {
Log.e(TAG, "onReceive error: " + e.getMessage());
e.printStackTrace();
@ -58,52 +77,126 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
if (android.text.TextUtils.isEmpty(rootId)) rootId = postId;
try {
JSONObject body = new JSONObject();
body.put("channel_id", channelId);
body.put("message", message.toString());
body.put("root_id", rootId);
JSONObject body = buildReplyBody(channelId, postId, rootId, message);
if (!Network.INSTANCE.hasToken(serverUrl)) {
boolean queued = queueReplyForTransientFailure(
PendingNotificationActionStore.storageFor(DatabaseHelper.Companion.getInstance()),
serverUrl,
body,
PendingNotificationActionStore.REASON_MISSING_AUTH_TOKEN,
System.currentTimeMillis()
) != null;
if (queued) {
onReplyQueued(notificationId);
} else {
onReplyFailed(notificationId);
}
return;
}
Network.INSTANCE.post(serverUrl, "/api/v4/posts?set_online=false", body,
null,
json -> {
if (json != null) {
try {
if (json.has("status_code")) {
int statusCode = json.getInt("status_code");
if (statusCode < 200 || statusCode >= 300) {
Log.i(TAG, "Reply FAILED status=" + statusCode);
onReplyFailed(notificationId);
return null;
}
}
onReplySuccess(notificationId, message);
Log.i(TAG, "Reply SUCCESS");
} catch (JSONException e) {
e.printStackTrace();
onReplyFailed(notificationId);
}
} else {
Log.i(TAG, "Reply FAILED: null response");
onReplyFailed(notificationId);
}
return null;
},
e -> {
Log.i(TAG, "Reply FAILED exception: " + e.getMessage());
onReplyFailed(notificationId);
return null;
}
);
ReceiptDelivery.HttpResponse response = postReply(serverUrl, REPLY_ENDPOINT, body);
handleReplyResponse(serverUrl, notificationId, message, body, response.statusCode, System.currentTimeMillis());
} catch (JSONException e) {
Log.e(TAG, "JSON error: " + e.getMessage());
onReplyFailed(notificationId);
} catch (Exception e) {
Log.i(TAG, "Reply FAILED exception: " + e.getMessage());
try {
JSONObject body = buildReplyBody(channelId, postId, rootId, message);
boolean queued = queueReplyForTransientFailure(
PendingNotificationActionStore.storageFor(DatabaseHelper.Companion.getInstance()),
serverUrl,
body,
"transient_exception_" + e.getClass().getSimpleName(),
System.currentTimeMillis()
) != null;
if (queued) {
onReplyQueued(notificationId);
} else {
onReplyFailed(notificationId);
}
} catch (Exception queueException) {
Log.e(TAG, "Reply queue failed: " + queueException.getMessage());
onReplyFailed(notificationId);
}
}
}
static JSONObject buildReplyBody(final String channelId, final String postId,
final String rootId, final CharSequence message) throws JSONException {
JSONObject body = new JSONObject();
body.put("channel_id", channelId);
body.put("message", message.toString());
body.put("root_id", rootId);
return body;
}
static com.tokilabs.nexo.messaging.helpers.PendingNotificationAction queueReplyForTransientFailure(
final PendingNotificationActionStorage storage,
final String serverUrl,
final JSONObject body,
final String reason,
final long now
) {
return PendingNotificationActionStore.enqueueReply(storage, serverUrl, REPLY_ENDPOINT, body, reason, now);
}
protected ReceiptDelivery.HttpResponse postReply(final String serverUrl, final String endpoint,
final JSONObject body) throws Exception {
try (Response response = Network.INSTANCE.postSync(serverUrl, endpoint, body, null)) {
String responseBody = response.body() != null ? response.body().string() : null;
return new ReceiptDelivery.HttpResponse(response.code(), responseBody);
}
}
private void handleReplyResponse(final String serverUrl, final int notificationId,
final CharSequence message, final JSONObject body,
final int statusCode, final long now) {
String classification = PendingNotificationActionStore.classifyHttpStatus(statusCode);
if (PendingNotificationActionStore.RESULT_SUCCESS.equals(classification)) {
onReplySuccess(notificationId, message);
Log.i(TAG, "Reply SUCCESS");
return;
}
String reason = PendingNotificationActionStore.reasonForHttpStatus(statusCode);
if (PendingNotificationActionStore.isTransient(classification)) {
boolean queued = queueReplyForTransientFailure(
PendingNotificationActionStore.storageFor(DatabaseHelper.Companion.getInstance()),
serverUrl,
body,
reason,
now
) != null;
Log.i(TAG, "Reply queued=" + queued + " reason=" + reason);
if (queued) {
onReplyQueued(notificationId);
} else {
onReplyFailed(notificationId);
}
return;
}
PendingNotificationActionStore.sendPermanentFailureEvent(
PendingNotificationActionStore.KIND_REPLY,
serverUrl,
REPLY_ENDPOINT,
reason,
1,
null
);
Log.i(TAG, "Reply permanent failure reason=" + reason);
onReplyFailed(notificationId);
}
protected void onReplyFailed(int notificationId) {
recreateNotification(notificationId, "Message failed to send.");
}
protected void onReplyQueued(int notificationId) {
recreateNotification(notificationId, "Message queued to send.");
}
protected void onReplySuccess(int notificationId, final CharSequence message) {
recreateNotification(notificationId, message);
}

View file

@ -3,43 +3,133 @@ package com.tokilabs.nexo.messaging;
import android.os.Bundle;
import android.util.Log;
import com.tokilabs.nexo.messaging.helpers.DatabaseHelper;
import com.tokilabs.nexo.messaging.helpers.Network;
import com.tokilabs.nexo.messaging.helpers.PendingNotificationActionStorage;
import com.tokilabs.nexo.messaging.helpers.PendingNotificationActionStore;
import org.json.JSONObject;
import java.io.IOException;
import java.util.Objects;
import okhttp3.Response;
public class ReceiptDelivery {
private static final String TAG = "ReceiptDelivery";
public static final String ACK_ENDPOINT = "api/v4/notifications/ack";
private static final String[] ACK_KEYS = {
"post_id", "root_id", "category", "message", "team_id",
"channel_id", "channel_name", "type", "sender_id", "sender_name", "version"
};
public static Bundle send(final String ackId, final String serverUrl,
final String postId, final String type, final boolean isIdLoaded) {
Log.i(TAG, String.format("Send ACK=%s TYPE=%s to URL=%s ID-LOADED=%s", ackId, type, serverUrl, isIdLoaded));
public static ReceiptDeliveryResult send(final String ackId, final String serverUrl,
final String postId, final String type, final boolean isIdLoaded) {
return send(
ackId,
serverUrl,
postId,
type,
isIdLoaded,
ReceiptDelivery::postAck,
PendingNotificationActionStore.storageFor(DatabaseHelper.Companion.getInstance()),
System.currentTimeMillis(),
ReceiptDelivery::sendFailureEvent,
Network.INSTANCE::hasToken
);
}
static ReceiptDeliveryResult send(final String ackId, final String serverUrl,
final String postId, final String type, final boolean isIdLoaded,
final AckPostSender sender,
final PendingNotificationActionStorage storage,
final long now) {
return send(
ackId,
serverUrl,
postId,
type,
isIdLoaded,
sender,
storage,
now,
ReceiptDelivery::sendFailureEvent,
server -> true
);
}
static ReceiptDeliveryResult send(final String ackId, final String serverUrl,
final String postId, final String type, final boolean isIdLoaded,
final AckPostSender sender,
final PendingNotificationActionStorage storage,
final long now,
final FailureReporter failureReporter) {
return send(ackId, serverUrl, postId, type, isIdLoaded, sender, storage, now, failureReporter, server -> true);
}
static ReceiptDeliveryResult send(final String ackId, final String serverUrl,
final String postId, final String type, final boolean isIdLoaded,
final AckPostSender sender,
final PendingNotificationActionStorage storage,
final long now,
final FailureReporter failureReporter,
final TokenAvailabilityChecker tokenAvailabilityChecker) {
logInfo(String.format("Send ACK=%s TYPE=%s to URL=%s ID-LOADED=%s", ackId, type, serverUrl, isIdLoaded));
try {
JSONObject body = new JSONObject();
body.put("id", ackId);
body.put("received_at", System.currentTimeMillis());
body.put("platform", "android");
body.put("type", type);
body.put("post_id", postId);
body.put("is_id_loaded", isIdLoaded);
try (Response response = Network.INSTANCE.postSync(serverUrl, "api/v4/notifications/ack", body, null)) {
String responseBody = Objects.requireNonNull(response.body()).string();
JSONObject jsonResponse = new JSONObject(responseBody);
return parseAckResponse(jsonResponse);
JSONObject body = buildAckBody(ackId, postId, type, isIdLoaded, now);
if (!tokenAvailabilityChecker.hasToken(serverUrl)) {
boolean queued = PendingNotificationActionStore.enqueueAck(
storage,
ackId,
serverUrl,
ACK_ENDPOINT,
body,
PendingNotificationActionStore.REASON_MISSING_AUTH_TOKEN,
now
) != null;
if (queued) {
logInfo("ACK queued until auth token is available");
return ReceiptDeliveryResult.queued(PendingNotificationActionStore.REASON_MISSING_AUTH_TOKEN);
}
return ReceiptDeliveryResult.transientFailure(PendingNotificationActionStore.REASON_MISSING_AUTH_TOKEN);
}
HttpResponse response = sender.post(serverUrl, ACK_ENDPOINT, body);
String classification = PendingNotificationActionStore.classifyHttpStatus(response.statusCode);
if (PendingNotificationActionStore.RESULT_SUCCESS.equals(classification)) {
return ReceiptDeliveryResult.success(parseAckResponseBody(response.body));
}
String reason = PendingNotificationActionStore.reasonForHttpStatus(response.statusCode);
if (PendingNotificationActionStore.isTransient(classification)) {
boolean queued = PendingNotificationActionStore.enqueueAck(
storage, ackId, serverUrl, ACK_ENDPOINT, body, reason, now
) != null;
if (queued) {
logInfo("ACK queued for retry reason=" + reason);
return ReceiptDeliveryResult.queued(reason);
}
return ReceiptDeliveryResult.transientFailure(reason);
}
failureReporter.report(
PendingNotificationActionStore.KIND_ACK,
serverUrl,
ACK_ENDPOINT,
reason,
1,
PendingNotificationActionStore.ackActionId(ackId)
);
logInfo("ACK permanent failure reason=" + reason);
return ReceiptDeliveryResult.permanentFailure(reason);
} catch (IOException e) {
return queueAfterException(ackId, serverUrl, postId, type, isIdLoaded, storage, now, e);
} catch (Exception e) {
Log.e(TAG, "Send receipt delivery failed: " + e.getMessage());
logError("Send receipt delivery failed: " + e.getMessage());
e.printStackTrace();
return null;
return queueAfterException(ackId, serverUrl, postId, type, isIdLoaded, storage, now, e);
}
}
@ -57,4 +147,139 @@ public class ReceiptDelivery {
return null;
}
}
public static JSONObject buildAckBody(final String ackId, final String postId,
final String type, final boolean isIdLoaded,
final long receivedAt) throws Exception {
JSONObject body = new JSONObject();
body.put("id", ackId);
body.put("received_at", receivedAt);
body.put("platform", "android");
body.put("type", type);
body.put("post_id", postId);
body.put("is_id_loaded", isIdLoaded);
return body;
}
static Bundle parseAckResponseBody(final String responseBody) {
if (responseBody == null || responseBody.trim().isEmpty() || "OK".equals(responseBody.trim())) {
return null;
}
try {
JSONObject jsonResponse = new JSONObject(responseBody);
return parseAckResponse(jsonResponse);
} catch (Exception e) {
logInfo("ACK response body is not a notification payload");
return null;
}
}
private static ReceiptDeliveryResult queueAfterException(
final String ackId,
final String serverUrl,
final String postId,
final String type,
final boolean isIdLoaded,
final PendingNotificationActionStorage storage,
final long now,
final Exception exception
) {
try {
JSONObject body = buildAckBody(ackId, postId, type, isIdLoaded, now);
String reason = "transient_exception_" + exception.getClass().getSimpleName();
boolean queued = PendingNotificationActionStore.enqueueAck(
storage, ackId, serverUrl, ACK_ENDPOINT, body, reason, now
) != null;
if (queued) {
logInfo("ACK queued after exception reason=" + reason);
return ReceiptDeliveryResult.queued(reason);
}
return ReceiptDeliveryResult.transientFailure(reason);
} catch (Exception e) {
logError("ACK queue failed: " + e.getMessage());
return ReceiptDeliveryResult.transientFailure("queue_failed");
}
}
private static HttpResponse postAck(final String serverUrl, final String endpoint, final JSONObject body) throws Exception {
try (Response response = Network.INSTANCE.postSync(serverUrl, endpoint, body, null)) {
String responseBody = response.body() != null ? Objects.requireNonNull(response.body()).string() : null;
return new HttpResponse(response.code(), responseBody);
}
}
interface AckPostSender {
HttpResponse post(String serverUrl, String endpoint, JSONObject body) throws Exception;
}
interface FailureReporter {
void report(String kind, String serverUrl, String endpoint, String reason, int attempts, String actionId);
}
interface TokenAvailabilityChecker {
boolean hasToken(String serverUrl);
}
private static void sendFailureEvent(String kind, String serverUrl, String endpoint,
String reason, int attempts, String actionId) {
PendingNotificationActionStore.sendPermanentFailureEvent(kind, serverUrl, endpoint, reason, attempts, actionId);
}
public static final class HttpResponse {
public final int statusCode;
public final String body;
public HttpResponse(int statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
}
public static final class ReceiptDeliveryResult {
public final Bundle responseBundle;
public final boolean queued;
public final boolean permanentFailure;
public final String failureReason;
private ReceiptDeliveryResult(Bundle responseBundle, boolean queued,
boolean permanentFailure, String failureReason) {
this.responseBundle = responseBundle;
this.queued = queued;
this.permanentFailure = permanentFailure;
this.failureReason = failureReason;
}
public static ReceiptDeliveryResult success(Bundle responseBundle) {
return new ReceiptDeliveryResult(responseBundle, false, false, null);
}
public static ReceiptDeliveryResult queued(String reason) {
return new ReceiptDeliveryResult(null, true, false, reason);
}
public static ReceiptDeliveryResult transientFailure(String reason) {
return new ReceiptDeliveryResult(null, false, false, reason);
}
public static ReceiptDeliveryResult permanentFailure(String reason) {
return new ReceiptDeliveryResult(null, false, true, reason);
}
}
private static void logInfo(String message) {
try {
Log.i(TAG, message);
} catch (RuntimeException ignored) {
// Android local unit tests use stubbed android.util.Log.
}
}
private static void logError(String message) {
try {
Log.e(TAG, message);
} catch (RuntimeException ignored) {
// Android local unit tests use stubbed android.util.Log.
}
}
}

View file

@ -10,6 +10,7 @@ import com.google.firebase.messaging.RemoteMessage
import com.tokilabs.nexo.messaging.helpers.CustomPushNotificationHelper
import com.tokilabs.nexo.messaging.helpers.DatabaseHelper
import com.tokilabs.nexo.messaging.helpers.NotificationHelper
import com.tokilabs.nexo.messaging.helpers.PendingNotificationActionDispatcher
import com.tokilabs.nexo.messaging.helpers.PushNotificationDataHelper
import io.flutter.embedding.engine.FlutterEngineCache
import kotlinx.coroutines.DelicateCoroutinesApi
@ -28,6 +29,7 @@ class NexoFirebaseMessagingService : FirebaseMessagingService() {
super.onCreate()
// Service can be started before plugin attaches; init DB defensively.
DatabaseHelper.init(applicationContext)
PendingNotificationActionDispatcher.retryDueActionsAsync()
}
override fun onNewToken(token: String) {
@ -238,14 +240,27 @@ internal object PayloadGate {
isIdLoaded: Boolean,
bundle: Bundle,
verifier: (signature: String?, serverUrl: String?, ackId: String?) -> Boolean,
ackSender: (ackId: String, serverUrl: String, postId: String?, type: String?, isIdLoaded: Boolean) -> Bundle?,
ackSender: (
ackId: String,
serverUrl: String,
postId: String?,
type: String?,
isIdLoaded: Boolean,
) -> ReceiptDelivery.ReceiptDeliveryResult,
): Boolean {
if (!verifier(signature, serverUrl, ackId)) {
return false
}
if (ackId != null && serverUrl != null) {
val response = ackSender(ackId, serverUrl, postId, type, isIdLoaded)
val result = ackSender(ackId, serverUrl, postId, type, isIdLoaded)
if (result.queued) {
Log.i("PayloadGate", "ACK queued for retry: ${result.failureReason}")
} else if (result.permanentFailure) {
Log.i("PayloadGate", "ACK permanent failure: ${result.failureReason}")
}
val response = result.responseBundle
if (isIdLoaded && response != null) {
if (!bundle.containsKey("server_url")) {
response.putString("server_url", serverUrl)

View file

@ -7,6 +7,7 @@ import android.os.Bundle
import com.tokilabs.nexo.messaging.helpers.CustomPushNotificationHelper
import com.tokilabs.nexo.messaging.helpers.DatabaseHelper
import com.tokilabs.nexo.messaging.helpers.Network
import com.tokilabs.nexo.messaging.helpers.PendingNotificationActionDispatcher
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@ -49,6 +50,7 @@ class NexoMessagingPlugin :
applicationContext = context
DatabaseHelper.init(context)
PendingNotificationActionDispatcher.retryDueActionsAsync()
CustomPushNotificationHelper.createNotificationChannels(context)
registerLifecycleCallbacks(context)
@ -202,6 +204,7 @@ class NexoMessagingPlugin :
if (serverUrl != null && token != null) {
Network.setToken(serverUrl, token)
DatabaseHelper.getInstance()?.saveServerUrl(serverUrl, identifier)
PendingNotificationActionDispatcher.retryDueActionsAsync()
result.success(null)
} else {
result.error("INVALID_ARG", "serverUrl or token is null", null)

View file

@ -45,6 +45,23 @@ class DatabaseHelper private constructor(private val context: Context) {
}
}
fun getGlobalValue(key: String): String? {
return try {
globalDb.globalDao().getValue(key)
} catch (e: Exception) {
Log.e(TAG, "getGlobalValue error for $key: ${e.message}")
null
}
}
fun saveGlobalValue(key: String, value: String) {
try {
globalDb.globalDao().upsert(GlobalEntity(key, value))
} catch (e: Exception) {
Log.e(TAG, "saveGlobalValue error for $key: ${e.message}")
}
}
val onlyServerUrl: String?
get() = try {
val servers = globalDb.serversDao().getAll()

View file

@ -19,14 +19,25 @@ object Network {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private const val TAG = "NomadNetwork"
private val tokenLock = Any()
private val tokenCache = mutableMapOf<String, String>()
fun setToken(serverUrl: String, token: String) {
tokenCache[serverUrl] = token
synchronized(tokenLock) {
tokenCache[serverUrl] = token
}
}
fun clearToken(serverUrl: String) {
tokenCache.remove(serverUrl)
synchronized(tokenLock) {
tokenCache.remove(serverUrl)
}
}
fun hasToken(serverUrl: String): Boolean {
return synchronized(tokenLock) {
tokenCache[serverUrl]?.isNotBlank() == true
}
}
fun postSync(serverUrl: String, endpoint: String, body: JSONObject, headers: Map<String, String>? = null): Response {
@ -37,7 +48,7 @@ object Network {
.post(requestBody)
.addHeader("Content-Type", "application/json")
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
synchronized(tokenLock) { tokenCache[serverUrl] }?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "POST $url")
@ -59,7 +70,7 @@ object Network {
.post(requestBody)
.addHeader("Content-Type", "application/json")
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
synchronized(tokenLock) { tokenCache[serverUrl] }?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "POST async $url")
@ -88,7 +99,7 @@ object Network {
.url(url)
.get()
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
synchronized(tokenLock) { tokenCache[serverUrl] }?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "GET $url")

View file

@ -0,0 +1,466 @@
package com.tokilabs.nexo.messaging.helpers
import com.tokilabs.nexo.messaging.PushNotificationEvents
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
import kotlin.math.max
interface PendingNotificationActionStorage {
fun readQueueJson(): String?
fun writeQueueJson(value: String)
}
data class PendingNotificationAction(
val id: String,
val kind: String,
val serverUrl: String,
val endpoint: String,
val body: String,
val attempts: Int,
val nextAttemptAt: Long,
val createdAt: Long,
val lastError: String?,
) {
fun bodyJson(): JSONObject = JSONObject(body)
}
data class PendingNotificationRetryDecision(
val permanentFailure: Boolean,
val action: PendingNotificationAction?,
)
data class PendingNotificationDeliveryResponse(
val statusCode: Int,
val body: String?,
)
fun interface PendingNotificationActionSender {
@Throws(Exception::class)
fun post(action: PendingNotificationAction): PendingNotificationDeliveryResponse
}
object PendingNotificationActionStore {
const val KIND_ACK = "ack"
const val KIND_REPLY = "reply"
const val KIND_DISMISS = "dismiss"
const val RESULT_SUCCESS = "success"
const val RESULT_TRANSIENT = "transient"
const val RESULT_PERMANENT_AUTH = "permanent_auth"
const val RESULT_PERMANENT_SERVER = "permanent_server"
const val REASON_MISSING_AUTH_TOKEN = "missing_auth_token"
const val MAX_ATTEMPTS = 3
private const val QUEUE_KEY = "pendingNotificationActions"
private const val BASE_RETRY_DELAY_MS = 30_000L
@JvmStatic
fun storageFor(dbHelper: DatabaseHelper?): PendingNotificationActionStorage? {
if (dbHelper == null) return null
return object : PendingNotificationActionStorage {
override fun readQueueJson(): String? = dbHelper.getGlobalValue(QUEUE_KEY)
override fun writeQueueJson(value: String) {
dbHelper.saveGlobalValue(QUEUE_KEY, value)
}
}
}
@JvmStatic
fun ackActionId(ackId: String): String = "$KIND_ACK:$ackId"
@JvmStatic
fun replyActionId(): String = "$KIND_REPLY:${UUID.randomUUID()}"
@JvmStatic
fun classifyHttpStatus(statusCode: Int): String = when {
statusCode in 200..299 -> RESULT_SUCCESS
statusCode == 408 || statusCode == 429 || statusCode >= 500 -> RESULT_TRANSIENT
statusCode == 401 || statusCode == 403 -> RESULT_PERMANENT_AUTH
statusCode in 400..499 -> RESULT_PERMANENT_SERVER
else -> RESULT_TRANSIENT
}
@JvmStatic
fun reasonForHttpStatus(statusCode: Int): String = when (classifyHttpStatus(statusCode)) {
RESULT_SUCCESS -> "success"
RESULT_PERMANENT_AUTH -> "auth_rejected_$statusCode"
RESULT_PERMANENT_SERVER -> "server_rejected_$statusCode"
else -> "transient_http_$statusCode"
}
@JvmStatic
fun isPermanent(classification: String): Boolean =
classification == RESULT_PERMANENT_AUTH || classification == RESULT_PERMANENT_SERVER
@JvmStatic
fun isTransient(classification: String): Boolean = classification == RESULT_TRANSIENT
@JvmStatic
fun enqueueAck(
dbHelper: DatabaseHelper?,
ackId: String,
serverUrl: String,
endpoint: String,
body: JSONObject,
lastError: String?,
now: Long,
): PendingNotificationAction? =
enqueue(storageFor(dbHelper), KIND_ACK, ackActionId(ackId), serverUrl, endpoint, body, lastError, now, 1)
@JvmStatic
fun enqueueAck(
storage: PendingNotificationActionStorage?,
ackId: String,
serverUrl: String,
endpoint: String,
body: JSONObject,
lastError: String?,
now: Long,
): PendingNotificationAction? =
enqueue(storage, KIND_ACK, ackActionId(ackId), serverUrl, endpoint, body, lastError, now, 1)
@JvmStatic
fun enqueueReply(
dbHelper: DatabaseHelper?,
serverUrl: String,
endpoint: String,
body: JSONObject,
lastError: String?,
now: Long,
): PendingNotificationAction? =
enqueue(storageFor(dbHelper), KIND_REPLY, replyActionId(), serverUrl, endpoint, body, lastError, now, 1)
@JvmStatic
fun enqueueReply(
storage: PendingNotificationActionStorage?,
serverUrl: String,
endpoint: String,
body: JSONObject,
lastError: String?,
now: Long,
): PendingNotificationAction? =
enqueue(storage, KIND_REPLY, replyActionId(), serverUrl, endpoint, body, lastError, now, 1)
@JvmStatic
@Synchronized
fun enqueue(
storage: PendingNotificationActionStorage?,
kind: String,
id: String,
serverUrl: String,
endpoint: String,
body: JSONObject,
lastError: String?,
now: Long,
attempts: Int,
): PendingNotificationAction? {
if (storage == null) return null
val actions = readActions(storage).toMutableList()
val existingIndex = actions.indexOfFirst { it.id == id }
val existing = actions.getOrNull(existingIndex)
val normalizedAttempts = max(existing?.attempts ?: 0, attempts)
val action = PendingNotificationAction(
id = id,
kind = kind,
serverUrl = serverUrl,
endpoint = endpoint,
body = body.toString(),
attempts = normalizedAttempts,
nextAttemptAt = now + retryDelayMs(normalizedAttempts),
createdAt = existing?.createdAt ?: now,
lastError = lastError,
)
if (existingIndex >= 0) {
actions[existingIndex] = action
} else {
actions.add(action)
}
writeActions(storage, actions)
return action
}
@JvmStatic
@Synchronized
fun dueActions(storage: PendingNotificationActionStorage?, now: Long): List<PendingNotificationAction> {
if (storage == null) return emptyList()
return readActions(storage).filter { it.nextAttemptAt <= now }
}
@JvmStatic
@Synchronized
fun allActions(storage: PendingNotificationActionStorage?): List<PendingNotificationAction> {
if (storage == null) return emptyList()
return readActions(storage)
}
@JvmStatic
@Synchronized
fun recordDeferred(
storage: PendingNotificationActionStorage?,
action: PendingNotificationAction,
reason: String?,
): PendingNotificationAction {
if (storage == null) return action.copy(lastError = reason)
val actions = readActions(storage).toMutableList()
val index = actions.indexOfFirst { it.id == action.id }
val current = actions.getOrNull(index) ?: action
val updated = current.copy(lastError = reason)
if (index >= 0) {
actions[index] = updated
} else {
actions.add(updated)
}
writeActions(storage, actions)
return updated
}
@JvmStatic
@Synchronized
fun recordSuccess(storage: PendingNotificationActionStorage?, actionId: String) {
if (storage == null) return
writeActions(storage, readActions(storage).filterNot { it.id == actionId })
}
@JvmStatic
@Synchronized
fun recordPermanentFailure(
storage: PendingNotificationActionStorage?,
action: PendingNotificationAction,
reason: String?,
): PendingNotificationAction {
if (storage != null) {
writeActions(storage, readActions(storage).filterNot { it.id == action.id })
}
return action.copy(lastError = reason)
}
@JvmStatic
@Synchronized
fun recordTransientFailure(
storage: PendingNotificationActionStorage?,
action: PendingNotificationAction,
reason: String?,
now: Long,
): PendingNotificationRetryDecision {
if (storage == null) {
return PendingNotificationRetryDecision(true, action.copy(lastError = reason))
}
val actions = readActions(storage).toMutableList()
val index = actions.indexOfFirst { it.id == action.id }
val current = actions.getOrNull(index) ?: action
val attempts = current.attempts + 1
val failed = current.copy(attempts = attempts, lastError = reason)
if (attempts >= MAX_ATTEMPTS) {
if (index >= 0) actions.removeAt(index)
writeActions(storage, actions)
return PendingNotificationRetryDecision(true, failed)
}
val updated = failed.copy(nextAttemptAt = now + retryDelayMs(attempts))
if (index >= 0) {
actions[index] = updated
} else {
actions.add(updated)
}
writeActions(storage, actions)
return PendingNotificationRetryDecision(false, updated)
}
@JvmStatic
fun permanentFailureEvent(action: PendingNotificationAction, reason: String?): Map<String, Any?> =
permanentFailureEvent(
kind = action.kind,
serverUrl = action.serverUrl,
endpoint = action.endpoint,
reason = reason ?: action.lastError ?: "unknown",
attempts = action.attempts,
actionId = action.id,
)
@JvmStatic
fun permanentFailureEvent(
kind: String,
serverUrl: String?,
endpoint: String?,
reason: String?,
attempts: Int,
actionId: String?,
): Map<String, Any?> = mapOf(
"type" to "action_failure",
"action" to kind,
"server_url" to serverUrl,
"endpoint" to endpoint,
"reason" to (reason ?: "unknown"),
"attempts" to attempts,
"action_id" to actionId,
"permanent" to true,
)
@JvmStatic
fun sendPermanentFailureEvent(action: PendingNotificationAction, reason: String?) {
PushNotificationEvents.send(permanentFailureEvent(action, reason))
}
@JvmStatic
fun sendPermanentFailureEvent(
kind: String,
serverUrl: String?,
endpoint: String?,
reason: String?,
attempts: Int,
actionId: String?,
) {
PushNotificationEvents.send(permanentFailureEvent(kind, serverUrl, endpoint, reason, attempts, actionId))
}
private fun retryDelayMs(attempts: Int): Long {
val multiplier = when (attempts.coerceAtLeast(1)) {
1 -> 1L
2 -> 2L
else -> 4L
}
return BASE_RETRY_DELAY_MS * multiplier
}
private fun readActions(storage: PendingNotificationActionStorage): List<PendingNotificationAction> {
val raw = storage.readQueueJson()
if (raw.isNullOrBlank()) return emptyList()
return try {
val array = JSONArray(raw)
buildList {
for (i in 0 until array.length()) {
val item = array.getJSONObject(i)
add(
PendingNotificationAction(
id = item.getString("id"),
kind = item.getString("kind"),
serverUrl = item.getString("serverUrl"),
endpoint = item.getString("endpoint"),
body = item.getString("body"),
attempts = item.optInt("attempts", 0),
nextAttemptAt = item.optLong("nextAttemptAt", 0L),
createdAt = item.optLong("createdAt", 0L),
lastError = item.optString("lastError").ifBlank { null },
),
)
}
}
} catch (_: Exception) {
emptyList()
}
}
private fun writeActions(storage: PendingNotificationActionStorage, actions: List<PendingNotificationAction>) {
val array = JSONArray()
actions.forEach { action ->
array.put(
JSONObject()
.put("id", action.id)
.put("kind", action.kind)
.put("serverUrl", action.serverUrl)
.put("endpoint", action.endpoint)
.put("body", action.body)
.put("attempts", action.attempts)
.put("nextAttemptAt", action.nextAttemptAt)
.put("createdAt", action.createdAt)
.put("lastError", action.lastError ?: ""),
)
}
storage.writeQueueJson(array.toString())
}
}
object PendingNotificationActionDispatcher {
private val retryLock = Any()
@JvmStatic
fun retryDueActionsAsync() {
Thread({ retryDueActionsBlocking() }, "NexoPendingActionRetry").start()
}
@JvmStatic
fun retryDueActionsBlocking(): Int {
val storage = PendingNotificationActionStore.storageFor(DatabaseHelper.getInstance()) ?: return 0
return retryDueActions(
storage = storage,
sender = PendingNotificationActionSender { action ->
Network.postSync(action.serverUrl, action.endpoint, action.bodyJson(), null).use { response ->
PendingNotificationDeliveryResponse(response.code, response.body?.string())
}
},
now = System.currentTimeMillis(),
failureReporter = { action, reason ->
PendingNotificationActionStore.sendPermanentFailureEvent(action, reason)
},
hasAuthToken = { serverUrl -> Network.hasToken(serverUrl) },
)
}
internal fun retryDueActions(
storage: PendingNotificationActionStorage,
sender: PendingNotificationActionSender,
now: Long,
failureReporter: (PendingNotificationAction, String) -> Unit,
hasAuthToken: (String) -> Boolean = { serverUrl -> Network.hasToken(serverUrl) },
): Int = synchronized(retryLock) {
retryDueActionsLocked(storage, sender, now, failureReporter, hasAuthToken)
}
private fun retryDueActionsLocked(
storage: PendingNotificationActionStorage,
sender: PendingNotificationActionSender,
now: Long,
failureReporter: (PendingNotificationAction, String) -> Unit,
hasAuthToken: (String) -> Boolean,
): Int {
var processed = 0
val due = PendingNotificationActionStore.dueActions(storage, now)
due.forEach { action ->
if (!hasAuthToken(action.serverUrl)) {
PendingNotificationActionStore.recordDeferred(
storage,
action,
PendingNotificationActionStore.REASON_MISSING_AUTH_TOKEN,
)
return@forEach
}
processed += 1
try {
val response = sender.post(action)
val classification = PendingNotificationActionStore.classifyHttpStatus(response.statusCode)
when {
classification == PendingNotificationActionStore.RESULT_SUCCESS -> {
PendingNotificationActionStore.recordSuccess(storage, action.id)
}
PendingNotificationActionStore.isPermanent(classification) -> {
val reason = PendingNotificationActionStore.reasonForHttpStatus(response.statusCode)
val failed = PendingNotificationActionStore.recordPermanentFailure(storage, action, reason)
failureReporter(failed, reason)
}
else -> {
val reason = PendingNotificationActionStore.reasonForHttpStatus(response.statusCode)
val decision = PendingNotificationActionStore.recordTransientFailure(storage, action, reason, now)
if (decision.permanentFailure && decision.action != null) {
failureReporter(decision.action, reason)
}
}
}
} catch (e: Exception) {
val reason = "transient_exception_${e.javaClass.simpleName}"
val decision = PendingNotificationActionStore.recordTransientFailure(storage, action, reason, now)
if (decision.permanentFailure && decision.action != null) {
failureReporter(decision.action, reason)
}
}
}
return processed
}
}

View file

@ -21,7 +21,10 @@ internal class PayloadGateTest {
isIdLoaded = false,
bundle = Mockito.mock(Bundle::class.java),
verifier = { _, _, _ -> false },
ackSender = { _, _, _, _, _ -> ackCalled = true; null },
ackSender = { _, _, _, _, _ ->
ackCalled = true
ReceiptDelivery.ReceiptDeliveryResult.success(null)
},
)
assertFalse(accepted, "invalid signature must be dropped")
@ -41,7 +44,10 @@ internal class PayloadGateTest {
isIdLoaded = false,
bundle = Mockito.mock(Bundle::class.java),
verifier = { _, _, _ -> true },
ackSender = { _, _, _, _, _ -> ackCalled = true; null },
ackSender = { _, _, _, _, _ ->
ackCalled = true
ReceiptDelivery.ReceiptDeliveryResult.success(null)
},
)
assertTrue(accepted, "valid signature must be accepted")
@ -64,7 +70,10 @@ internal class PayloadGateTest {
isIdLoaded = true,
bundle = currentBundle,
verifier = { _, _, _ -> true },
ackSender = { _, _, _, _, _ -> ackCalled = true; response },
ackSender = { _, _, _, _, _ ->
ackCalled = true
ReceiptDelivery.ReceiptDeliveryResult.success(response)
},
)
assertTrue(accepted, "valid signature must be accepted")
@ -90,7 +99,7 @@ internal class PayloadGateTest {
isIdLoaded = true,
bundle = currentBundle,
verifier = { _, _, _ -> true },
ackSender = { _, _, _, _, _ -> response },
ackSender = { _, _, _, _, _ -> ReceiptDelivery.ReceiptDeliveryResult.success(response) },
)
assertTrue(accepted)
@ -112,7 +121,10 @@ internal class PayloadGateTest {
isIdLoaded = false,
bundle = Mockito.mock(Bundle::class.java),
verifier = { _, _, _ -> true },
ackSender = { _, _, _, _, _ -> ackCalled = true; null },
ackSender = { _, _, _, _, _ ->
ackCalled = true
ReceiptDelivery.ReceiptDeliveryResult.success(null)
},
)
assertTrue(accepted, "valid signature must be accepted")

View file

@ -0,0 +1,175 @@
package com.tokilabs.nexo.messaging
import com.tokilabs.nexo.messaging.helpers.PendingNotificationActionStorage
import com.tokilabs.nexo.messaging.helpers.PendingNotificationActionStore
import org.json.JSONObject
import java.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
private class MemoryReceiptActionStorage : PendingNotificationActionStorage {
var raw: String? = null
override fun readQueueJson(): String? = raw
override fun writeQueueJson(value: String) {
raw = value
}
}
internal class ReceiptDeliveryTest {
@Test
fun send_successOkBodyReturnsNoIdLoadedBundle() {
val storage = MemoryReceiptActionStorage()
val result = ReceiptDelivery.send(
"ack-1",
"https://example.com",
"post-1",
"message",
false,
ReceiptDelivery.AckPostSender { _, endpoint, body ->
assertEquals(ReceiptDelivery.ACK_ENDPOINT, endpoint)
assertEquals("ack-1", body.getString("id"))
ReceiptDelivery.HttpResponse(200, "OK")
},
storage,
1_000L,
)
assertNull(result.responseBundle)
assertFalse(result.queued)
assertFalse(result.permanentFailure)
assertTrue(PendingNotificationActionStore.allActions(storage).isEmpty())
}
@Test
fun send_ioExceptionQueuesAckForRetry() {
val storage = MemoryReceiptActionStorage()
val result = ReceiptDelivery.send(
"ack-1",
"https://example.com",
"post-1",
"message",
false,
ReceiptDelivery.AckPostSender { _, _, _ -> throw IOException("offline") },
storage,
1_000L,
)
assertTrue(result.queued)
assertFalse(result.permanentFailure)
val actions = PendingNotificationActionStore.allActions(storage)
assertEquals(1, actions.size)
assertEquals(PendingNotificationActionStore.KIND_ACK, actions.first().kind)
assertEquals(PendingNotificationActionStore.ackActionId("ack-1"), actions.first().id)
}
@Test
fun send_transientHttpQueuesAckForRetry() {
val storage = MemoryReceiptActionStorage()
val result = ReceiptDelivery.send(
"ack-1",
"https://example.com",
"post-1",
"message",
false,
ReceiptDelivery.AckPostSender { _, _, _ -> ReceiptDelivery.HttpResponse(503, "unavailable") },
storage,
1_000L,
)
assertTrue(result.queued)
assertEquals("transient_http_503", result.failureReason)
assertEquals(1, PendingNotificationActionStore.allActions(storage).size)
}
@Test
fun send_authFailureIsPermanentAndDoesNotQueue() {
val storage = MemoryReceiptActionStorage()
val result = ReceiptDelivery.send(
"ack-1",
"https://example.com",
"post-1",
"message",
false,
ReceiptDelivery.AckPostSender { _, _, _ -> ReceiptDelivery.HttpResponse(401, "unauthorized") },
storage,
1_000L,
ReceiptDelivery.FailureReporter { _, _, _, _, _, _ -> },
)
assertTrue(result.permanentFailure)
assertFalse(result.queued)
assertEquals("auth_rejected_401", result.failureReason)
assertTrue(PendingNotificationActionStore.allActions(storage).isEmpty())
}
@Test
fun send_missingAuthTokenQueuesWithoutPosting() {
val storage = MemoryReceiptActionStorage()
var senderCalled = false
val result = ReceiptDelivery.send(
"ack-1",
"https://example.com",
"post-1",
"message",
false,
ReceiptDelivery.AckPostSender { _, _, _ ->
senderCalled = true
ReceiptDelivery.HttpResponse(204, null)
},
storage,
1_000L,
ReceiptDelivery.FailureReporter { _, _, _, _, _, _ -> },
ReceiptDelivery.TokenAvailabilityChecker { false },
)
assertTrue(result.queued)
assertFalse(senderCalled)
assertEquals(PendingNotificationActionStore.REASON_MISSING_AUTH_TOKEN, result.failureReason)
val actions = PendingNotificationActionStore.allActions(storage)
assertEquals(1, actions.size)
assertEquals(1, actions.first().attempts)
assertEquals(PendingNotificationActionStore.REASON_MISSING_AUTH_TOKEN, actions.first().lastError)
}
@Test
fun buildAckBodyUsesReceiptTimeAndIdLoadedFlag() {
val body: JSONObject = ReceiptDelivery.buildAckBody("ack-1", "post-1", "message", true, 1_234L)
assertEquals("ack-1", body.getString("id"))
assertEquals(1_234L, body.getLong("received_at"))
assertEquals("android", body.getString("platform"))
assertEquals("message", body.getString("type"))
assertEquals("post-1", body.getString("post_id"))
assertTrue(body.getBoolean("is_id_loaded"))
}
@Test
fun replyTransientQueueHelperStoresReplyAction() {
val storage = MemoryReceiptActionStorage()
val body = NotificationReplyBroadcastReceiver.buildReplyBody("channel-1", "post-1", "post-1", "hello")
val action = NotificationReplyBroadcastReceiver.queueReplyForTransientFailure(
storage,
"https://example.com",
body,
"transient_http_503",
1_000L,
)
assertNotNull(action)
assertEquals(PendingNotificationActionStore.KIND_REPLY, action.kind)
assertEquals(NotificationReplyBroadcastReceiver.REPLY_ENDPOINT, action.endpoint)
assertEquals("hello", action.bodyJson().getString("message"))
}
}

View file

@ -0,0 +1,259 @@
package com.tokilabs.nexo.messaging.helpers
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
private class MemoryPendingActionStorage(initial: String? = null) : PendingNotificationActionStorage {
var raw: String? = initial
override fun readQueueJson(): String? = raw
override fun writeQueueJson(value: String) {
raw = value
}
}
internal class PendingNotificationActionStoreTest {
@Test
fun enqueueAckStoresActionAndSelectsWhenDue() {
val storage = MemoryPendingActionStorage()
val body = JSONObject().put("id", "ack-1")
val action = PendingNotificationActionStore.enqueueAck(
storage = storage,
ackId = "ack-1",
serverUrl = "https://example.com",
endpoint = "api/v4/notifications/ack",
body = body,
lastError = "transient_http_503",
now = 1_000L,
)
assertNotNull(action)
assertEquals(PendingNotificationActionStore.KIND_ACK, action.kind)
assertEquals(1, action.attempts)
assertTrue(PendingNotificationActionStore.dueActions(storage, 30_999L).isEmpty())
assertEquals(listOf(action), PendingNotificationActionStore.dueActions(storage, 31_000L))
}
@Test
fun malformedQueueIsResetOnNextWrite() {
val storage = MemoryPendingActionStorage("not-json")
PendingNotificationActionStore.enqueueReply(
storage = storage,
serverUrl = "https://example.com",
endpoint = "/api/v4/posts?set_online=false",
body = JSONObject().put("message", "queued"),
lastError = "transient_exception_IOException",
now = 1_000L,
)
val actions = PendingNotificationActionStore.allActions(storage)
assertEquals(1, actions.size)
assertEquals(PendingNotificationActionStore.KIND_REPLY, actions.first().kind)
}
@Test
fun transientFailureAtMaxAttemptsBecomesPermanentAndRemovesAction() {
val storage = MemoryPendingActionStorage()
val action = PendingNotificationActionStore.enqueue(
storage = storage,
kind = PendingNotificationActionStore.KIND_ACK,
id = "ack:ack-1",
serverUrl = "https://example.com",
endpoint = "api/v4/notifications/ack",
body = JSONObject().put("id", "ack-1"),
lastError = "transient_http_503",
now = 1_000L,
attempts = 2,
)
val decision = PendingNotificationActionStore.recordTransientFailure(
storage = storage,
action = action!!,
reason = "transient_http_503",
now = 31_000L,
)
assertTrue(decision.permanentFailure)
assertEquals(3, decision.action?.attempts)
assertTrue(PendingNotificationActionStore.allActions(storage).isEmpty())
}
@Test
fun httpStatusClassificationSeparatesTransientAndPermanentFailures() {
assertEquals(PendingNotificationActionStore.RESULT_SUCCESS, PendingNotificationActionStore.classifyHttpStatus(204))
assertEquals(PendingNotificationActionStore.RESULT_PERMANENT_AUTH, PendingNotificationActionStore.classifyHttpStatus(401))
assertEquals(PendingNotificationActionStore.RESULT_PERMANENT_AUTH, PendingNotificationActionStore.classifyHttpStatus(403))
assertEquals(PendingNotificationActionStore.RESULT_PERMANENT_SERVER, PendingNotificationActionStore.classifyHttpStatus(404))
assertEquals(PendingNotificationActionStore.RESULT_TRANSIENT, PendingNotificationActionStore.classifyHttpStatus(408))
assertEquals(PendingNotificationActionStore.RESULT_TRANSIENT, PendingNotificationActionStore.classifyHttpStatus(429))
assertEquals(PendingNotificationActionStore.RESULT_TRANSIENT, PendingNotificationActionStore.classifyHttpStatus(503))
}
@Test
fun dispatcherRemovesActionAfterSuccessfulRetry() {
val storage = MemoryPendingActionStorage()
val action = PendingNotificationActionStore.enqueueAck(
storage = storage,
ackId = "ack-1",
serverUrl = "https://example.com",
endpoint = "api/v4/notifications/ack",
body = JSONObject().put("id", "ack-1"),
lastError = "transient_http_503",
now = 1_000L,
)
val processed = PendingNotificationActionDispatcher.retryDueActions(
storage = storage,
sender = PendingNotificationActionSender {
assertEquals(action!!.id, it.id)
PendingNotificationDeliveryResponse(204, null)
},
now = 31_000L,
failureReporter = { _, _ -> error("no permanent failure expected") },
hasAuthToken = { true },
)
assertEquals(1, processed)
assertTrue(PendingNotificationActionStore.allActions(storage).isEmpty())
}
@Test
fun dispatcherDefersDueActionWithoutAuthToken() {
val storage = MemoryPendingActionStorage()
PendingNotificationActionStore.enqueueAck(
storage = storage,
ackId = "ack-1",
serverUrl = "https://example.com",
endpoint = "api/v4/notifications/ack",
body = JSONObject().put("id", "ack-1"),
lastError = "transient_http_503",
now = 1_000L,
)
var senderCalls = 0
var failureCalls = 0
val processed = PendingNotificationActionDispatcher.retryDueActions(
storage = storage,
sender = PendingNotificationActionSender {
senderCalls += 1
PendingNotificationDeliveryResponse(204, null)
},
now = 31_000L,
failureReporter = { _, _ -> failureCalls += 1 },
hasAuthToken = { false },
)
val actions = PendingNotificationActionStore.allActions(storage)
assertEquals(0, processed)
assertEquals(0, senderCalls)
assertEquals(0, failureCalls)
assertEquals(1, actions.size)
assertEquals(1, actions.first().attempts)
assertEquals(PendingNotificationActionStore.REASON_MISSING_AUTH_TOKEN, actions.first().lastError)
}
@Test
fun dispatcherSendsDeferredActionAfterAuthTokenIsAvailable() {
val storage = MemoryPendingActionStorage()
PendingNotificationActionStore.enqueueAck(
storage = storage,
ackId = "ack-1",
serverUrl = "https://example.com",
endpoint = "api/v4/notifications/ack",
body = JSONObject().put("id", "ack-1"),
lastError = PendingNotificationActionStore.REASON_MISSING_AUTH_TOKEN,
now = 1_000L,
)
val processed = PendingNotificationActionDispatcher.retryDueActions(
storage = storage,
sender = PendingNotificationActionSender { PendingNotificationDeliveryResponse(204, null) },
now = 31_000L,
failureReporter = { _, _ -> error("no permanent failure expected") },
hasAuthToken = { true },
)
assertEquals(1, processed)
assertTrue(PendingNotificationActionStore.allActions(storage).isEmpty())
}
@Test
fun dispatcherSingleFlightPreventsDuplicateReplyPost() {
val storage = MemoryPendingActionStorage()
PendingNotificationActionStore.enqueueReply(
storage = storage,
serverUrl = "https://example.com",
endpoint = "/api/v4/posts?set_online=false",
body = JSONObject().put("message", "hello"),
lastError = "transient_http_503",
now = 1_000L,
)
val senderCalls = AtomicInteger(0)
val senderEntered = CountDownLatch(1)
val releaseSender = CountDownLatch(1)
val sender = PendingNotificationActionSender {
senderCalls.incrementAndGet()
senderEntered.countDown()
assertTrue(releaseSender.await(5, TimeUnit.SECONDS))
PendingNotificationDeliveryResponse(204, null)
}
val first = Thread {
PendingNotificationActionDispatcher.retryDueActions(
storage = storage,
sender = sender,
now = 31_000L,
failureReporter = { _, _ -> error("no permanent failure expected") },
hasAuthToken = { true },
)
}
val second = Thread {
PendingNotificationActionDispatcher.retryDueActions(
storage = storage,
sender = sender,
now = 31_000L,
failureReporter = { _, _ -> error("no permanent failure expected") },
hasAuthToken = { true },
)
}
first.start()
assertTrue(senderEntered.await(5, TimeUnit.SECONDS))
second.start()
releaseSender.countDown()
first.join(5_000L)
second.join(5_000L)
assertFalse(first.isAlive)
assertFalse(second.isAlive)
assertEquals(1, senderCalls.get())
assertTrue(PendingNotificationActionStore.allActions(storage).isEmpty())
}
@Test
fun permanentFailureEventDoesNotIncludeRequestBody() {
val event = PendingNotificationActionStore.permanentFailureEvent(
kind = PendingNotificationActionStore.KIND_REPLY,
serverUrl = "https://example.com",
endpoint = "/api/v4/posts?set_online=false",
reason = "server_rejected_400",
attempts = 1,
actionId = "reply:1",
)
assertEquals("action_failure", event["type"])
assertEquals(PendingNotificationActionStore.KIND_REPLY, event["action"])
assertEquals("server_rejected_400", event["reason"])
assertFalse(event.containsKey("body"))
}
}