feat: client integration and control-plane phase updates

- Update control-plane and edge domain rules
- Update roadmap phase and current status
- Archive client-integration-clone-adoption and nexo-notification-message-smoke-alignment milestones
- Update control-plane-client milestone
- Add client assets, lib/src, integrations tests
- Add google-services.json for client android
- Update client pubspec, build configs, and platform manifests
This commit is contained in:
toki 2026-05-30 18:02:06 +09:00
parent 7b9345bc1a
commit d7324fe184
28 changed files with 944 additions and 119 deletions

View file

@ -1,7 +1,7 @@
---
domain: control-plane
last_rule_review_commit: 6e1c201d0b82661465238821edb136444eacb9d0
last_rule_updated_at: 2026-05-28
last_rule_review_commit: 7b9345bc1af3d7c449e07b6fc9627d984c80cebf
last_rule_updated_at: 2026-05-30
---
# control-plane
@ -39,6 +39,8 @@ last_rule_updated_at: 2026-05-28
## 유지할 패턴
- Control Plane은 Edge를 통해 시스템을 관찰/제어한다. Node를 직접 등록하거나 직접 스케줄링하는 active contract를 만들지 않는다.
- Control Plane은 상시 운영, multi-edge 관찰/명령, 정책/감사 UX의 기본 운영면으로 확장한다. Edge-local bootstrap, config, 진단, 단일 Edge 유지보수 fallback을 대체하거나 제거하는 전제로 설계하지 않는다.
- Control Plane-Edge wire protocol은 Edge가 소유한 operation contract를 원격 운영면으로 소비하는 형태로 설계한다. Edge CLI와 같은 행위를 노출해야 하면 Edge 쪽 표면 중립 service/operation boundary로 수렴시킨다.
- `net/http`는 health/readiness/bootstrap 같은 보조 endpoint에 우선 사용하고, IOP 고유 운영 통신은 proto-socket 기반 wire protocol로 확장한다.
- 브라우저/앱 경계는 WebSocket/WSS proto-socket을 사용할 수 있다. Edge-Node TCP transport 기준을 이 경계에 억지로 적용하지 않는다.
- `ScheduleRequest`/`ScheduleResponse`는 legacy placeholder로만 취급하고, 새 orchestration 계약은 Edge-owned runtime state를 우회하지 않도록 다시 설계한다.
@ -47,7 +49,7 @@ last_rule_updated_at: 2026-05-28
## 다른 도메인과의 경계
- **edge**: Edge는 로컬 실행 그룹 상태와 Node registry를 소유한다. Control Plane은 Edge를 통해 상태 조회, 설정 변경, 명령 전달, 이벤트 수신을 확장한다.
- **edge**: Edge는 로컬 실행 그룹 상태와 Node registry를 소유한다. Control Plane은 Edge를 통해 상태 조회, 설정 변경, 명령 전달, 이벤트 수신을 확장한다. Control Plane 없는 bootstrap/local/field/진단 fallback은 `iop-edge` command 표면에 남긴다.
- **platform-common**: protobuf 원본, 생성물, 공통 설정/관측성 helper는 platform-common 계약을 따른다.
- **testing**: Control Plane command, wire endpoint, bootstrap/enrollment 사용자 흐름을 바꾸면 testing domain rule의 검증 기준을 따른다.
@ -55,5 +57,6 @@ last_rule_updated_at: 2026-05-28
- Control Plane에서 Node를 직접 연결하거나 직접 스케줄링하는 경로를 제품 기본 계약으로 만들지 않는다.
- Edge-local registry, run dispatch, stream relay 구현을 Control Plane으로 복제하지 않는다.
- Control Plane 도입만을 이유로 Edge-local CLI bootstrap/config/진단 fallback을 제품 기본 계약에서 제외하거나 제거하지 않는다. 축소는 별도 roadmap 결정과 대체 fallback 기준이 있을 때만 다룬다.
- gRPC를 Control Plane 기본 wire protocol로 도입하지 않는다.
- 실제 DB/Redis credential이나 환경별 endpoint를 tracked docs/rules에 기록하지 않는다.

View file

@ -1,7 +1,7 @@
---
domain: edge
last_rule_review_commit: 6e1c201d0b82661465238821edb136444eacb9d0
last_rule_updated_at: 2026-05-28
last_rule_review_commit: 7b9345bc1af3d7c449e07b6fc9627d984c80cebf
last_rule_updated_at: 2026-05-30
---
# edge
@ -62,6 +62,7 @@ last_rule_updated_at: 2026-05-28
- edge는 사전 등록된 node 정의를 검증하고 연결 상태를 registry에 반영하는 역할을 먼저 안정화한다.
- edge는 단순 gateway가 아니라 Node registry, adapter/profile configuration, routing, stream relay의 소유자다.
- 공식 local/field 운영 UX는 `iop-edge` command 표면에 모은다. repo helper script나 README 절차를 공식 사용자 경로로 승격하지 않는다.
- `iop-edge` command 표면은 bootstrap, bundle-local config, env/config 진단, node 등록 command 발급, smoke, Control Plane 없는 단일 Edge 유지보수 범위로 유지한다. 후속 specialized agent enrollment command도 같은 local/field fallback 원칙을 따른다. 상시 multi-edge 운영 UI/API 역할을 CLI에 확대하지 않는다.
- `config init`은 bundle-local `edge.yaml` template을 만들고, `env`는 서버를 기동하지 않고 effective environment를 설명해야 한다.
- `node register`는 사용자에게 `curl -fsSL <bootstrap-url> | bash -s <token>` 한 줄을 출력하는 경로를 유지한다. 사용자가 기본 경로에서 `node.yaml`을 직접 만들거나 `iop-node serve --config ...`를 직접 실행하게 하지 않는다.
- 외부 OpenAI-compatible HTTP API는 입력 표면일 뿐이며, 내부 실행 모델 전체를 대표하지 않는다.
@ -76,6 +77,7 @@ last_rule_updated_at: 2026-05-28
- OpenAI/Ollama passthrough성 옵션은 입력 표면에서 명시적으로 변환하고, node adapter의 Ollama 실행 계약을 우회하지 않는다.
- run/node event fanout은 `events.Bus`를 통해 수행하고, transport handler에서 console/HTTP 표면으로 직접 출력하거나 응답하지 않는다.
- 입력 서버 lifecycle은 `input.Manager`가 소유하며, bootstrap runtime이 transport server와 함께 시작/종료한다.
- CLI, ops console, HTTP/A2A 입력 표면, 후속 Control Plane wire handler가 같은 Edge operation을 노출할 때는 `service.Service` 같은 표면 중립 application service로 수렴시킨다. registry 변경, run dispatch, command request 로직을 표면별로 복제하지 않는다.
- `apps/edge/cmd/edge/**`, `apps/edge/internal/bootstrap/**`, `apps/edge/internal/transport/**`, `apps/edge/internal/service/**`, `apps/edge/internal/events/**`, `apps/edge/internal/input/**`, `apps/edge/internal/openai/**`, `apps/edge/internal/opsconsole/**`, `apps/edge/internal/node/**`, console/HTTP 입출력, run/node event relay를 바꾼 뒤에는 `testing` domain rule의 작업 후 검증 기준을 따른다.
## 다른 도메인과의 경계
@ -83,7 +85,7 @@ last_rule_updated_at: 2026-05-28
- **node**: edge는 node 내부 adapter를 직접 실행하지 않는다. edge는 사전 등록 정보와 연결 registry를 기반으로 요청을 보낼 대상과 실행 설정을 관리하고, TCP/protobuf로 `RunRequest`/`CancelRequest`/`NodeCommandRequest`를 보낸다.
- **platform-common**: edge 설정, metrics, protobuf 타입은 platform-common 계약을 따른다.
- **external input surfaces**: OpenAI-compatible HTTP와 A2A JSON-RPC는 edge inbound adapter이며, 내부 transport/protobuf 경계를 대체하지 않는다.
- **control-plane**: control-plane은 Edge를 통해 시스템을 제어한다. Node 직접 연결/직접 스케줄링을 control-plane 책임으로 굳히지 않는다.
- **control-plane**: control-plane은 Edge를 통해 시스템을 제어한다. 상시 운영, multi-edge 관찰, 정책/감사 UX는 control-plane으로 확장하되, Control Plane 없는 bootstrap/local/field/진단 fallback은 `iop-edge` command 표면에 남긴다. Node 직접 연결/직접 스케줄링을 control-plane 책임으로 굳히지 않는다.
## 금지 사항
@ -91,6 +93,7 @@ last_rule_updated_at: 2026-05-28
- OpenAI-compatible HTTP API를 추가할 때 내부 TCP/protobuf 경계를 우회하지 않는다.
- A2A/OpenAI/console 입력 표면에서 registry client에 직접 `RunRequest`를 조립·전송하지 않는다. 공유 로직은 `service.Service`에 둔다.
- Control Plane 구현 전이라도 Node를 Control Plane에 직접 연결하는 경로를 edge 도메인에 추가하지 않는다.
- Control Plane 도입만을 이유로 `iop-edge config`, `env`, `node register`, `nodes list`, `smoke`, `setup` 같은 local/field fallback command 경로를 제거하거나 제품 기본 계약에서 제외하지 않는다. 축소는 별도 roadmap 결정과 대체 fallback 기준이 있을 때만 다룬다.
- `node register`와 bootstrap UX에 named environment parameter 조합을 기본 사용자 경로로 노출하지 않는다.
- gRPC 또는 WebSocket을 기본 내부 transport로 바꾸지 않는다.
- `proto/gen/iop/*.pb.go` 생성 파일을 직접 수정하지 않는다.

View file

@ -11,7 +11,7 @@ NomadCode의 `Client Integration Standardization` 결과를 IOP Flutter client
## 상태
[계획]
[폐기]
## 구현 잠금
@ -19,7 +19,7 @@ NomadCode의 `Client Integration Standardization` 결과를 IOP Flutter client
- 결정 필요: 아래 체크리스트
- [ ] NomadCode client 표준화 산출물을 받은 뒤 IOP client에 복제할 파일/구조/설정 범위를 확정한다.
- [x] 이전 Flutter 앱 경로는 `apps/client`로 전환하고 호환 경로는 두지 않는다.
- [ ] Nexo 알림/메시지 Smoke 정합성 Milestone에서 valid signature 또는 scope decision과 IOP consumer 설정 차이가 정리됐는지 확인한다.
- [x] Nexo 알림/메시지 Smoke 정합성 Milestone에서 valid signature 또는 scope decision과 IOP consumer 설정 차이가 정리됐는지 확인한다.
- [x] IOP client의 Mattermost push routing은 내부 화면, 외부 링크, 운영 이벤트를 모두 지원한다. 세부 구현은 notification type/context별 primary route, fallback route, audit/ops event 기록 정책으로 정리한다.
## 범위
@ -60,13 +60,13 @@ NomadCode 표준과 IOP 차이를 추적 가능하게 남긴다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 상태: 폐기
- 요청일: 2026-05-30
- 완료 근거: 사용자가 NomadCode Flutter client 복제 작업은 별도 프로젝트에서 진행 예정이므로 IOP roadmap의 해당 Milestone을 제거하라고 지시했다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
- [x] 사용자가 폐기 방향을 지시했다
- [x] archive 이동을 승인했다
- 리뷰 코멘트: IOP active roadmap에서는 이 Milestone을 더 이상 구현 후보로 두지 않는다.
## 범위 제외

View file

@ -0,0 +1,85 @@
# Milestone: Nexo 알림/메시지 Smoke 정합성
## 위치
- Roadmap: `agent-roadmap/ROADMAP.md`
- Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
## 목표
`../nexo`의 메시징/푸시 알림 검증 상태를 IOP client integration의 선행 게이트로 정리한다. `../nomadcode`에서 `nexo_messaging`을 실제 소비 앱에 적용하고 server-generated signed push smoke까지 완료한 결과를 기준으로, IOP `apps/client`가 같은 Flutter integration skeleton을 복제하기 위해 필요한 설정 차이를 확정한다.
## 상태
[완료]
## 구현 잠금
- 상태: 해제
- 결정 필요: 아래 체크리스트
- [x] Nexo에서 valid signed push trigger를 제공해 notification display/opened/reply/dismiss까지 검증하는 방향으로 진행한다.
- [x] IOP client의 Mattermost push routing은 내부 화면, 외부 링크, 운영 이벤트를 모두 지원한다. 남은 작업은 notification type/context별 기본 라우팅, fallback, 이벤트 기록 정책을 정의하는 것이다.
## 범위
- `../nomadcode`의 Mattermost server-generated signed push smoke evidence와 `../nexo/packages/messaging_flutter` host responsibility 확인
- IOP `apps/client``nexo_messaging`, Firebase, Android application id, Google Services plugin, platform guard, plugin initialization 설정 차이 정리
- IOP client의 device token, auth token, signing key, notification opened routing callback 책임 경계 정리
- `Client Integration Clone Adoption`을 시작하기 전 필요한 선행 게이트와 후속 분리 항목 문서화
## 기능
### Epic: [nexo-smoke] Nexo smoke gate
Nexo 쪽 알림/메시지 smoke 상태를 IOP client adoption의 선행 조건으로 분류한다.
- [x] [handoff-status] `../nomadcode/agent-test/local/mattermost-server-generated-push-smoke.md` 기준으로 server-generated signed push smoke의 PASS/N/A 항목을 최신 상태로 확인한다. 검증: 2026-05-30 remote Android emulator에서 FCM receipt, valid signature, notification display, ACK, opened event, channel navigation, inline reply, dismiss, device token prefix PASS를 확인했다.
- [x] [signed-scope] valid signed push trigger를 확보해 notification display, opened routing, inline reply, dismiss까지 검증한 결과를 남긴다. 검증: positive path는 Mattermost REST `POST /api/v4/posts` server-generated DM이며 placeholder signature를 쓰지 않았다.
### Epic: [iop-consumer] IOP consumer 설정 정합성
IOP `apps/client`가 Nexo messaging plugin의 host app responsibility를 충족하는지 확인하고 부족한 설정을 adoption 작업 입력으로 만든다.
- [x] [dependency-gap] IOP `apps/client/pubspec.yaml`과 Android 설정에서 `nexo_messaging`, `firebase_core`, Google Services plugin, Firebase config, application id 차이를 정리한다. 검증: 현재 IOP client에는 해당 의존성과 Firebase Android wiring이 없으므로 clone adoption의 첫 변경 범위로 둔다.
- [x] [bootstrap-gap] Web + Android bootstrap에서 Firebase 초기화, `NexoMessagingPlugin.instance.initialize()`, platform guard, deterministic test escape hatch 필요 여부를 정리한다. 검증: Android는 NomadCode bootstrap 패턴을 이식하고, Web/desktop은 native push runtime을 no-op 또는 guard 처리한다.
- [x] [credential-boundary] device token, auth token, signing key 저장과 전달 책임을 IOP Control Plane/client 경계에 맞춰 정의한다. 검증: host는 auth/signing key/device token handoff만 소유하고 native FCM/ACK/reply/dismiss는 plugin에 둔다.
- [x] [routing-boundary] notification opened event가 내부 화면, 외부 링크, 운영 이벤트를 모두 사용할 수 있도록 notification type/context별 기본 라우팅, fallback, 이벤트 기록 정책을 정리한다. 검증: primary internal route, external link fallback, ops event recording을 adoption 기본 정책으로 둔다.
### Epic: [adoption-gate] Clone adoption handoff
기존 `Client Integration Clone Adoption` Milestone이 시작할 수 있는 입력과 남은 블로커를 추적 가능하게 만든다.
- [x] [clone-input] `Client Integration Clone Adoption`에서 가져갈 Nexo messaging skeleton, IOP 치환 항목, 후속 분리 항목을 checklist로 남긴다. 검증: 복제 대상은 `../nomadcode/apps/client/lib/src/integrations/mattermost/*`, bootstrap pattern, tests, Android Firebase/Google Services wiring이며 NomadCode 제품 화면은 제외한다.
- [x] [lock-sync] 이 Milestone과 `../nexo` `client-validation`의 외부 의존 잠금 상태가 workspace lock에 반영되어 있는지 확인한다. 검증: `../.agent-roadmap-sync/locks.yaml``iop:nexo-notification-message-smoke-alignment` rely-on 상태가 `enable`이다.
## 완료 리뷰
- 상태: 승인됨
- 요청일: 2026-05-30
- 승인일: 2026-05-30
- 완료 근거: `../nomadcode`에서 `nexo_messaging` 적용 후 server-generated signed push smoke의 주요 runtime evidence가 PASS로 정리되었다.
- 완료 근거: IOP client의 dependency/bootstrap/credential/routing gap을 clone adoption 입력으로 분리했다.
- 완료 근거: workspace lock의 선행 의존 상태가 `enable`로 확인되었다.
- 리뷰 필요:
- [x] 사용자가 완료 결과를 확인했다
- [x] archive 이동을 승인했다
- 리뷰 코멘트: 사용자가 완료 및 archive 이동을 승인했다. 복제 시작 가능 후보다. 단, IOP 앱 자체의 Firebase config, application id, routing adapter 구현과 Android runtime smoke는 `Client Integration Clone Adoption`에서 별도로 검증한다.
## 범위 제외
- Nexo plugin runtime 대규모 재작성
- IOP client의 전체 제품 UI 재설계
- iOS/macOS native push 구현
- Control Plane-Edge wire protocol 세부 확정
- raw FCM token, auth token, signing key, private endpoint 값을 tracked 문서에 기록하는 일
## 작업 컨텍스트
- 관련 경로: `apps/client/`, `apps/client/android/`, `../nomadcode/apps/client/`, `../nomadcode/agent-test/local/mattermost-server-generated-push-smoke.md`, `../nexo/packages/messaging_flutter/`, `../nexo/apps/flutter-test/`
- 표준선(선택): Nexo `nexo_messaging`은 Android-first plugin runtime이고 host app은 Firebase config, plugin initialization, auth/signing key 저장, device token 전달, opened routing callback만 소유한다.
- 표준선(선택): IOP Web target은 messaging native runtime을 직접 실행하지 않고 platform guard와 no-op 또는 후속 확장 경계를 명확히 둔다.
- 표준선(선택): Mattermost push routing은 내부 화면, 외부 링크, 운영 이벤트를 배타적으로 고르지 않고 모두 지원한다. 구현에서는 type/context별 primary route, fallback route, audit/ops event 기록 여부를 정책으로 분리한다.
- 외부 의존 잠금: `nexo:agent-roadmap/phase/product-foundation/milestones/client-validation.md`
- 선행 작업: NomadCode의 `nexo_messaging` 적용과 server-generated signed push 기반 full notification smoke
- 후속 작업: `Client Integration Clone Adoption`
- 확인 필요: 사용자 완료 확인 후 archive 이동 여부를 결정한다. 구현 후속은 `Client Integration Clone Adoption`에서 IOP target 값으로 검증한다.

View file

@ -7,9 +7,9 @@
## 활성 Milestone
- [진행중] Nexo 알림/메시지 Smoke 정합성
- [계획] Control Plane과 Client
- Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md`
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md`
## 선택 규칙

View file

@ -10,7 +10,7 @@
## Milestone 흐름
완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다.
완료 또는 폐기된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다.
완료, 검토중, 진행중, 계획 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
- [완료] Flutter-first Client 마이그레이션
@ -21,13 +21,13 @@
- 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md`
- 요약: Control Plane이 없는 동안 `edge.yaml`을 테스트용 source of truth로 삼고, config 템플릿 생성, effective environment 출력, Node register/bootstrap 출력, setup, smoke test를 `iop-edge` command 중심으로 모은다.
- [진행중] Nexo 알림/메시지 Smoke 정합성
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md`
- 요약: `../nexo` client-validation의 FCM ACK smoke와 IOP `apps/client`의 Nexo messaging 소비 설정 차이를 선행 게이트로 정리한다.
- [완료] Nexo 알림/메시지 Smoke 정합성
- 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md`
- 요약: `../nomadcode`의 server-generated signed push smoke evidence와 IOP `apps/client`의 Nexo messaging 소비 설정 차이를 선행 게이트로 정리하고 archive했다.
- [계획] Client Integration Clone Adoption
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md`
- 요약: Nexo 알림/메시지 smoke 정합성 확인 뒤 NomadCode client 표준을 IOP client에 복제하기 위한 모듈명, bootstrap, proto-socket, Mattermost push host integration 기준을 정리한다.
- [폐기] Client Integration Clone Adoption
- 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md`
- 요약: NomadCode Flutter client clone adoption은 별도 프로젝트에서 진행하기로 하여 IOP active roadmap에서 제외했다.
- [계획] Control Plane과 Client
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md`
@ -46,5 +46,4 @@
- Control Plane은 Node를 직접 스케줄링하지 않고 Edge를 통해 관찰/제어한다.
- Flutter client는 IOP 자체 운영 UI/UX이며 NomadCode 전용 shell로 만들지 않는다.
- Multi-Edge 운영은 Edge의 로컬 상태 소유권을 침범하지 않는 범위에서 확장한다.
- `../nomadcode`에서 정리한 client 표준을 복제할 때는 제품 feature 전체가 아니라 client skeleton, integration boundary, bootstrap convention, 문서화된 handoff 기준만 가져온다.
- `../nexo`의 messaging/push smoke가 valid signature 또는 scope decision까지 정리되기 전에는 Mattermost push host integration을 IOP client에 확정 적용하지 않는다.
- NomadCode Flutter client 복제/표준화 작업은 별도 프로젝트 범위로 분리하고, IOP Phase에서는 필요한 경우 확정 산출물만 후속 Milestone으로 반영한다.

View file

@ -21,7 +21,6 @@ Client는 NomadCode 전용 화면이 아니라 IOP가 독립적으로 운영되
- [ ] Client의 첫 운영 화면 범위를 Edge/Node 상태 조회 중심으로 둘지, 명령/설정 변경까지 포함할지 결정한다.
- [ ] Control Plane-Edge 연결에서 우선 구현할 IOP Wire Protocol 범위를 결정한다.
- [ ] IOP client가 범용 운영 UI로 남기 위해 제외할 NomadCode 전용 UX 경계를 결정한다.
- [ ] Client Integration Clone Adoption 완료 전 Flutter client 화면 구현을 어디까지 진행할지 결정한다.
## 범위
@ -74,6 +73,6 @@ Client는 NomadCode 전용 화면이 아니라 IOP가 독립적으로 운영되
- 관련 경로: `apps/control-plane`, `apps/client`, `apps/control-plane/README.md`, `apps/client/README.md`, `agent-test/local/control-plane-smoke.md`, `docker-compose.yml`, `scripts/dev/web.sh`
- 표준선(선택): Control Plane은 Node를 직접 스케줄링하지 않고 Edge를 통해 관찰/제어한다.
- 표준선(선택): Client-Control Plane의 첫 wire surface는 `/client` WebSocket hello handshake를 기준으로 둔다.
- 선행 작업: Flutter-first Client 마이그레이션, Client Integration Clone Adoption, Agent Bootstrap과 OTO 등록, 정책/이력/감사 일부 기준
- 선행 작업: Flutter-first Client 마이그레이션, Agent Bootstrap과 OTO 등록, 정책/이력/감사 일부 기준
- 후속 작업: Multi-Edge 운영
- 확인 필요: Client 첫 화면 범위, Control Plane-Edge wire 우선순위, NomadCode 전용 UX 제외 기준, client 표준 clone 반영 범위
- 확인 필요: Client 첫 화면 범위, Control Plane-Edge wire 우선순위, NomadCode 전용 UX 제외 기준

View file

@ -1,82 +0,0 @@
# Milestone: Nexo 알림/메시지 Smoke 정합성
## 위치
- Roadmap: `agent-roadmap/ROADMAP.md`
- Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
## 목표
`../nexo`의 메시징/푸시 알림 검증 상태를 IOP client integration의 선행 게이트로 정리한다. Nexo `client-validation`의 FCM ACK smoke 결과와 남은 valid signed payload 결정을 확인하고, IOP `apps/client``nexo_messaging` 소비 앱 조건을 만족하기 위해 필요한 설정 차이를 확정한다.
## 상태
[진행중]
## 구현 잠금
- 상태: 잠금
- 결정 필요: 아래 체크리스트
- [x] Nexo에서 valid signed push trigger를 제공해 notification display/opened/reply/dismiss까지 검증하는 방향으로 진행한다.
- [x] IOP client의 Mattermost push routing은 내부 화면, 외부 링크, 운영 이벤트를 모두 지원한다. 남은 작업은 notification type/context별 기본 라우팅, fallback, 이벤트 기록 정책을 정의하는 것이다.
## 범위
- `../nexo``client-validation` Milestone, `04+03_fcm_ack_smoke` handoff, `packages/messaging_flutter` host responsibility 확인
- IOP `apps/client``nexo_messaging`, Firebase, Android application id, Google Services plugin, platform guard, plugin initialization 설정 차이 정리
- IOP client의 device token, auth token, signing key, notification opened routing callback 책임 경계 정리
- `Client Integration Clone Adoption`을 시작하기 전 필요한 선행 게이트와 후속 분리 항목 문서화
## 기능
### Epic: [nexo-smoke] Nexo smoke gate
Nexo 쪽 알림/메시지 smoke 상태를 IOP client adoption의 선행 조건으로 분류한다.
- [ ] [handoff-status] `../nexo/HANDOFF.md``../nexo/agent-roadmap/phase/product-foundation/milestones/client-validation.md` 기준으로 FCM ACK smoke의 PASS/BLOCKED 항목을 최신 상태로 확인한다.
- [ ] [signed-scope] valid signed push trigger를 확보해 notification display, opened routing, inline reply, dismiss까지 검증한 결과를 남긴다. 진행 방향: FCM receipt + ACK까지만 인정하지 않고 full notification smoke를 완료한다.
### Epic: [iop-consumer] IOP consumer 설정 정합성
IOP `apps/client`가 Nexo messaging plugin의 host app responsibility를 충족하는지 확인하고 부족한 설정을 adoption 작업 입력으로 만든다.
- [ ] [dependency-gap] IOP `apps/client/pubspec.yaml`과 Android 설정에서 `nexo_messaging`, `firebase_core`, Google Services plugin, Firebase config, application id 차이를 정리한다.
- [ ] [bootstrap-gap] Web + Android bootstrap에서 Firebase 초기화, `NexoMessagingPlugin.instance.initialize()`, platform guard, deterministic test escape hatch 필요 여부를 정리한다.
- [ ] [credential-boundary] device token, auth token, signing key 저장과 전달 책임을 IOP Control Plane/client 경계에 맞춰 정의한다.
- [ ] [routing-boundary] notification opened event가 내부 화면, 외부 링크, 운영 이벤트를 모두 사용할 수 있도록 notification type/context별 기본 라우팅, fallback, 이벤트 기록 정책을 정리한다.
### Epic: [adoption-gate] Clone adoption handoff
기존 `Client Integration Clone Adoption` Milestone이 시작할 수 있는 입력과 남은 블로커를 추적 가능하게 만든다.
- [ ] [clone-input] `Client Integration Clone Adoption`에서 가져갈 Nexo messaging skeleton, IOP 치환 항목, 후속 분리 항목을 checklist로 남긴다.
- [ ] [lock-sync] 이 Milestone과 `../nexo` `client-validation`의 외부 의존 잠금 상태가 workspace lock에 반영되어 있는지 확인한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외
- Nexo plugin runtime 대규모 재작성
- IOP client의 전체 제품 UI 재설계
- iOS/macOS native push 구현
- Control Plane-Edge wire protocol 세부 확정
- raw FCM token, auth token, signing key, private endpoint 값을 tracked 문서에 기록하는 일
## 작업 컨텍스트
- 관련 경로: `apps/client/`, `apps/client/android/`, `../nexo/HANDOFF.md`, `../nexo/packages/messaging_flutter/`, `../nexo/apps/flutter-test/`, `../nexo/agent-roadmap/phase/product-foundation/milestones/client-validation.md`
- 표준선(선택): Nexo `nexo_messaging`은 Android-first plugin runtime이고 host app은 Firebase config, plugin initialization, auth/signing key 저장, device token 전달, opened routing callback만 소유한다.
- 표준선(선택): IOP Web target은 messaging native runtime을 직접 실행하지 않고 platform guard와 no-op 또는 후속 확장 경계를 명확히 둔다.
- 표준선(선택): Mattermost push routing은 내부 화면, 외부 링크, 운영 이벤트를 배타적으로 고르지 않고 모두 지원한다. 구현에서는 type/context별 primary route, fallback route, audit/ops event 기록 여부를 정책으로 분리한다.
- 외부 의존 잠금: `nexo:agent-roadmap/phase/product-foundation/milestones/client-validation.md`
- 선행 작업: Nexo `client-validation`의 valid signed push trigger 기반 full notification smoke
- 후속 작업: `Client Integration Clone Adoption`
- 확인 필요: Nexo에서 valid signed push trigger를 준비하고 display/opened/reply/dismiss evidence를 수집해야 한다.

View file

@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Mattermost credentials
assets/mattermost_credentials.json

View file

@ -3,16 +3,18 @@ plugins {
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
}
android {
namespace = "com.example.iop_client"
namespace = "com.tokilabs.mattermost"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@ -21,7 +23,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.iop_client"
applicationId = "com.tokilabs.mattermost"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
@ -42,3 +44,7 @@ android {
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}

View file

@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "1047648748539",
"project_id": "mattermost-6ac08",
"storage_bucket": "mattermost-6ac08.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1047648748539:android:818bf70bfbb3f9d070415e",
"android_client_info": {
"package_name": "com.tokilabs.mattermost"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAW6j_oPl9MVcm93qS3JgaEPD5ywp-TzZ0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View file

@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="iop_client"
android:name="${applicationName}"

View file

@ -1,3 +1,13 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.google.gms:google-services:4.4.2")
}
}
allprojects {
repositories {
google()

View file

@ -0,0 +1 @@

View file

@ -1,14 +1,48 @@
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'client_config.dart';
import 'iop_wire/client_wire_client.dart';
import 'src/integrations/mattermost/mattermost_push_host_integration.dart';
import 'src/integrations/mattermost/mattermost_push_plugin_client.dart';
void main() {
runApp(const IopClientApp());
final _mattermostHost = MattermostPushHostIntegration(
pushClient: MattermostPushPluginClient(),
);
Future<void> main() => runIopClient();
Future<void> applyFullscreenMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
),
);
}
Future<void> bootstrapIopClient() async {
await applyFullscreenMode();
await Firebase.initializeApp();
await _mattermostHost.initialize();
}
Future<void> runIopClient() async {
WidgetsFlutterBinding.ensureInitialized();
await bootstrapIopClient();
runApp(IopClientApp(mattermostHost: _mattermostHost));
}
class IopClientApp extends StatelessWidget {
final ClientWireClient? testClient;
const IopClientApp({super.key, this.testClient});
final MattermostPushHostIntegration? mattermostHost;
const IopClientApp({super.key, this.testClient, this.mattermostHost});
@override
Widget build(BuildContext context) {
@ -24,14 +58,19 @@ class IopClientApp extends StatelessWidget {
),
scaffoldBackgroundColor: const Color(0xFF0F172A),
),
home: ClientHomePage(testClient: testClient),
home: ClientHomePage(
testClient: testClient,
mattermostHost: mattermostHost,
),
);
}
}
class ClientHomePage extends StatefulWidget {
final ClientWireClient? testClient;
const ClientHomePage({super.key, this.testClient});
final MattermostPushHostIntegration? mattermostHost;
const ClientHomePage({super.key, this.testClient, this.mattermostHost});
@override
State<ClientHomePage> createState() => _ClientHomePageState();
@ -45,6 +84,7 @@ class _ClientHomePageState extends State<ClientHomePage>
String _wireStatus = 'Disconnected';
String _statusMessage = 'Not connected';
ClientWireClient? _client;
StreamSubscription? _notificationSubscription;
@override
void initState() {
@ -60,17 +100,45 @@ class _ClientHomePageState extends State<ClientHomePage>
// Auto-connect on start
WidgetsBinding.instance.addPostFrameCallback((_) {
applyFullscreenMode();
_connectWire();
});
final host = widget.mattermostHost;
if (host != null) {
_notificationSubscription = host.onNotification.listen(
_showMattermostNotification,
);
}
}
@override
void dispose() {
_pulseController.dispose();
_notificationSubscription?.cancel();
_client?.close();
super.dispose();
}
void _showMattermostNotification(Map<String, dynamic> data) {
if (data['type'] != 'message' || !mounted) return;
final message = data['message'] as String? ?? '';
final channel = data['channel_name'] as String? ?? '';
final sender = data['sender_name'] as String? ?? '';
final content = sender.isNotEmpty
? '[$channel] $sender: $message'
: message;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(content, maxLines: 2, overflow: TextOverflow.ellipsis),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _connectWire() async {
if (!mounted) return;
setState(() {

View file

@ -0,0 +1,161 @@
import 'dart:convert';
import 'package:flutter/foundation.dart' show FlutterError;
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'mattermost_push_client.dart';
/// Mattermost + FCM .
class MattermostAuthService {
final MattermostPushClient _pushService;
String? _serverUrl;
String? _serverIdentifier;
String? _authToken;
String? _userId;
String? _sessionId;
String? get serverUrl => _serverUrl;
bool get isLoggedIn => _authToken != null;
MattermostAuthService(this._pushService);
/// assets/mattermost_credentials.json FCM .
Future<void> autoLoginAndRegister() async {
final creds = await _loadCredentials();
if (creds == null) {
print(
'[MattermostAuth] Credentials asset not found, skipping auto login.',
);
return;
}
_serverUrl = creds['serverUrl']!;
_serverIdentifier = creds['serverId'];
print('[MattermostAuth] Logging in to $_serverUrl ...');
await _login(creds['loginId']!, creds['password']!);
// FCM ,
final existingToken = await _pushService.getDeviceToken();
if (existingToken != null && existingToken.isNotEmpty) {
print('[MattermostAuth] FCM token already available, registering ...');
await _registerDeviceToken(existingToken);
} else {
print(
'[MattermostAuth] FCM token not ready yet, waiting for callback ...',
);
}
// FCM /
_pushService.onDeviceTokenReady = (deviceToken) async {
print('[MattermostAuth] FCM token ready, registering with server ...');
await _registerDeviceToken(deviceToken);
};
print('[MattermostAuth] Auto login & FCM registration complete.');
}
Future<Map<String, String>?> _loadCredentials() async {
final String jsonStr;
try {
jsonStr = await rootBundle.loadString(
'assets/mattermost_credentials.json',
);
} on FlutterError {
return null;
}
final map = json.decode(jsonStr) as Map<String, dynamic>;
final serverId = (map['serverId'] ?? map['serverIdentifier']) as String?;
return {
'serverUrl': map['serverUrl'] as String,
'loginId': map['loginId'] as String,
'password': map['password'] as String,
if (serverId != null && serverId.isNotEmpty) 'serverId': serverId,
};
}
/// POST /api/v4/users/login Token .
Future<void> _login(String loginId, String password) async {
final url = Uri.parse('$_serverUrl/api/v4/users/login');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: json.encode({'login_id': loginId, 'password': password}),
);
if (response.statusCode != 200) {
throw Exception(
'[MattermostAuth] Login failed (${response.statusCode}): ${response.body}',
);
}
_authToken = response.headers['token'];
if (_authToken == null) {
throw Exception('[MattermostAuth] Login response missing Token header');
}
final body = json.decode(response.body) as Map<String, dynamic>;
_userId = body['id'] as String?;
_sessionId = body['session_id'] as String? ?? body['id'] as String?;
print('[MattermostAuth] Login OK - userId=$_userId sessionId=$_sessionId');
// (ACK, )
await _pushService.setAuthToken(
_serverUrl!,
_authToken!,
identifier: _serverIdentifier,
);
// config에서 signing key
await _fetchAndStoreSigningKey();
}
/// GET /api/v4/config/client?format=old AsymmetricSigningPublicKey를 .
Future<void> _fetchAndStoreSigningKey() async {
try {
final url = Uri.parse('$_serverUrl/api/v4/config/client?format=old');
final response = await http.get(
url,
headers: {'Authorization': 'Bearer $_authToken'},
);
if (response.statusCode != 200) {
print(
'[MattermostAuth] Failed to fetch config (${response.statusCode})',
);
return;
}
final config = json.decode(response.body) as Map<String, dynamic>;
final signingKey = config['AsymmetricSigningPublicKey'] as String?;
if (signingKey != null && signingKey.isNotEmpty) {
await _pushService.setSigningKey(_serverUrl!, signingKey);
print('[MattermostAuth] Signing key stored.');
} else {
print('[MattermostAuth] No signing key in server config.');
}
} catch (e) {
print('[MattermostAuth] Failed to fetch signing key: $e');
}
}
/// PUT /api/v4/users/sessions/device device_id .
Future<void> _registerDeviceToken(String deviceToken) async {
final url = Uri.parse('$_serverUrl/api/v4/users/sessions/device');
final response = await http.put(
url,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_authToken',
},
body: json.encode({'device_id': deviceToken}),
);
if (response.statusCode == 200) {
print('[MattermostAuth] FCM device token registered successfully.');
} else {
print(
'[MattermostAuth] FCM registration failed (${response.statusCode}): ${response.body}',
);
}
}
}

View file

@ -0,0 +1,25 @@
/// Host-facing interface for the Mattermost push plugin.
///
/// All production access to the platform plugin singleton is routed through
/// implementations of this interface. The host integration depends on this
/// abstraction so tests can inject fakes without booting Firebase/FCM.
abstract interface class MattermostPushClient {
Stream<Map<String, dynamic>> get onNotification;
Future<void> initialize();
Future<String?> getDeviceToken();
Future<void> setAuthToken(
String serverUrl,
String token, {
String? identifier,
});
Future<void> setSigningKey(String serverUrl, String signingKey);
set onDeviceTokenReady(Future<void> Function(String token)? callback);
set onNavigateToChannel(
void Function(String serverUrl, String channelId)? callback,
);
set onNavigateToThread(
void Function(String serverUrl, String rootId)? callback,
);
}

View file

@ -0,0 +1,70 @@
import 'package:flutter/foundation.dart' show debugPrint;
import 'mattermost_auth_service.dart';
import 'mattermost_push_client.dart';
/// Owns the Mattermost host/plugin responsibility boundary.
///
/// The integration:
/// - initializes the push client exactly once,
/// - performs auto-login best-effort (skips silently when credentials are
/// unavailable, so app boot is not blocked),
/// - registers navigation callbacks in one place (not from a widget build),
/// - exposes the notification stream for app-level consumers.
class MattermostPushHostIntegration {
final MattermostPushClient pushClient;
final MattermostAuthService Function(MattermostPushClient client)
_authServiceFactory;
bool _initialized = false;
MattermostPushHostIntegration({
required this.pushClient,
MattermostAuthService Function(MattermostPushClient client)?
authServiceFactory,
}) : _authServiceFactory =
authServiceFactory ??
((MattermostPushClient client) => MattermostAuthService(client));
Stream<Map<String, dynamic>> get onNotification => pushClient.onNotification;
bool get isInitialized => _initialized;
/// Initialize plugin, perform auth handoff, and register navigation
/// callbacks. Safe to call once at bootstrap; subsequent calls are no-ops.
///
/// [onNavigateToChannel] / [onNavigateToThread] are optional. When omitted,
/// the integration installs debug-logging fallbacks so the navigation path
/// remains observable without forcing the caller to wire routing.
Future<void> initialize({
void Function(String serverUrl, String channelId)? onNavigateToChannel,
void Function(String serverUrl, String rootId)? onNavigateToThread,
}) async {
if (_initialized) return;
_initialized = true;
await pushClient.initialize();
final authService = _authServiceFactory(pushClient);
try {
await authService.autoLoginAndRegister();
} catch (e) {
debugPrint('[MattermostHost] Mattermost auto-login failed: $e');
}
pushClient.onNavigateToChannel =
onNavigateToChannel ??
(serverUrl, channelId) {
debugPrint(
'[MattermostHost] Navigate to channel: $channelId on $serverUrl',
);
};
pushClient.onNavigateToThread =
onNavigateToThread ??
(serverUrl, rootId) {
debugPrint(
'[MattermostHost] Navigate to thread: $rootId on $serverUrl',
);
};
}
}

View file

@ -0,0 +1,58 @@
import 'dart:async';
import 'package:nexo_messaging/nexo_messaging.dart';
import 'mattermost_push_client.dart';
/// Production adapter wrapping the platform-channel singleton.
///
/// This is the ONLY file in production that should reference
/// `NexoMessagingPlugin.instance`. Everything else depends on
/// [MattermostPushClient].
class MattermostPushPluginClient implements MattermostPushClient {
final NexoMessagingPlugin _plugin;
MattermostPushPluginClient({NexoMessagingPlugin? plugin})
: _plugin = plugin ?? NexoMessagingPlugin.instance;
@override
Stream<Map<String, dynamic>> get onNotification => _plugin.onNotification;
@override
Future<void> initialize() => _plugin.initialize();
@override
Future<String?> getDeviceToken() => _plugin.getDeviceToken();
@override
Future<void> setAuthToken(
String serverUrl,
String token, {
String? identifier,
}) => _plugin.setAuthToken(serverUrl, token, identifier: identifier);
@override
Future<void> setSigningKey(String serverUrl, String signingKey) =>
_plugin.setSigningKey(serverUrl, signingKey);
@override
set onDeviceTokenReady(Future<void> Function(String token)? callback) {
_plugin.onDeviceTokenReady = callback == null
? null
: (token) => unawaited(callback(token));
}
@override
set onNavigateToChannel(
void Function(String serverUrl, String channelId)? callback,
) {
_plugin.onNavigateToChannel = callback;
}
@override
set onNavigateToThread(
void Function(String serverUrl, String rootId)? callback,
) {
_plugin.onNavigateToThread = callback;
}
}

View file

@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -5,6 +5,14 @@
import FlutterMacOS
import Foundation
import firebase_core
import firebase_messaging
import nexo_messaging
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
NexoMessagingPlugin.register(with: registry.registrar(forPlugin: "NexoMessagingPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View file

@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
url: "https://pub.dev"
source: hosted
version: "1.3.59"
async:
dependency: transitive
description:
@ -57,6 +65,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
url: "https://pub.dev"
source: hosted
version: "3.15.2"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
url: "https://pub.dev"
source: hosted
version: "2.24.1"
firebase_messaging:
dependency: transitive
description:
name: firebase_messaging
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
url: "https://pub.dev"
source: hosted
version: "15.2.10"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
url: "https://pub.dev"
source: hosted
version: "4.6.10"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
url: "https://pub.dev"
source: hosted
version: "3.10.10"
fixnum:
dependency: "direct main"
description:
@ -83,6 +139,27 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
@ -139,6 +216,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
nexo_messaging:
dependency: "direct main"
description:
path: "../../../nexo/packages/messaging_flutter"
relative: true
source: path
version: "0.0.1"
path:
dependency: transitive
description:
@ -147,6 +231,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
proto_socket:
dependency: "direct main"
description:
@ -215,6 +307,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.10"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:
@ -231,6 +395,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.11.3 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.38.0"

View file

@ -35,11 +35,23 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# Firebase
firebase_core: ^3.13.0
# Nexo messaging / Mattermost-compatible push plugin
nexo_messaging:
path: ../../../nexo/packages/messaging_flutter
protobuf: ^6.0.0
fixnum: ^1.1.0
fixnum: ^1.1.1
proto_socket:
path: ../../../proto-socket/dart
# HTTP client
http: ^1.3.0
url_launcher: ^6.3.2
dev_dependencies:
flutter_test:
sdk: flutter
@ -62,6 +74,11 @@ flutter:
# the material Icons class.
uses-material-design: true
assets:
# Local Mattermost smoke credentials live at assets/mattermost_credentials.json
# and are ignored by apps/client/.gitignore.
- assets/
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg

View file

@ -0,0 +1,174 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:iop_client/src/integrations/mattermost/mattermost_auth_service.dart';
import 'package:iop_client/src/integrations/mattermost/mattermost_push_client.dart';
import 'package:iop_client/src/integrations/mattermost/mattermost_push_host_integration.dart';
class _FakePushClient implements MattermostPushClient {
int initializeCalls = 0;
int navigateChannelAssignments = 0;
int navigateThreadAssignments = 0;
final StreamController<Map<String, dynamic>> _controller =
StreamController<Map<String, dynamic>>.broadcast();
void Function(String serverUrl, String channelId)? _navigateChannel;
void Function(String serverUrl, String rootId)? _navigateThread;
@override
Stream<Map<String, dynamic>> get onNotification => _controller.stream;
void emit(Map<String, dynamic> data) => _controller.add(data);
Future<void> dispose() => _controller.close();
@override
Future<void> initialize() async {
initializeCalls += 1;
}
@override
Future<String?> getDeviceToken() async => null;
@override
Future<void> setAuthToken(
String serverUrl,
String token, {
String? identifier,
}) async {}
@override
Future<void> setSigningKey(String serverUrl, String signingKey) async {}
@override
set onDeviceTokenReady(Future<void> Function(String token)? callback) {}
@override
set onNavigateToChannel(
void Function(String serverUrl, String channelId)? callback,
) {
navigateChannelAssignments += 1;
_navigateChannel = callback;
}
@override
set onNavigateToThread(
void Function(String serverUrl, String rootId)? callback,
) {
navigateThreadAssignments += 1;
_navigateThread = callback;
}
void triggerNavigateChannel(String serverUrl, String channelId) {
_navigateChannel?.call(serverUrl, channelId);
}
void triggerNavigateThread(String serverUrl, String rootId) {
_navigateThread?.call(serverUrl, rootId);
}
}
class _NoopAuthService implements MattermostAuthService {
int autoLoginCalls = 0;
final bool throwOnLogin;
_NoopAuthService({this.throwOnLogin = false});
@override
Future<void> autoLoginAndRegister() async {
autoLoginCalls += 1;
if (throwOnLogin) {
throw StateError('credentials missing');
}
}
@override
noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('initialize calls push client init exactly once', () async {
final push = _FakePushClient();
final auth = _NoopAuthService();
final host = MattermostPushHostIntegration(
pushClient: push,
authServiceFactory: (_) => auth,
);
await host.initialize();
await host.initialize();
expect(push.initializeCalls, equals(1));
expect(auth.autoLoginCalls, equals(1));
expect(host.isInitialized, isTrue);
await push.dispose();
});
test('auto-login failure does not block initialize', () async {
final push = _FakePushClient();
final auth = _NoopAuthService(throwOnLogin: true);
final host = MattermostPushHostIntegration(
pushClient: push,
authServiceFactory: (_) => auth,
);
await host.initialize();
expect(host.isInitialized, isTrue);
expect(push.initializeCalls, equals(1));
await push.dispose();
});
test(
'navigation callbacks are assigned exactly once during initialize',
() async {
final push = _FakePushClient();
final host = MattermostPushHostIntegration(
pushClient: push,
authServiceFactory: (_) => _NoopAuthService(),
);
String? capturedChannel;
String? capturedThread;
await host.initialize(
onNavigateToChannel: (_, channelId) => capturedChannel = channelId,
onNavigateToThread: (_, rootId) => capturedThread = rootId,
);
expect(push.navigateChannelAssignments, equals(1));
expect(push.navigateThreadAssignments, equals(1));
push.triggerNavigateChannel('https://srv', 'channel-1');
push.triggerNavigateThread('https://srv', 'root-1');
expect(capturedChannel, equals('channel-1'));
expect(capturedThread, equals('root-1'));
await push.dispose();
},
);
test('notification stream remains consumable through integration', () async {
final push = _FakePushClient();
final host = MattermostPushHostIntegration(
pushClient: push,
authServiceFactory: (_) => _NoopAuthService(),
);
await host.initialize();
final received = <Map<String, dynamic>>[];
final sub = host.onNotification.listen(received.add);
push.emit(const {'type': 'message', 'message': 'hi'});
await Future<void>.delayed(Duration.zero);
expect(received, hasLength(1));
expect(received.first['message'], equals('hi'));
await sub.cancel();
await push.dispose();
});
}

View file

@ -6,6 +6,12 @@
#include "generated_plugin_registrant.h"
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View file

@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
firebase_core
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -129,6 +129,7 @@ openai:
- "$MODEL"
session_id: "cline"
timeout_sec: 30
strict_output: false
console:
adapter: mock
target: mock-echo