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:
parent
5e4089c629
commit
8ce6b1292a
22 changed files with 2181 additions and 224 deletions
|
|
@ -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를 생성한다.
|
||||
|
|
@ -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로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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로 기록한다.
|
||||
|
|
@ -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로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -92,4 +92,5 @@ dependencies {
|
|||
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||
testImplementation("org.json:json:20240303")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue