refactor: organize roadmap phases and update mobile app structure

- Reorganize roadmap archives into phase-based directory structure
- Add PHASE.md files for each roadmap phase
- Update mobile app: remove Mattermost-specific push notification code
- Update mobile app: migrate to workspace-based authentication
- Update pubspec.yaml and platform configs for nomadcode app
This commit is contained in:
toki 2026-05-25 13:59:24 +09:00
parent 3297aeca22
commit 663919c8b0
65 changed files with 528 additions and 2944 deletions

View file

@ -4,58 +4,58 @@
NomadCode는 Flutter 기반 앱, core 서비스, 공유 계약, agent-operation 규칙을 하나의 원레포로 묶어 AI-assisted development workflow를 조율하는 프로젝트다.
현재 로드맵은 기존 `services/core/README.md`의 단계별 다음 작업을 `agent-ops/roadmap/` 구조로 옮긴 뒤, 클라이언트 전략을 Flutter-first로 재정렬한 것이다. React/Vite 웹 콘솔 제거와 Flutter 앱 기준 클라이언트 정리, provider-neutral work item pipeline 설계는 완료되었다. 현재는 Flutter 앱에 섞인 Mattermost push notification migration을 별도 `mattermost-push-plugin` Flutter plugin repo로 분리하는 작업을 최상위 진행 항목으로 둔다. 이후 core 백엔드 오케스트레이션의 workflow 상태 전이 안정화, 외부 통합, Flutter-first 프로젝트 제어 UX를 이어간다. IOP 연결은 NomadCode가 직접 모델 런타임을 소유하지 않고 IOP의 외부 입력 표면을 호출하는 방향으로 정리한다.
현재 로드맵은 `ROADMAP.md -> phase/<phase-slug>/PHASE.md -> phase/<phase-slug>/milestones/<milestone-slug>.md` scaffold를 기준으로 관리한다. React/Vite 웹 콘솔 제거, 서버/Plane/provider 기반 작업, Flutter-first 클라이언트 정리는 완료되었고, Mattermost push plugin extraction은 사용자 완료 확인과 archive 승인 전의 검토중 상태다. 이후 core workflow 안정화, 외부 통합, Flutter-first 프로젝트 제어 UX를 이어간다.
## Phase 흐름
- Client Platform Consolidation: React/Vite 웹 콘솔을 제품 UI 경로에서 걷어내고 Flutter 앱을 NomadCode 클라이언트 source of truth로 정리한다.
- Mattermost Push Plugin Extraction: Flutter 앱에 직접 들어간 Mattermost push notification Dart/API, Android native service, Room DB, notification resource를 `../mattermost-push-plugin` Flutter plugin repo로 분리하고 앱 통합 경계를 정리한다.
- Server Skeleton: 서버 실행 골격, task 저장 구조, 비동기 job 실행, Adapter stub을 구성한다.
- Plane Communication Foundation: Plane self-hosted 인스턴스와 통신하기 위한 인증, API client, 외부 참조 저장, smoke 검증 토대를 만든다.
- Work Item Provider Pipeline Design: Plane/Jira 등 work item provider와 core task 사이의 생성, enqueue, 상태 투영, 결과 발행 계약을 provider-neutral하게 정리한다.
- Workflow Core: Plane task pipeline 설계가 정리된 뒤 상태 전이, retry, timeout, notification event의 기본 구조를 안정화한다.
- External Integration: Plane 통신 토대와 workflow core 이후 Mattermost, Agent Integrator, IOP OpenAI API Responses-compatible 호출을 실제 통합 흐름으로 확장한다.
- Project Workspace Management UX: Flutter-first 클라이언트 구조 위에서 Project/Session/Workspace 방향을 유지하면서 앱 기반 프로젝트 단위 제어 화면을 정리한다.
위에서 아래로 진행된 순서와 예정 흐름을 나타낸다.
완료된 Phase도 로드맵에서 제거하지 않고, archive의 Phase 문서로 연결한다.
검토중 또는 진행중 Phase는 계획 Phase보다 위에 두어, 아래로 갈수록 미래 계획에 가까워지게 정렬한다.
## Milestone 목록
### Mattermost Push Plugin Extraction
- [Mattermost Push Plugin Extraction](milestones/mattermost-push-plugin-extraction.md) - 상태: 진행 중; 목표: Flutter 앱에 섞인 Mattermost push notification migration을 `../mattermost-push-plugin` 독립 Flutter plugin repo로 분리하고 NomadCode 앱은 plugin consumer로 전환한다.
### Workflow Core
- [Workflow Core](milestones/workflow-core.md) - 상태: 진행 중; 목표: 실제 e2e 흐름을 기준으로 task lifecycle, retry, timeout, notification event를 안정화한다.
### External Integration
- [External Integration](milestones/external-integration.md) - 상태: 계획; 목표: Plane 확장, Mattermost, Agent Integrator, IOP OpenAI API Responses-compatible 호출을 실제 통합 흐름으로 확장한다.
### Project Workspace Management UX
- [Project Workspace Management UX](milestones/project-workspace-management-ux.md) - 상태: 계획; 목표: Flutter-first 클라이언트 구조 위에서 프로젝트 단위 제어 UX를 앱 기반 desktop/mobile layout 기준으로 정리한다.
## 아카이브 Milestone 요약
- Server Skeleton - 상태: 완료; 아카이브일: 2026-05-24; 요약: core 서버 실행 골격, task 저장, 비동기 job, 초기 외부 Adapter stub을 구성했다.; 핵심 산출물/근거: 모든 필수 기능과 완료 기준이 체크되어 있으며 server 실행, task API/storage, River job, Plane/Mattermost stub이 기록되어 있다.; 후속 영향: Plane Communication Foundation과 후속 workflow/provider 작업의 기반이 되었다.
- Plane Communication Foundation - 상태: 완료; 아카이브일: 2026-05-24; 요약: Plane self-hosted 인스턴스와의 인증/API 통신, 외부 참조 저장, 수동 smoke 검증 토대를 만들었다.; 핵심 산출물/근거: 모든 필수 기능과 완료 기준이 체크되어 있으며 Plane adapter 테스트, external ref 저장, 실제 comment/state smoke, README/운영 문서 근거가 기록되어 있다.; 후속 영향: Work Item Provider Pipeline Design과 Workflow Core의 선행 기반이 되었다.
- Flutter-first Client Consolidation - 상태: 완료; 아카이브일: 2026-05-25; 요약: Flutter 앱을 제품 UI 기준 구현으로 세우고 React/Vite 웹 콘솔 scaffold를 제거했다.; 핵심 산출물/근거: `apps/web` 제거, root `bin/*`의 Flutter-first 검증 흐름, Flutter code-server 직접 실행 표면, `packages/contracts` 계약 후보 문서가 반영되어 있다.; 후속 영향: Project Workspace Management UX와 사용자-facing task state UX의 기반이 되었다.
- Work Item Provider Pipeline Design - 상태: 완료; 아카이브일: 2026-05-25; 요약: Plane/Jira 등 work item provider와 core task 사이의 생성, enqueue, 상태 투영, 결과 발행 계약을 provider-neutral하게 정리했다.; 핵심 산출물/근거: 모든 필수 기능과 완료 기준이 체크되어 있으며 provider-neutral adapter, task mapper, pipeline service, projection/idempotency/retry 정책이 기록되어 있다.; 후속 영향: Workflow Core가 상태 전이 구현을 시작할 수 있는 기준이 되었다.
- [완료] Server Skeleton
- 경로: `agent-ops/roadmap/archive/phase/server-skeleton/PHASE.md`
- 요약: core 서버 실행 골격, task 저장 구조, 비동기 job 실행, Adapter stub을 구성했다.
- [완료] Plane Communication Foundation
- 경로: `agent-ops/roadmap/archive/phase/plane-communication-foundation/PHASE.md`
- 요약: Plane self-hosted 인스턴스와 통신하기 위한 인증, API client, 외부 참조 저장, smoke 검증 토대를 만들었다.
- [완료] Client Platform Consolidation
- 경로: `agent-ops/roadmap/archive/phase/client-platform-consolidation/PHASE.md`
- 요약: React/Vite 웹 콘솔을 제품 UI 경로에서 걷어내고 Flutter 앱을 NomadCode 클라이언트 source of truth로 정리했다.
- [완료] Work Item Provider Pipeline Design
- 경로: `agent-ops/roadmap/archive/phase/work-item-provider-pipeline-design/PHASE.md`
- 요약: Plane/Jira 등 work item provider와 core task 사이의 생성, enqueue, 상태 투영, 결과 발행 계약을 provider-neutral하게 정리했다.
- [검토중] Mattermost Push Plugin Extraction
- 경로: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/PHASE.md`
- 요약: Flutter 앱에 섞인 Mattermost push notification migration을 `../mattermost-push-plugin` Flutter plugin repo로 분리했고, 사용자 완료 확인과 archive 승인을 기다린다.
- [진행중] Workflow Core
- 경로: `agent-ops/roadmap/phase/workflow-core/PHASE.md`
- 요약: 실제 e2e 흐름을 기준으로 task lifecycle, retry, timeout, notification event를 안정화한다.
- [계획] External Integration
- 경로: `agent-ops/roadmap/phase/external-integration/PHASE.md`
- 요약: Plane 확장, Mattermost, Agent Integrator, IOP OpenAI API Responses-compatible 호출을 실제 통합 흐름으로 확장한다.
- [계획] Project Workspace Management UX
- 경로: `agent-ops/roadmap/phase/project-workspace-management-ux/PHASE.md`
- 요약: Flutter-first 클라이언트 구조 위에서 프로젝트 단위 제어 UX를 앱 기반 desktop/mobile layout 기준으로 정리한다.
## 로딩 정책
- 일반 작업에서는 `agent-ops/roadmap/ROADMAP.md`를 매번 읽지 않는다.
- 기능 추가, 구조 변경, 스킬 추가/수정, 문서 구조 변경 작업을 수행할 때는 `agent-ops/roadmap/current.md`를 먼저 읽는다.
- `current.md`는 현재 작업 위치가 아니라 활성 Milestone 후보 목록이다.
- `current.md`는 현재 작업 위치가 아니라 활성 Phase와 활성 Milestone 후보 목록이다.
- `current.md`에는 개인별 현재 작업 위치나 완료 상태를 기록하지 않는다.
- `current.md`의 활성 Milestone은 `agent-ops/roadmap/milestones/` 하위 문서만 가리키며, `agent-ops/roadmap/archive/**`는 포함하지 않는다.
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 활성 Milestone 문서를 같은 세션에서 1회 읽는다.
- 활성 Milestone 밖의 작업이면 이 문서의 Milestone 목록을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
- 이 문서는 로드맵 생성/갱신, Phase 전환, Milestone 추가/수정 요청이 있을 때만 읽는다.
- 상세 작업과 완료 기준은 각 Milestone 문서의 체크리스트로 관리한다.
- 완료 또는 폐기되어 아카이브된 Milestone은 이 문서의 `아카이브 Milestone 요약`에 당시 요약만 남기고, 아카이브 문서 링크나 상세 경로는 남기지 않는다.
- 상세 문서가 있는 `agent-ops/roadmap/archive/**`는 사용자가 명시적으로 요청한 경우에만 읽는다.
- 아카이브된 Milestone 문서는 최신 템플릿이나 스킬 규약에 맞춰 재포맷하지 않는다.
- `current.md`의 활성 Phase는 `agent-ops/roadmap/phase/<phase-slug>/PHASE.md`를 가리킨다.
- `current.md`의 활성 Milestone은 `agent-ops/roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md`를 가리킨다.
- `current.md``agent-ops/roadmap/archive/**` 경로를 활성 항목으로 포함하지 않는다.
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 활성 Phase와 Milestone 문서를 같은 세션에서 1회 읽는다.
- 활성 Phase 또는 Milestone 밖의 작업이면 이 문서의 Phase 흐름을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
- 이 문서는 로드맵 생성/갱신, Phase 전환, Phase 추가/수정, 전체 구조 변경 요청이 있을 때만 읽는다.
- 상세 작업과 완료 기준은 각 Milestone 문서의 `필수 기능`, `완료 기준`으로 관리한다.
- 모든 필수 작업과 완료 기준이 충족된 Milestone은 먼저 `[검토중]`으로 두고, 사용자 완료 확인과 archive 승인을 받은 뒤 `[완료]`로 전환한다.
- 완료된 Phase는 `agent-ops/roadmap/archive/phase/<phase-slug>/PHASE.md`로 이동하고, 하위 Milestone도 같은 archive Phase scaffold 아래에 둔다.
- 진행중 Phase 안에서 완료된 Milestone은 활성 Phase 문서에 짧은 링크를 남기고, 상세 문서는 `agent-ops/roadmap/archive/phase/<phase-slug>/milestones/`로 이동한다.
- archive `PHASE.md`는 Phase 자체가 완료 또는 폐기될 때만 만들며, 진행중 Phase의 완료 Milestone만 archive된 경우 archive Phase 디렉터리에 `milestones/`만 있을 수 있다.
- `agent-ops/roadmap/archive/**`는 일반 작업에서 읽지 않는다. 과거 완료 내용, 완료 근거, 복원, 비교가 필요한 경우에만 `ROADMAP.md` 또는 `PHASE.md`의 archive 링크를 따라가서 읽는다.
- 아카이브된 Phase/Milestone 문서는 최신 템플릿이나 스킬 규약에 맞춰 재포맷하지 않는다.
- 선택된 Milestone의 `구현 잠금` 섹션이 없거나 상태가 `잠금`이면 코드 구현, `agent-task` 구현 계획 생성, 세부 API/파일 구조 확정을 시작하기 전에 현재 요청에 직접 영향을 주는 `결정 필요` 항목만 확인한다.
- 현재 요청과 직접 관련 없는 미정 항목은 잠금 상태로 남겨도 되며, 기존 구조/도메인 rule/플랫폼 관례로 정할 수 있는 작업은 표준선으로 기록하고 진행할 수 있다.
- Milestone 전체에서 사용자만 결정할 항목이 더 이상 없고 에이전트가 표준선에 따라 실행하면 되는 상태라면 `구현 잠금` 상태를 `해제`로 둔다.

View file

@ -0,0 +1,20 @@
# Phase: Client Platform Consolidation
## 상태
[완료]
## 목표
React/Vite 웹 콘솔을 제품 UI 경로에서 제거하고 Flutter 앱을 NomadCode 클라이언트 source of truth로 정리한다.
## Milestone 흐름
- [완료] Flutter-first Client Consolidation
- 경로: `agent-ops/roadmap/archive/phase/client-platform-consolidation/milestones/flutter-first-client-consolidation.md`
- 요약: `apps/web` 제거, root `bin/*` Flutter-first 검증 흐름, Flutter code-server 실행 표면, `packages/contracts` 계약 후보 문서를 반영했다.
## Phase 경계
- 이 문서는 기존 archive Milestone을 새 Phase scaffold에 연결하기 위한 archive index다.
- 상세 완료 근거는 하위 archive Milestone 스냅샷을 따른다.

View file

@ -0,0 +1,20 @@
# Phase: Plane Communication Foundation
## 상태
[완료]
## 목표
Plane self-hosted 인스턴스와의 인증/API 통신, 외부 참조 저장, 수동 smoke 검증 토대를 만든다.
## Milestone 흐름
- [완료] Plane Thin E2E Loop
- 경로: `agent-ops/roadmap/archive/phase/plane-communication-foundation/milestones/plane-thin-e2e-loop.md`
- 요약: Plane adapter 테스트, external ref 저장, comment/state smoke, README/운영 문서 근거를 완료했다.
## Phase 경계
- 이 문서는 기존 archive Milestone을 새 Phase scaffold에 연결하기 위한 archive index다.
- 상세 완료 근거는 하위 archive Milestone 스냅샷을 따른다.

View file

@ -0,0 +1,20 @@
# Phase: Server Skeleton
## 상태
[완료]
## 목표
core 서버 실행 골격, task 저장 구조, 비동기 job 실행, Adapter stub을 구성해 후속 Plane 통신과 workflow/provider 작업의 기반을 만든다.
## Milestone 흐름
- [완료] Server Skeleton
- 경로: `agent-ops/roadmap/archive/phase/server-skeleton/milestones/server-skeleton.md`
- 요약: core 서버 실행, task API/storage, River job, Plane/Mattermost stub 기반을 완료했다.
## Phase 경계
- 이 문서는 기존 archive Milestone을 새 Phase scaffold에 연결하기 위한 archive index다.
- 상세 완료 근거는 하위 archive Milestone 스냅샷을 따른다.

View file

@ -0,0 +1,20 @@
# Phase: Work Item Provider Pipeline Design
## 상태
[완료]
## 목표
Plane/Jira 등 work item provider와 core task 사이의 생성, enqueue, 상태 투영, 결과 발행 계약을 provider-neutral하게 정리한다.
## Milestone 흐름
- [완료] Plane Task Pipeline Design
- 경로: `agent-ops/roadmap/archive/phase/work-item-provider-pipeline-design/milestones/plane-task-pipeline-design.md`
- 요약: provider-neutral adapter, task mapper, pipeline service, projection/idempotency/retry 정책을 완료했다.
## Phase 경계
- 이 문서는 기존 archive Milestone을 새 Phase scaffold에 연결하기 위한 archive index다.
- 상세 완료 근거는 하위 archive Milestone 스냅샷을 따른다.

View file

@ -1,14 +1,31 @@
# 현재 로드맵 컨텍스트
## 활성 Phase
- [검토중] Mattermost Push Plugin Extraction
- 경로: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/PHASE.md`
- [진행중] Workflow Core
- 경로: `agent-ops/roadmap/phase/workflow-core/PHASE.md`
## 활성 Milestone
- Mattermost Push Plugin Extraction: agent-ops/roadmap/milestones/mattermost-push-plugin-extraction.md
- [검토중] Mattermost Push Plugin Extraction
- Phase: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/PHASE.md`
- 경로: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/milestones/mattermost-push-plugin-extraction.md`
- [진행중] Workflow Core
- Phase: `agent-ops/roadmap/phase/workflow-core/PHASE.md`
- 경로: `agent-ops/roadmap/phase/workflow-core/milestones/workflow-core.md`
## 선택 규칙
- 이 문서는 활성 Milestone 후보 목록이며, 개인별 현재 작업 위치나 완료 상태를 기록하지 않는다.
- 활성 Milestone은 `agent-ops/roadmap/milestones/` 하위 문서만 가리키며, `agent-ops/roadmap/archive/**`는 포함하지 않는다.
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 Milestone을 선택하고 같은 세션에서 1회 읽는다.
- 활성 Milestone 둘 이상에 걸치면 필요한 Milestone 문서를 모두 읽고 작업 범위를 좁힌다.
- 활성 Milestone 밖의 작업이면 `agent-ops/roadmap/ROADMAP.md`의 Milestone 목록을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
- 이 문서는 활성 Phase와 활성 Milestone 후보 목록이며, 개인별 현재 작업 위치나 완료 상태를 기록하지 않는다.
- 활성 Phase는 `agent-ops/roadmap/phase/<phase-slug>/PHASE.md`를 가리킨다.
- 활성 Milestone은 `agent-ops/roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md`를 가리킨다.
- 활성 항목은 아카이브 경로를 포함하지 않는다.
- `[검토중]` 항목은 사용자 완료 확인 전까지 활성 항목으로 남길 수 있다.
- `[완료]` 또는 `[폐기]` 항목은 archive 링크를 남긴 뒤 활성 항목에서 제거한다.
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 Phase와 Milestone을 선택하고 같은 세션에서 1회 읽는다.
- 활성 Phase 또는 Milestone 둘 이상에 걸치면 필요한 문서를 모두 읽고 작업 범위를 좁힌다.
- 활성 범위 밖의 작업이면 `agent-ops/roadmap/ROADMAP.md`의 Phase 흐름을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
- 완료된 과거 내용이 필요할 때만 `ROADMAP.md` 또는 `PHASE.md`에 있는 archive 링크를 따라가서 읽는다.
- 선택된 Milestone의 `구현 잠금` 섹션이 없거나 상태가 `잠금`이면 구현이나 구현 계획을 시작하기 전에 현재 요청에 직접 영향을 주는 `결정 필요` 항목만 확인한다. 관련 결정이 없고 표준선으로 처리 가능하면 잠금을 유지한 채 진행할 수 있으며, Milestone 전체에서 사용자만 결정할 항목이 더 이상 없을 때만 `구현 잠금` 상태를 `해제`로 둔다.

View file

@ -1,70 +0,0 @@
# Mattermost Push Plugin Extraction
## 목표
Flutter 앱 내부에 직접 이식된 Mattermost push notification 기능을 `../mattermost-push-plugin` 독립 Flutter plugin repo로 분리한다. NomadCode 앱은 Firebase 설정, 로그인/라우팅, 제품 UI만 유지하고, Mattermost push 수신/표시/답장/ACK/토큰 저장 경계는 plugin API를 통해 소비하도록 전환한다.
## 단계
Mattermost Push Plugin Extraction
## 상태
진행 중
## 구현 잠금
- 상태: 해제
- 결정 필요: 없음
## 범위
- `../mattermost-push-plugin` repo를 Flutter plugin 프로젝트로 구성
- `apps/mobile/lib/services/push_notification_service.dart`의 public Dart facade를 plugin API로 승격
- Android native FCM service, notification builder, reply/dismiss receiver, ACK delivery, Room DB helper, network helper, notification resource를 plugin 소유로 이동
- 앱 `MainActivity``MainApplication` 직접 의존을 제거하고 Flutter plugin 등록/초기화 경계로 전환
- NomadCode 앱은 plugin path dependency, Firebase project 설정, Mattermost 로그인/credential 관리, 알림 클릭 라우팅만 보유
- 현재 Android-first 구현을 기준으로 하되 iOS/macOS는 no-op 또는 후속 확장 지점을 명확히 둠
- 기존 `push-notification-todo.md`의 남은 Android DB stub과 singleton DB close 위험을 plugin extraction 과정에서 추적 가능한 작업으로 재배치
## 필수 기능
- [ ] [plugin-scaffold] `../mattermost-push-plugin``mattermost_push_plugin` Flutter plugin repo로 구성한다.
- [ ] [dart-api] push 초기화, 알림 stream, opened event, device token, auth token, signing key 저장 API를 plugin Dart surface로 정의한다.
- [ ] [android-native] Android FCM service, notification builder, ACK, inline reply, dismiss, Room DB, network helper, notification resource를 plugin Android implementation으로 이동한다.
- [ ] [host-boundary] `MainActivity`/`MainApplication` 직접 참조를 제거하고 launch intent, lifecycle, channel registration, DB/channel 초기화를 plugin-safe 방식으로 정리한다.
- [ ] [app-consumer] `apps/mobile`을 plugin consumer로 전환하고 앱에는 Firebase 설정, Mattermost auth flow, 라우팅 콜백만 남긴다.
- [ ] [risk-hardening] Room DB singleton close, `PushNotificationDataHelper` DAO stub, Firebase Messaging manifest 충돌 같은 extraction 위험을 처리하거나 명시적 follow-up으로 남긴다.
- [ ] [docs-verify] plugin README, host app 통합 메모, 검증 명령과 수동 smoke 기준을 작성한다.
## 완료 기준
- [ ] `../mattermost-push-plugin`에 Flutter plugin 구조, Dart API, Android implementation, Android manifest/resource/dependency가 존재한다.
- [ ] `apps/mobile`은 Mattermost push 관련 native service/helper/resource를 직접 소유하지 않고 plugin path dependency로 연동한다.
- [ ] Android 알림 수신, 시스템 알림 표시, 알림 탭 event, inline reply, ACK, device/auth/signing key 저장 경계가 plugin API 또는 plugin native 구현으로 닫힌다.
- [ ] host 앱이 유지해야 하는 Firebase 설정, `google-services.json`, Mattermost login/credential, 라우팅 callback 책임이 문서화되어 있다.
- [ ] `apps/mobile``flutter test`가 통과하고, Android build 또는 명시한 대체 검증으로 plugin 통합이 깨지지 않음을 확인한다.
- [ ] iOS/macOS 지원 범위가 no-op인지 후속 구현인지 문서에 명확히 남아 있다.
## 범위 제외
- Mattermost 서버 push gateway 또는 server-side plugin 구현
- pub.dev 공개 배포, semantic versioning, release automation
- iOS/macOS native push full implementation
- NomadCode project/workspace UX 화면 재설계
- Mattermost auth 제품 UI 완성
- core service notification event 또는 Mattermost adapter 구현
## 작업 컨텍스트
- 관련 경로: `apps/mobile/`, `../mattermost-push-plugin/`
- 현재 plugin repo: `../mattermost-push-plugin`은 사용자가 생성한 빈 git repo다.
- 표준선(선택): repo명은 `mattermost-push-plugin`, Dart package name은 `mattermost_push_plugin`으로 둔다. 첫 구현은 Android-first Flutter plugin으로 잡고, host app이 Firebase project 설정과 Mattermost login/라우팅을 소유한다.
- 선행 작업: Flutter-first Client Consolidation
- 후속 작업: Project Workspace Management UX, External Integration의 Mattermost 사용자-facing 흐름
- 분석 근거:
- `apps/mobile/lib/services/push_notification_service.dart`는 EventChannel/MethodChannel과 FCM token facade를 제공한다.
- `apps/mobile/android/app/src/main/java/com/tokilabs/mattermost/MattermostFirebaseMessagingService.kt`는 native FCM 수신, signature verification, ACK, notification build, EventChannel 전달을 수행한다.
- `apps/mobile/android/app/src/main/java/com/tokilabs/mattermost/MainActivity.kt``MainApplication.kt`는 현재 plugin으로 옮겨야 할 channel registration, DB init, notification channel init, lifecycle tracker를 직접 보유한다.
- `apps/mobile/android/app/build.gradle.kts`는 Firebase Messaging, JJWT, OkHttp, coroutines, Room/kapt 의존성을 앱에 직접 둔다.
- 확인 필요: 없음

View file

@ -0,0 +1,24 @@
# Phase: External Integration
## 상태
[계획]
## 목표
Work Item Provider Pipeline Design과 Workflow Core 이후 Plane/Jira 확장, Mattermost, Agent Integrator, IOP OpenAI API Responses-compatible 호출을 실제 통합 흐름으로 확장한다. NomadCode가 직접 모델 런타임을 소유하지 않고 IOP의 외부 입력 표면을 호출하는 방향을 유지한다.
## Milestone 흐름
완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다.
완료, 검토중, 진행중, 계획 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
- [계획] External Integration
- 경로: `agent-ops/roadmap/phase/external-integration/milestones/external-integration.md`
- 요약: Plane/Jira/Mattermost/Agent Integrator/IOP 호출 경계를 실제 통합 adapter 흐름으로 확장한다.
## Phase 경계
- 외부 provider 세부 구현은 adapter 경계 밖으로 새지 않게 한다.
- IOP 내부 모델 라우팅, 모델 프로파일, RAG, MCP, output validation, fallback 정책은 NomadCode 범위에서 제외한다.
- A2A 도입 시점과 Agent Integrator 유지 여부는 사용자 결정이 필요하다.

View file

@ -1,16 +1,17 @@
# External Integration
# Milestone: External Integration
## 위치
- Roadmap: `agent-ops/roadmap/ROADMAP.md`
- Phase: `agent-ops/roadmap/phase/external-integration/PHASE.md`
## 목표
Work Item Provider Pipeline Design과 workflow core 이후 남은 Plane/Jira 확장, Mattermost, Agent Integrator, IOP 연결을 stub 또는 호환 호출 경로에서 실제 통합 흐름으로 확장한다. IOP 호출은 현재 단계에서 OpenAI API Responses-compatible 경로를 기본으로 하며, NomadCode가 직접 모델 런타임을 호출하거나 IOP native protocol을 외부 호출 표면으로 사용하지 않는다.
## 단계
External Integration
## 상태
계획
[계획]
## 구현 잠금
@ -32,6 +33,10 @@ External Integration
## 필수 기능
### Epic: [external-integration] Provider and execution adapters
외부 work item provider, 협업 도구, 실행 표면을 adapter 경계 안에서 실제 통합 흐름으로 확장한다.
- [ ] [plane-adapter-expand] Plane issue 생성, comment, status update adapter 확장
- [ ] [jira-adapter] Jira issue 조회, comment, status transition adapter 구현
- [ ] [mattermost-adapter] Mattermost 메시지 발송 adapter 구현
@ -49,6 +54,16 @@ External Integration
- [x] NomadCode의 기본 실행 경로가 직접 모델 호출이 아니라 IOP 경유 호출임이 로드맵과 운영 문서에서 일관되게 읽힌다.
- [ ] 외부 provider별 세부 구현이 adapter 경계 밖으로 새지 않는다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외
- Outline / Forgejo / Nextcloud 연동

View file

@ -0,0 +1,24 @@
# Phase: Mattermost Push Plugin Extraction
## 상태
[검토중]
## 목표
Flutter 앱에 직접 들어간 Mattermost push notification Dart/API, Android native service, Room DB, notification resource를 `../mattermost-push-plugin` Flutter plugin repo로 분리한다. NomadCode 앱은 Firebase 설정, Mattermost auth, 라우팅 callback만 소유하는 plugin consumer로 정리한다.
## Milestone 흐름
완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다.
완료, 검토중, 진행중, 계획 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
- [검토중] Mattermost Push Plugin Extraction
- 경로: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/milestones/mattermost-push-plugin-extraction.md`
- 요약: `apps/mobile`의 native push 소유권을 plugin으로 옮기고 host 앱은 path dependency, Firebase/auth, routing 경계만 유지하도록 정리했다.
## Phase 경계
- Mattermost push 수신, 표시, inline reply, dismiss, ACK, token/signing key 저장은 plugin 소유로 둔다.
- Host 앱은 Firebase project 설정, Mattermost login/credential, 앱 navigation callback만 소유한다.
- iOS/macOS native push full implementation, server-side Mattermost plugin, pub.dev 배포는 이 Phase 범위에서 제외한다.

View file

@ -0,0 +1,94 @@
# Milestone: Mattermost Push Plugin Extraction
## 위치
- Roadmap: `agent-ops/roadmap/ROADMAP.md`
- Phase: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/PHASE.md`
## 목표
Flutter 앱 내부에 직접 이식된 Mattermost push notification 기능을 `../mattermost-push-plugin` 독립 Flutter plugin repo로 분리한다. NomadCode 앱은 Firebase 설정, 로그인/라우팅, 제품 UI만 유지하고, Mattermost push 수신/표시/답장/ACK/토큰 저장 경계는 plugin API를 통해 소비하도록 전환한다.
## 상태
[검토중]
## 구현 잠금
- 상태: 해제
- 결정 필요: 없음
## 범위
- `../mattermost-push-plugin` repo를 Flutter plugin 프로젝트로 구성
- `apps/mobile/lib/services/push_notification_service.dart`의 public Dart facade를 plugin API로 승격
- Android native FCM service, notification builder, reply/dismiss receiver, ACK delivery, Room DB helper, network helper, notification resource를 plugin 소유로 이동
- 앱 `MainActivity``MainApplication` 직접 의존을 제거하고 Flutter plugin 등록/초기화 경계로 전환
- NomadCode 앱은 plugin path dependency, Firebase project 설정, Mattermost 로그인/credential 관리, 알림 클릭 라우팅만 보유
- 현재 Android-first 구현을 기준으로 하되 iOS/macOS는 no-op 또는 후속 확장 지점을 명확히 둠
- 기존 `push-notification-todo.md`의 남은 Android DB stub과 singleton DB close 위험을 plugin extraction 과정에서 추적 가능한 작업으로 재배치
## 필수 기능
### Epic: [extraction] Plugin extraction boundary
Flutter host 앱에 있던 Mattermost push domain을 독립 plugin repo와 host consumer 경계로 분리한다.
- [x] [plugin-scaffold] `../mattermost-push-plugin``mattermost_push_plugin` Flutter plugin repo로 구성했다.
- [x] [dart-api] push 초기화, 알림 stream, opened event, device token, auth token, signing key 저장 API를 plugin Dart surface로 정의했다.
- [x] [android-native] Android FCM service, notification builder, ACK, inline reply, dismiss, Room DB, network helper, notification resource를 plugin Android implementation으로 이동했다.
- [x] [host-boundary] `MainActivity`/`MainApplication` 직접 참조를 제거하고 launch intent, lifecycle, channel registration, DB/channel 초기화를 plugin-safe 방식으로 정리했다.
- [x] [app-consumer] `apps/mobile`을 plugin consumer로 전환하고 앱에는 Firebase 설정, Mattermost auth flow, 라우팅 콜백만 남겼다.
- [x] [risk-hardening] Room DB singleton close, `PushNotificationDataHelper` DAO stub, Firebase Messaging manifest 충돌 위험을 처리하거나 명시적 follow-up으로 남겼다.
- [x] [docs-verify] plugin README, host app 통합 메모, 검증 명령과 수동 smoke 기준을 작성했다.
## 완료 기준
- [x] `../mattermost-push-plugin`에 Flutter plugin 구조, Dart API, Android implementation, Android manifest/resource/dependency가 존재한다.
- [x] `apps/mobile`은 Mattermost push 관련 native service/helper/resource를 직접 소유하지 않고 plugin path dependency로 연동한다.
- [x] Android 알림 수신, 시스템 알림 표시, 알림 탭 event, inline reply, ACK, device/auth/signing key 저장 경계가 plugin API 또는 plugin native 구현으로 닫힌다.
- [x] host 앱이 유지해야 하는 Firebase 설정, `google-services.json`, Mattermost login/credential, 라우팅 callback 책임이 문서화되어 있다.
- [x] `apps/mobile``flutter test`가 통과하고, Android build 또는 명시한 대체 검증으로 plugin 통합이 깨지지 않음을 확인한다. 대체 검증은 plugin/app `flutter test`와 host/plugin boundary 파일 확인으로 남겼으며, Android SDK가 있는 환경의 debug APK/device smoke는 최종 리뷰 확인 항목으로 둔다.
- [x] iOS/macOS 지원 범위가 no-op인지 후속 구현인지 문서에 명확히 남아 있다.
## 완료 리뷰
- 상태: 요청됨
- 요청일: 2026-05-25
- 완료 근거:
- `../mattermost-push-plugin` sibling repo에 Flutter plugin scaffold, Dart API, Android FCM service/receiver/helper/Room/resource, iOS/macOS no-op scaffold가 존재한다.
- `apps/mobile` 미커밋 변경에서 direct push service/native service/resource를 제거하고 `mattermost_push_plugin` path dependency와 host auth/routing 경계로 전환했다.
- `flutter test``../mattermost-push-plugin``apps/mobile`에서 모두 통과했다. `flutter build apk --debug`는 현재 환경에 Android SDK가 없어 실행되지 않았다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: Android SDK 또는 실제 device가 있는 환경에서 debug APK/FCM 수동 smoke를 추가 확인할지 최종 리뷰에서 결정한다.
## 범위 제외
- Mattermost 서버 push gateway 또는 server-side plugin 구현
- pub.dev 공개 배포, semantic versioning, release automation
- iOS/macOS native push full implementation
- NomadCode project/workspace UX 화면 재설계
- Mattermost auth 제품 UI 완성
- core service notification event 또는 Mattermost adapter 구현
## 작업 컨텍스트
- 관련 경로: `apps/mobile/`, `../mattermost-push-plugin/`
- 현재 plugin repo: `../mattermost-push-plugin`은 사용자가 생성한 별도 git repo이며, 현재 HEAD는 `678302b initial commit: mattermost-push-plugin`이고 working tree는 clean 상태다.
- 표준선(선택): repo명은 `mattermost-push-plugin`, Dart package name은 `mattermost_push_plugin`으로 둔다. 첫 구현은 Android-first Flutter plugin으로 잡고, host app이 Firebase project 설정과 Mattermost login/라우팅을 소유한다.
- 선행 작업: Flutter-first Client Consolidation
- 후속 작업: Project Workspace Management UX, External Integration의 Mattermost 사용자-facing 흐름
- 현재 반영 근거:
- `../mattermost-push-plugin/lib/src/mattermost_push_plugin.dart`는 EventChannel/MethodChannel과 FCM token facade를 plugin API로 제공한다.
- `../mattermost-push-plugin/android/src/main/kotlin/com/tokilabs/mattermost_push_plugin/MattermostFirebaseMessagingService.kt`는 native FCM 수신, signature verification, ACK, notification build, EventChannel 전달을 수행한다.
- `../mattermost-push-plugin/android/src/main/kotlin/com/tokilabs/mattermost_push_plugin/MattermostPushPlugin.kt`는 channel registration, DB init, notification channel init, lifecycle tracker, launch intent event를 plugin 경계로 소유한다.
- `apps/mobile/android/app/src/main/java/com/tokilabs/mattermost/MainActivity.kt`는 FlutterActivity fullscreen 설정만 남기고 native push channel 직접 등록을 제거했다.
- `apps/mobile/pubspec.yaml``mattermost_push_plugin` path dependency를 사용하고 `firebase_messaging`, `flutter_local_notifications` 직접 dependency를 제거했다.
- `apps/mobile/push-notification-todo.md`는 host app 내 native push 파일을 직접 수정하지 않는 handoff 문서로 축소되었다.
- 검증:
- `flutter test` in `../mattermost-push-plugin`: 통과
- `flutter test` in `apps/mobile`: 통과
- `flutter build apk --debug` in `apps/mobile`: 실패, `ANDROID_HOME` 미설정으로 Android SDK를 찾지 못함
- 확인 필요: 사용자 완료 확인과 archive 이동 승인.

View file

@ -0,0 +1,24 @@
# Phase: Project Workspace Management UX
## 상태
[계획]
## 목표
Flutter-first 클라이언트 구조 위에서 기존 Project/Session/Workspace 방향을 유지하면서 프로젝트 단위 제어 UX를 앱 기반 desktop과 mobile layout으로 정리한다. Desktop은 mobile에서 가능한 프로젝트 관리 기능을 포함하고 code-server와 Agent Chat을 다루는 프로젝트 제어 확장 표면으로 정리한다.
## Milestone 흐름
완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다.
완료, 검토중, 진행중, 계획 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
- [계획] Project Workspace Management UX
- 경로: `agent-ops/roadmap/phase/project-workspace-management-ux/milestones/project-workspace-management-ux.md`
- 요약: 프로젝트 단위 제어 UX의 desktop/mobile 역할, parity 기준, IOP/task 결과 표시 기준을 정리한다.
## Phase 경계
- 기존 Project/Session/Workspace 방향을 대체하지 않고 보강한다.
- IOP 내부 모델 라우팅, 외부 agent 추가 흐름, 상세 화면/API/DB schema 확정은 이 Phase의 결정 전 범위에서 제외한다.
- UX 책임 경계와 우선순위는 사용자 결정이 필요한 잠금 항목으로 유지한다.

View file

@ -1,16 +1,17 @@
# Project Workspace Management UX
# Milestone: Project Workspace Management UX
## 위치
- Roadmap: `agent-ops/roadmap/ROADMAP.md`
- Phase: `agent-ops/roadmap/phase/project-workspace-management-ux/PHASE.md`
## 목표
Flutter-first 클라이언트 구조 위에서 기존 Project/Session/Workspace 방향을 유지하면서 프로젝트 단위 제어 UX를 앱 기반 desktop과 mobile layout으로 정리한다. Mobile에서 가능한 프로젝트 관리 기능은 desktop에도 포함하고, desktop은 프로젝트 단위 확장 표면을 code-server와 Agent Chat을 다루는 앱 shell로 정리한다.
## 단계
Project Workspace Management UX
## 상태
계획
[계획]
## 구현 잠금
@ -31,6 +32,10 @@ Project Workspace Management UX
## 필수 기능
### Epic: [workspace-ux] Project workspace UX definition
Flutter-first 앱에서 project/session/workspace 제어 표면과 desktop/mobile 역할 경계를 정의한다.
- [ ] [common-capability] 프로젝트 단위 제어 화면의 공통 capability를 정의한다.
- [ ] [mobile-tabs] mobile 상단탭 기반 프로젝트 관리 구조를 정의한다.
- [ ] [desktop-tabs] Flutter 앱 desktop 탭 기반 프로젝트 관리 구조를 정의한다.
@ -46,6 +51,16 @@ Project Workspace Management UX
- [ ] IOP 실행 결과, task 상태, 파일 변경 결과가 NomadCode UX에서 어떻게 보이는지 최소 기준이 정리되어 있다.
- [ ] 기존 Project/Session/Workspace 방향을 대체하지 않고 보강하는 Milestone으로 읽힌다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외
- IOP 내부 모델 라우팅, 모델 프로파일, RAG, MCP, output validation 구현

View file

@ -0,0 +1,24 @@
# Phase: Workflow Core
## 상태
[진행중]
## 목표
Plane 통신 토대와 provider-neutral pipeline 설계 이후 실제 상태 변화를 기준으로 task lifecycle, enqueue/running/completed/failed 흐름을 안정화한다. retry, timeout, notification event의 최소 구조를 추가해 core workflow의 운영 기준을 만든다.
## Milestone 흐름
완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다.
완료, 검토중, 진행중, 계획 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
- [진행중] Workflow Core
- 경로: `agent-ops/roadmap/phase/workflow-core/milestones/workflow-core.md`
- 요약: task lifecycle, 상태 전이 책임, 실패 정책, retry/timeout, notification event 모델을 안정화한다.
## Phase 경계
- Workflow Core는 canonical task lifecycle, retry/timeout envelope, heartbeat, terminal 상태 기록을 소유한다.
- 외부 협업 도구 notification 발송, provider별 실제 comment/status 전송, 복잡한 workflow DSL은 후속 Phase로 넘긴다.
- IOP/A2A/model runtime 내부 정책은 외부 실행 표면 또는 adapter 책임으로 둔다.

View file

@ -1,16 +1,17 @@
# Workflow Core
# Milestone: Workflow Core
## 위치
- Roadmap: `agent-ops/roadmap/ROADMAP.md`
- Phase: `agent-ops/roadmap/phase/workflow-core/PHASE.md`
## 목표
Plane 통신 토대와 별도 pipeline 설계가 정리된 뒤 실제 상태 변화를 기준으로 task 상태 전이를 정리하고 enqueue / running / completed / failed 흐름을 안정화한다. retry, timeout, notification event의 기본 구조를 추가해 core workflow의 운영 기준을 만든다.
## 단계
Workflow Core
## 상태
진행
[진행중]
## 구현 잠금
@ -27,6 +28,10 @@ Workflow Core
## 필수 기능
### Epic: [workflow-core] Workflow lifecycle stability
Core task lifecycle과 상태 전이 책임, 실패/재시도/timeout/notification event 기준을 안정화한다.
- [ ] [lifecycle-model] task lifecycle 상태 모델 점검
- [ ] [transition-ownership] scheduler와 workflow service의 상태 전이 책임 정리
- [ ] [failure-policy] 실패 task 처리 기준
@ -40,6 +45,16 @@ Workflow Core
- [ ] retry 정책의 최소 동작 방식이 구현되거나 명확히 문서화된다.
- [ ] notification event가 workflow 상태 변화와 연결된다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외
- 실제 외부 협업 도구로 notification 발송

View file

@ -1,6 +1,28 @@
# nomadcode-app
nomadcode-app Flutter project.
The Flutter-first NomadCode client application.
## Mattermost Push Notification Integration
Mattermost push notification delivery is implemented via the local `mattermost_push_plugin` path dependency.
### Architecture & Responsibility Split
To maintain a clean and maintainable codebase, the responsibilities are split between the host application and the plugin as follows:
#### Host Application (`apps/mobile`) Responsibilities:
- **Firebase Configuration**: Contains the standard Firebase configuration files (`android/app/google-services.json`) and performs the standard `Firebase.initializeApp()` call in Dart main.
- **Mattermost Authentication**: Handles login and session lifecycles, and securely registers the user authentication token and server signing key to the plugin by calling `MattermostPushPlugin.instance.setAuthToken()` and `MattermostPushPlugin.instance.setSigningKey()`.
- **Navigation Callbacks**: Registers standard Flutter navigation callbacks `onNavigateToChannel` and `onNavigateToThread` to handle workspace channel and CRT thread routing upon user interaction.
- **FCM Token Sync**: Listens to device token registrations via `onDeviceTokenReady` and registers the token with the Mattermost server.
#### Mattermost Push Plugin (`mattermost_push_plugin`) Responsibilities:
- **Native Android FCM Service**: Intercepts background/foreground FCM notifications securely via a custom Kotlin FCM service.
- **Signature Verification**: Validates incoming push payload signatures against stored public signing keys (using `JJWT`).
- **System Notification Builder**: Displays structured notifications with local avatar caches, thread summaries, and action buttons.
- **Inline Reply / Dismiss Actions**: Receives native background actions, records responses in a Room database, and forwards replies/ACKs directly to the Mattermost API via custom OkHttp adapters.
---
## Getting Started

View file

@ -1,7 +1,6 @@
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-kapt")
// 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")
@ -48,25 +47,4 @@ flutter {
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
// Firebase BOM (버전 통합 관리)
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
// JWT 서명 검증 (mattermost와 동일 버전)
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-orgjson:0.12.5") {
exclude(group = "org.json", module = "json")
}
// HTTP 클라이언트
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Kotlin Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Room DB (로컬 캐시용, Kotlin 2.2.0 지원을 위해 최신 RC 버전 사용)
implementation("androidx.room:room-runtime:2.7.0-rc01")
implementation("androidx.room:room-ktx:2.7.0-rc01")
kapt("androidx.room:room-compiler:2.7.0-rc01")
}

View file

@ -1,13 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application
android:label="nomadcode-app"
android:name=".MainApplication"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
<activity
@ -37,42 +32,6 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Flutter firebase_messaging 플러그인 컴포넌트 비활성화 (네이티브 서비스 사용) -->
<receiver
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
tools:node="remove" />
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingBackgroundService"
tools:node="remove" />
<provider
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingInitProvider"
android:authorities="${applicationId}.flutterfirebasemessaginginitprovider"
tools:node="remove" />
<!-- Firebase Messaging Service (네이티브) -->
<service
android:name=".MattermostFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
<!-- 알림 삭제 서비스 -->
<service
android:name=".NotificationDismissService"
android:enabled="true"
android:exported="false" />
<!-- 알림 답장 브로드캐스트 리시버 -->
<receiver
android:name=".NotificationReplyBroadcastReceiver"
android:enabled="true"
android:exported="false" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View file

@ -1,35 +0,0 @@
package com.tokilabs.mattermost
import android.app.Activity
import android.app.Application
import android.os.Bundle
/**
* 포그라운드/백그라운드 상태 추적.
* CustomPushNotification의 mAppLifecycleFacade.isAppVisible() 대응.
*/
object AppLifecycleTracker : Application.ActivityLifecycleCallbacks {
var isInForeground = false
private set
private var activeActivities = 0
override fun onActivityStarted(activity: Activity) {
activeActivities++
isInForeground = true
}
override fun onActivityStopped(activity: Activity) {
activeActivities--
if (activeActivities <= 0) {
activeActivities = 0
isInForeground = false
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
}

View file

@ -2,118 +2,11 @@ package com.tokilabs.mattermost
import android.os.Bundle
import androidx.core.view.WindowCompat
import com.tokilabs.mattermost.helpers.DatabaseHelper
import com.tokilabs.mattermost.helpers.Network
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
/**
* Flutter 메인 액티비티.
* - Flutter EventChannel 등록 (네이티브 알림 이벤트 Dart 전달)
* - Flutter MethodChannel 등록 (Dart 네이티브 토큰 저장 )
* - FlutterEngineCache에 엔진 등록 (백그라운드에서 Flutter 초기화 여부 확인용)
*/
class MainActivity : FlutterActivity() {
companion object {
const val NOTIFICATION_EVENT_CHANNEL = "com.tokilabs.mattermost/notifications"
const val NOTIFICATION_METHOD_CHANNEL = "com.tokilabs.mattermost/notification_actions"
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// FlutterEngineCache에 등록 → 백그라운드 서비스에서 초기화 여부 확인 가능
FlutterEngineCache.getInstance()
.put(MattermostFirebaseMessagingService.FLUTTER_ENGINE_ID, flutterEngine)
// EventChannel: 네이티브 알림 → Dart
EventChannel(flutterEngine.dartExecutor.binaryMessenger, NOTIFICATION_EVENT_CHANNEL)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
MattermostFirebaseMessagingService.eventSink = events
}
override fun onCancel(arguments: Any?) {
MattermostFirebaseMessagingService.eventSink = null
}
})
// MethodChannel: Dart → 네이티브 (토큰 저장, 채널 클리어 등)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, NOTIFICATION_METHOD_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"saveDeviceToken" -> {
val token = call.argument<String>("token")
if (token != null) {
DatabaseHelper.getInstance()?.saveDeviceToken(token)
result.success(null)
} else {
result.error("INVALID_ARG", "token is null", null)
}
}
"getDeviceToken" -> {
result.success(DatabaseHelper.getInstance()?.getDeviceToken())
}
// 로그인 성공 시 Dart에서 호출 → 서버 URL + 인증 토큰 저장
"setAuthToken" -> {
val serverUrl = call.argument<String>("serverUrl")
val token = call.argument<String>("token")
val identifier = call.argument<String>("identifier")
if (serverUrl != null && token != null) {
Network.setToken(serverUrl, token)
DatabaseHelper.getInstance()?.saveServerUrl(serverUrl, identifier)
result.success(null)
} else {
result.error("INVALID_ARG", "serverUrl or token is null", null)
}
}
// 서버의 JWT 서명 검증용 공개키 저장
"setSigningKey" -> {
val serverUrl = call.argument<String>("serverUrl")
val signingKey = call.argument<String>("signingKey")
if (serverUrl != null && signingKey != null) {
getSharedPreferences("mattermost_signing", MODE_PRIVATE)
.edit()
.putString("signing_key_$serverUrl", signingKey)
.apply()
result.success(null)
} else {
result.error("INVALID_ARG", "serverUrl or signingKey is null", null)
}
}
// 로그아웃 시 토큰 제거
"clearAuthToken" -> {
val serverUrl = call.argument<String>("serverUrl")
if (serverUrl != null) {
Network.clearToken(serverUrl)
result.success(null)
} else {
result.error("INVALID_ARG", "serverUrl is null", null)
}
}
else -> result.notImplemented()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
// 알림 탭으로 앱 진입 시 Intent 데이터를 Flutter로 전달
intent?.extras?.let { extras ->
val data = mutableMapOf<String, Any?>()
for (key in extras.keySet()) {
data[key] = extras.get(key)
}
if (data.isNotEmpty()) {
MattermostFirebaseMessagingService.eventSink?.success(
data + mapOf("type" to (data["type"] ?: "opened"), "userInteraction" to true)
)
}
}
}
}

View file

@ -1,27 +0,0 @@
package com.tokilabs.mattermost
import android.app.Application
import com.tokilabs.mattermost.helpers.CustomPushNotificationHelper
import com.tokilabs.mattermost.helpers.DatabaseHelper
/**
* Application 클래스.
* - DatabaseHelper 초기화
* - 알림 채널 생성
* - AppLifecycleTracker 등록
*/
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
// 앱 생명주기 추적 (포그라운드/백그라운드 감지)
registerActivityLifecycleCallbacks(AppLifecycleTracker)
// DB 초기화
DatabaseHelper.init(this)
// Android 8+ 알림 채널 생성
CustomPushNotificationHelper.createNotificationChannels(this)
}
}

View file

@ -1,268 +0,0 @@
package com.tokilabs.mattermost
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.tokilabs.mattermost.helpers.CustomPushNotificationHelper
import com.tokilabs.mattermost.helpers.DatabaseHelper
import com.tokilabs.mattermost.helpers.Network
import com.tokilabs.mattermost.helpers.NotificationHelper
import com.tokilabs.mattermost.helpers.PushNotificationDataHelper
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.EventChannel
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
/**
* Firebase FCM 메시지 수신 처리.
* mattermost-mobile의 CustomPushNotification.kt 이식 버전.
* RN 브릿지(WIX PushNotification) FirebaseMessagingService + Flutter EventChannel 으로 교체.
*/
class MattermostFirebaseMessagingService : FirebaseMessagingService() {
private val dataHelper by lazy { PushNotificationDataHelper(this) }
companion object {
private const val TAG = "MattermostFCM"
const val FLUTTER_ENGINE_ID = "main_engine"
const val NOTIFICATION_CHANNEL = "com.tokilabs.mattermost/notifications"
// Flutter EventChannel sink (MainActivity에서 설정)
var eventSink: EventChannel.EventSink? = null
}
/**
* FCM 토큰 갱신 호출.
* Flutter 쪽으로 토큰 전달.
*/
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.i(TAG, "New FCM token: $token")
// Flutter EventChannel이 준비된 경우 토큰 전달 (Main 스레드에서 실행)
Handler(Looper.getMainLooper()).post {
eventSink?.success(mapOf("type" to "token_refresh", "token" to token))
}
}
/**
* FCM 메시지 수신 호출.
* CustomPushNotification.onReceived() 대응.
*/
@OptIn(DelicateCoroutinesApi::class)
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
Log.i(TAG, ">>> onMessageReceived ENTER, dataSize=${remoteMessage.data.size}")
try {
val data = remoteMessage.data
if (data.isEmpty()) return
val bundle = Bundle().apply {
data.forEach { (k, v) -> putString(k, v) }
}
val type = bundle.getString("type")
val ackId = bundle.getString("ack_id")
val postId = bundle.getString("post_id")
val channelId = bundle.getString("channel_id")
val signature = bundle.getString("signature")
val isIdLoaded = bundle.getString("id_loaded") == "true"
val notificationId = try {
NotificationHelper.getNotificationId(bundle)
} catch (e: Exception) {
Log.e(TAG, "getNotificationId failed: ${e.message}")
System.currentTimeMillis().toInt()
}
// server_url 해석: 직접 포함 → server_id로 DB 조회 → 단일 서버 fallback
var serverUrl = bundle.getString("server_url")
if (serverUrl == null) {
try {
val dbHelper = DatabaseHelper.getInstance()
val serverId = bundle.getString("server_id")
if (serverId != null && dbHelper != null) {
serverUrl = dbHelper.getServerUrlForIdentifier(serverId)
}
if (serverUrl == null) {
serverUrl = dbHelper?.onlyServerUrl
}
} catch (e: Exception) {
Log.e(TAG, "DB lookup failed: ${e.message}")
}
if (serverUrl != null) {
bundle.putString("server_url", serverUrl)
}
}
Log.i(TAG, "onMessageReceived type=$type channelId=$channelId ackId=$ackId serverUrl=$serverUrl")
GlobalScope.launch {
try {
handlePushNotificationInCoroutine(
serverUrl, type, channelId, ackId,
isIdLoaded, notificationId, postId, signature, bundle
)
} catch (e: Exception) {
Log.e(TAG, "Error handling notification: ${e.message}")
e.printStackTrace()
}
}
} catch (e: Exception) {
Log.e(TAG, ">>> onMessageReceived CRASH: ${e.message}")
e.printStackTrace()
}
}
/**
* 비동기 알림 처리 코루틴.
* CustomPushNotification.handlePushNotificationInCoroutine() 대응.
*/
private suspend fun handlePushNotificationInCoroutine(
serverUrl: String?,
type: String?,
channelId: String?,
ackId: String?,
isIdLoaded: Boolean,
notificationId: Int,
postId: String?,
signature: String?,
bundle: Bundle
) {
var currentBundle = bundle
// ACK 전송
if (ackId != null && serverUrl != null) {
val response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded)
if (isIdLoaded && response != null) {
if (!currentBundle.containsKey("server_url")) {
response.putString("server_url", serverUrl)
}
currentBundle.putAll(response)
}
}
// JWT 서명 검증
if (!CustomPushNotificationHelper.verifySignature(this, signature, serverUrl, ackId)) {
Log.i(TAG, "Notification skipped: signature verification failed")
return
}
finishProcessingNotification(serverUrl, type, channelId, notificationId, currentBundle)
}
/**
* 알림 최종 처리.
* CustomPushNotification.finishProcessingNotification() 대응.
*/
private suspend fun finishProcessingNotification(
serverUrl: String?,
type: String?,
channelId: String?,
notificationId: Int,
bundle: Bundle
) {
var currentBundle = bundle
val isFlutterRunning = isFlutterEngineRunning()
when (type) {
CustomPushNotificationHelper.PUSH_TYPE_MESSAGE,
CustomPushNotificationHelper.PUSH_TYPE_SESSION -> {
// 앱이 포그라운드가 아닐 때 시스템 알림 표시
if (!isFlutterRunning || !isAppInForeground()) {
var createSummary = type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE
if (type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE && channelId != null) {
serverUrl?.let {
val notificationResult = dataHelper.fetchAndStoreDataForPushNotification(currentBundle, isFlutterRunning)
notificationResult?.let { result ->
currentBundle.putBundle("data", result)
}
}
createSummary = NotificationHelper.addNotificationToPreferences(this, notificationId, currentBundle)
}
buildAndPostNotification(notificationId, createSummary, currentBundle)
}
}
CustomPushNotificationHelper.PUSH_TYPE_CLEAR ->
NotificationHelper.clearChannelOrThreadNotifications(this, currentBundle)
}
// Flutter가 실행 중이면 EventChannel로 알림 데이터 전달
if (isFlutterRunning) {
notifyReceivedToFlutter(currentBundle)
}
}
/**
* Flutter EventChannel로 알림 이벤트 전송.
* CustomPushNotification.notifyReceivedToJS() 대응.
*/
private fun notifyReceivedToFlutter(bundle: Bundle) {
val data = bundleToMap(bundle)
Log.i(TAG, "Sending notification event to Flutter: type=${data["type"]}")
Handler(Looper.getMainLooper()).post {
eventSink?.success(data)
}
}
/**
* 시스템 알림 빌드 표시.
*/
private fun buildAndPostNotification(notificationId: Int, createSummary: Boolean, bundle: Bundle) {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtras(bundle)
}
val pendingIntent = PendingIntent.getActivity(
this, notificationId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = CustomPushNotificationHelper.createNotificationBuilder(this, pendingIntent, bundle, false).build()
if (createSummary) {
val summary = CustomPushNotificationHelper.createNotificationBuilder(this, pendingIntent, bundle, true).build()
NotificationHelper.postNotification(this, summary, notificationId + 1)
}
NotificationHelper.postNotification(this, notification, notificationId)
}
/**
* Flutter 엔진 실행 여부 확인.
* CustomPushNotification의 isReactInitialized() 대응.
*/
private fun isFlutterEngineRunning(): Boolean {
return FlutterEngineCache.getInstance().contains(FLUTTER_ENGINE_ID)
}
/**
* 포그라운드 여부 확인.
*/
private fun isAppInForeground(): Boolean {
return AppLifecycleTracker.isInForeground
}
/**
* Bundle Map 변환 (EventChannel 전송용).
*/
private fun bundleToMap(bundle: Bundle): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
for (key in bundle.keySet()) {
val value = bundle.get(key)
map[key] = when (value) {
is Bundle -> bundleToMap(value)
else -> value
}
}
return map
}
}

View file

@ -1,40 +0,0 @@
package com.tokilabs.mattermost;
import android.app.IntentService;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import com.tokilabs.mattermost.helpers.NotificationHelper;
/**
* 사용자가 알림을 스와이프(해제) 처리.
* mattermost-mobile의 NotificationDismissService.java 이식 버전.
* com.wix.reactnativenotifications.core.NotificationIntentAdapter
* 표준 Intent extras 방식으로 교체.
*/
@SuppressWarnings("deprecation")
public class NotificationDismissService extends IntentService {
private static final String TAG = "NotifDismissService";
private static final String PUSH_NOTIFICATION_EXTRA_NAME = "pushNotification";
public NotificationDismissService() {
super("notificationDismissService");
}
@Override
protected void onHandleIntent(Intent intent) {
if (intent == null) return;
final Context context = getApplicationContext();
// WIX NotificationIntentAdapter 대신 표준 extras 방식으로 번들 추출
final Bundle bundle = intent.getBundleExtra(PUSH_NOTIFICATION_EXTRA_NAME);
if (bundle != null) {
NotificationHelper.INSTANCE.dismissNotification(context, bundle);
Log.i(TAG, "Dismiss notification id=" + NotificationHelper.INSTANCE.getNotificationId(bundle));
}
}
}

View file

@ -1,152 +0,0 @@
package com.tokilabs.mattermost;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.RemoteInput;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.Person;
import com.tokilabs.mattermost.helpers.CustomPushNotificationHelper;
import com.tokilabs.mattermost.helpers.Network;
import com.tokilabs.mattermost.helpers.NotificationHelper;
import org.json.JSONException;
import org.json.JSONObject;
/**
* 알림에서 직접 답장(인라인 리플) 처리.
* mattermost-mobile의 NotificationReplyBroadcastReceiver.java 이식 버전.
* com.facebook.react.bridge.* + Network.post() OkHttp 직접 사용으로 교체.
* WIX NotificationIntentAdapter / PushNotificationProps 표준 Intent/Bundle 방식으로 교체.
*/
public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
private static final String TAG = "NotifReplyReceiver";
private Context mContext;
private Bundle bundle;
private NotificationManager notificationManager;
@Override
public void onReceive(Context context, Intent intent) {
try {
final CharSequence message = getReplyMessage(intent);
if (message == null) return;
mContext = context;
bundle = intent.getBundleExtra(CustomPushNotificationHelper.NOTIFICATION);
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);
}
} catch (Exception e) {
Log.e(TAG, "onReceive error: " + e.getMessage());
e.printStackTrace();
}
}
protected void replyToMessage(final String serverUrl, final int notificationId,
final CharSequence message) {
final String channelId = bundle.getString("channel_id");
final String postId = bundle.getString("post_id");
String rootId = bundle.getString("root_id");
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);
final String finalRootId = rootId;
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;
}
);
} catch (JSONException e) {
Log.e(TAG, "JSON error: " + e.getMessage());
onReplyFailed(notificationId);
}
}
protected void onReplyFailed(int notificationId) {
recreateNotification(notificationId, "Message failed to send.");
}
protected void onReplySuccess(int notificationId, final CharSequence message) {
recreateNotification(notificationId, message);
}
private void recreateNotification(int notificationId, final CharSequence message) {
// 표준 PendingIntent 생성 (WIX NotificationIntentAdapter 제거)
Intent openIntent = new Intent(mContext, MainActivity.class);
openIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
openIntent.putExtras(bundle);
PendingIntent pendingIntent = PendingIntent.getActivity(
mContext, notificationId, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder builder =
CustomPushNotificationHelper.createNotificationBuilder(mContext, pendingIntent, bundle, false);
Notification notification = builder.build();
NotificationCompat.MessagingStyle messagingStyle =
NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification);
if (messagingStyle != null) {
messagingStyle.addMessage(message, System.currentTimeMillis(), (Person) null);
notification = builder.setStyle(messagingStyle).build();
}
notificationManager.notify(notificationId, notification);
}
private CharSequence getReplyMessage(Intent intent) {
Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
if (remoteInput != null) {
return remoteInput.getCharSequence(CustomPushNotificationHelper.KEY_TEXT_REPLY);
}
return null;
}
}

View file

@ -1,65 +0,0 @@
package com.tokilabs.mattermost;
import android.os.Bundle;
import android.util.Log;
import com.tokilabs.mattermost.helpers.Network;
import org.json.JSONObject;
import java.util.Objects;
import okhttp3.Response;
/**
* 알림 수신 확인(ACK) 서버 전송.
* mattermost-mobile의 ReceiptDelivery.java 이식 버전.
* com.facebook.react.bridge.Arguments / WritableMap org.json.JSONObject / android.os.Bundle 교체.
*/
public class ReceiptDelivery {
private static final String TAG = "ReceiptDelivery";
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));
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);
}
} catch (Exception e) {
Log.e(TAG, "Send receipt delivery failed: " + e.getMessage());
e.printStackTrace();
return null;
}
}
public static Bundle parseAckResponse(JSONObject jsonResponse) {
try {
Bundle bundle = new Bundle();
for (String key : ACK_KEYS) {
if (jsonResponse.has(key)) {
bundle.putString(key, jsonResponse.getString(key));
}
}
return bundle;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}

View file

@ -1,28 +0,0 @@
package com.tokilabs.mattermost.helpers
import android.graphics.Bitmap
import android.util.LruCache
/**
* 사용자 아바타 비트맵 캐시.
* mattermost-mobile의 BitmapCache 이식.
*/
class BitmapCache {
private data class CacheKey(val userId: String, val serverUrl: String)
private data class CacheEntry(val bitmap: Bitmap, val lastUpdateAt: Double)
private val cache = LruCache<CacheKey, CacheEntry>(20)
fun bitmap(userId: String, lastUpdateAt: Double, serverUrl: String): Bitmap? {
val entry = cache.get(CacheKey(userId, serverUrl)) ?: return null
return if (entry.lastUpdateAt == lastUpdateAt) entry.bitmap else null
}
fun insertBitmap(bitmap: Bitmap, userId: String, lastUpdateAt: Double, serverUrl: String) {
cache.put(CacheKey(userId, serverUrl), CacheEntry(bitmap, lastUpdateAt))
}
fun removeBitmap(userId: String, serverUrl: String) {
cache.remove(CacheKey(userId, serverUrl))
}
}

View file

@ -1,530 +0,0 @@
package com.tokilabs.mattermost.helpers;
import android.annotation.SuppressLint;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.Person;
import androidx.core.app.RemoteInput;
import androidx.core.graphics.drawable.IconCompat;
import com.tokilabs.mattermost.NotificationDismissService;
import com.tokilabs.mattermost.NotificationReplyBroadcastReceiver;
import com.tokilabs.mattermost.R;
import com.tokilabs.mattermost.helpers.db.MattermostDatabase;
import java.io.IOException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Date;
import java.util.Objects;
import io.jsonwebtoken.IncorrectClaimException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MissingClaimException;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
/**
* 알림 UI 생성 JWT 서명 검증.
* mattermost-mobile의 com.mattermost.helpers.CustomPushNotificationHelper 이식 버전.
* com.facebook.react.bridge.* android.os.Bundle / 표준 Java 타입으로 교체.
* WatermelonDB DatabaseHelper (Room 기반) 교체.
*/
public class CustomPushNotificationHelper {
public static final String CHANNEL_HIGH_IMPORTANCE_ID = "channel_01";
public static final String CHANNEL_MIN_IMPORTANCE_ID = "channel_02";
public static final String KEY_TEXT_REPLY = "CAN_REPLY";
public static final String NOTIFICATION_ID = "notificationId";
public static final String NOTIFICATION = "notification";
public static final String PUSH_TYPE_MESSAGE = "message";
public static final String PUSH_TYPE_CLEAR = "clear";
public static final String PUSH_TYPE_SESSION = "session";
public static final String CATEGORY_CAN_REPLY = "CAN_REPLY";
private static final String TAG = "PushNotifHelper";
private static NotificationChannel mHighImportanceChannel;
private static NotificationChannel mMinImportanceChannel;
private static final OkHttpClient client = new OkHttpClient();
private static final BitmapCache bitmapCache = new BitmapCache();
//
// Public API
//
public static NotificationCompat.Builder createNotificationBuilder(
Context context, PendingIntent intent, Bundle bundle, boolean createSummary) {
final NotificationCompat.Builder notification =
new NotificationCompat.Builder(context, CHANNEL_HIGH_IMPORTANCE_ID);
String postId = bundle.getString("post_id");
String rootId = bundle.getString("root_id");
String channelId = bundle.getString("channel_id");
int notificationId = postId != null ? postId.hashCode() : NotificationHelper.MESSAGE_NOTIFICATION_ID;
boolean isCrtEnabled = bundle.containsKey("is_crt_enabled") &&
Objects.equals(bundle.getString("is_crt_enabled"), "true");
String groupId = isCrtEnabled && !TextUtils.isEmpty(rootId) ? rootId : channelId;
addNotificationExtras(notification, bundle);
setNotificationIcons(context, notification, bundle);
setNotificationMessagingStyle(context, notification, bundle);
setNotificationGroup(notification, groupId, createSummary);
setNotificationBadgeType(notification);
setNotificationChannel(context, notification);
setNotificationDeleteIntent(context, notification, bundle, notificationId);
addNotificationReplyAction(context, notification, bundle, notificationId);
notification
.setContentIntent(intent)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPriority(Notification.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true);
return notification;
}
public static void createNotificationChannels(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
if (mHighImportanceChannel == null) {
mHighImportanceChannel = new NotificationChannel(
CHANNEL_HIGH_IMPORTANCE_ID, "High Importance", NotificationManager.IMPORTANCE_HIGH);
mHighImportanceChannel.setShowBadge(true);
notificationManager.createNotificationChannel(mHighImportanceChannel);
}
if (mMinImportanceChannel == null) {
mMinImportanceChannel = new NotificationChannel(
CHANNEL_MIN_IMPORTANCE_ID, "Min Importance", NotificationManager.IMPORTANCE_MIN);
mMinImportanceChannel.setShowBadge(true);
notificationManager.createNotificationChannel(mMinImportanceChannel);
}
}
/**
* JWT 서명 검증.
* 원본 로직 그대로 유지 (DatabaseHelper만 Room 기반으로 교체).
*/
public static boolean verifySignature(final Context context, String signature,
String serverUrl, String ackId) {
if (signature == null) {
Log.i(TAG, "No signature in the notification (backward compat)");
return true;
}
if (serverUrl == null) {
Log.i(TAG, "No server_url available, skipping signature check");
return true;
}
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
if (dbHelper == null) {
Log.i(TAG, "Cannot access the database, skipping signature check");
return true;
}
MattermostDatabase db = dbHelper.getDatabaseForServer(context, serverUrl);
if (db == null) {
Log.i(TAG, "Cannot access the server database, skipping signature check");
return true;
}
if ("NO_SIGNATURE".equals(signature)) {
String version = db.queryConfigServerVersion();
if (version == null) {
Log.i(TAG, "No server version");
return false;
}
if (!version.matches("[0-9]+(\\.[0-9]+)*")) {
Log.i(TAG, "Invalid server version");
return false;
}
String[] parts = version.split("\\.");
int major = parts.length > 0 ? Integer.parseInt(parts[0]) : 0;
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int patch = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
int[][] targets = {{9,8,0},{9,7,3},{9,6,3},{9,5,5},{8,1,14}};
boolean rejected = false;
for (int i = 0; i < targets.length; i++) {
boolean first = i == 0;
int majorTarget = targets[i][0];
int minorTarget = targets[i][1];
int patchTarget = targets[i][2];
if (major > majorTarget) { rejected = first; break; }
if (major < majorTarget) { continue; }
if (minor > minorTarget) { rejected = first; break; }
if (minor < minorTarget) { continue; }
if (patch >= patchTarget) { rejected = true; break; }
return true;
}
if (rejected) {
Log.i(TAG, "Server version should send signature");
return false;
}
return true;
}
String signingKey = db.queryConfigSigningKey();
if (signingKey == null) {
// Room DB에 없으면 SharedPreferences에서 조회
signingKey = context.getSharedPreferences("mattermost_signing", Context.MODE_PRIVATE)
.getString("signing_key_" + serverUrl, null);
}
if (signingKey == null) {
Log.i(TAG, "No signing key, skipping signature check");
return true;
}
try {
byte[] encoded = Base64.decode(signingKey, 0);
KeyFactory kf = KeyFactory.getInstance("EC");
PublicKey pubKey = kf.generatePublic(new X509EncodedKeySpec(encoded));
String storedDeviceToken = dbHelper.getDeviceToken();
if (storedDeviceToken == null) {
Log.i(TAG, "No device token stored");
return false;
}
String[] tokenParts = storedDeviceToken.split(":", 2);
if (tokenParts.length != 2) {
Log.i(TAG, "Wrong stored device token format");
return false;
}
String deviceToken = tokenParts[1];
if (deviceToken.isEmpty()) {
Log.i(TAG, "Empty stored device token");
return false;
}
Jwts.parser()
.require("ack_id", ackId)
.require("device_id", deviceToken)
.verifyWith((PublicKey) pubKey)
.build()
.parseSignedClaims(signature);
} catch (MissingClaimException e) {
Log.i(TAG, "Missing claim: " + e.getMessage());
return false;
} catch (IncorrectClaimException e) {
Log.i(TAG, "Incorrect claim: " + e.getMessage());
return false;
} catch (JwtException e) {
Log.i(TAG, "Cannot verify JWT: " + e.getMessage());
return false;
} catch (Exception e) {
Log.i(TAG, "Exception while parsing JWT: " + e.getMessage());
return false;
}
return true;
}
//
// Private helpers (원본 로직 그대로)
//
private static void addNotificationExtras(NotificationCompat.Builder notification, Bundle bundle) {
Bundle userInfoBundle = bundle.getBundle("userInfo");
if (userInfoBundle == null) userInfoBundle = new Bundle();
putIfNotNull(userInfoBundle, "channel_id", bundle.getString("channel_id"));
putIfNotNull(userInfoBundle, "post_id", bundle.getString("post_id"));
putIfNotNull(userInfoBundle, "root_id", bundle.getString("root_id"));
putIfNotNull(userInfoBundle, "is_crt_enabled", bundle.getString("is_crt_enabled"));
putIfNotNull(userInfoBundle, "server_url", bundle.getString("server_url"));
notification.addExtras(userInfoBundle);
}
@SuppressLint("UnspecifiedImmutableFlag")
private static void addNotificationReplyAction(Context context, NotificationCompat.Builder notification,
Bundle bundle, int notificationId) {
String postId = bundle.getString("post_id");
String serverUrl = bundle.getString("server_url");
boolean canReply = bundle.containsKey("category") &&
Objects.equals(bundle.getString("category"), CATEGORY_CAN_REPLY);
if (TextUtils.isEmpty(postId) || serverUrl == null || !canReply) return;
Intent replyIntent = new Intent(context, NotificationReplyBroadcastReceiver.class);
replyIntent.setAction(KEY_TEXT_REPLY);
replyIntent.putExtra(NOTIFICATION_ID, notificationId);
replyIntent.putExtra(NOTIFICATION, bundle);
PendingIntent replyPendingIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
replyPendingIntent = PendingIntent.getBroadcast(context, notificationId, replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
} else {
replyPendingIntent = PendingIntent.getBroadcast(context, notificationId, replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
}
RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY).setLabel("Reply").build();
NotificationCompat.Action replyAction = new NotificationCompat.Action.Builder(
R.drawable.ic_notif_action_reply, "Reply", replyPendingIntent)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.build();
notification.setShowWhen(true).addAction(replyAction);
}
private static void setNotificationMessagingStyle(Context context,
NotificationCompat.Builder notification, Bundle bundle) {
notification.setStyle(getMessagingStyle(context, bundle));
}
private static NotificationCompat.MessagingStyle getMessagingStyle(Context context, Bundle bundle) {
final String serverUrl = bundle.getString("server_url");
final String type = bundle.getString("type");
String urlOverride = bundle.getString("override_icon_url");
Person.Builder sender = new Person.Builder().setKey("me").setName("Me");
if (serverUrl != null && type != null && !type.equals(PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride);
if (avatar != null) sender.setIcon(IconCompat.createWithBitmap(avatar));
} catch (IOException e) {
e.printStackTrace();
}
}
NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(sender.build());
String conversationTitle = getConversationTitle(bundle);
setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle);
addMessagingStyleMessages(context, messagingStyle, conversationTitle, bundle);
return messagingStyle;
}
private static void addMessagingStyleMessages(Context context,
NotificationCompat.MessagingStyle messagingStyle,
String conversationTitle, Bundle bundle) {
String message = bundle.getString("message", bundle.getString("body"));
String senderId = bundle.getString("sender_id");
final String serverUrl = bundle.getString("server_url");
final String type = bundle.getString("type");
String urlOverride = bundle.getString("override_icon_url");
if (senderId == null) senderId = "sender_id";
String senderName = getSenderName(bundle);
if (conversationTitle == null || !TextUtils.isEmpty(senderName.trim())) {
message = removeSenderNameFromMessage(message, senderName);
}
long timestamp = new Date().getTime();
Person.Builder sender = new Person.Builder().setKey(senderId).setName(senderName);
if (serverUrl != null && type != null && !type.equals(PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) sender.setIcon(IconCompat.createWithBitmap(avatar));
} catch (IOException e) {
e.printStackTrace();
}
}
messagingStyle.addMessage(message, timestamp, sender.build());
}
private static void setNotificationIcons(Context context, NotificationCompat.Builder notification, Bundle bundle) {
String channelName = getConversationTitle(bundle);
String senderName = bundle.getString("sender_name");
String serverUrl = bundle.getString("server_url");
String urlOverride = bundle.getString("override_icon_url");
notification.setSmallIcon(R.mipmap.ic_notification);
if (serverUrl != null && channelName.equals(senderName)) {
try {
String senderId = bundle.getString("sender_id");
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) notification.setLargeIcon(avatar);
} catch (IOException e) {
e.printStackTrace();
}
}
}
private static void setNotificationGroup(NotificationCompat.Builder notification,
String channelId, boolean setAsSummary) {
notification.setGroup(channelId);
if (setAsSummary) notification.setGroupSummary(true);
}
private static void setNotificationBadgeType(NotificationCompat.Builder notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notification.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE);
}
}
private static void setNotificationChannel(Context context, NotificationCompat.Builder notification) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
if (mHighImportanceChannel == null) createNotificationChannels(context);
notification.setChannelId(mHighImportanceChannel.getId());
}
@SuppressLint("UnspecifiedImmutableFlag")
private static void setNotificationDeleteIntent(Context context, NotificationCompat.Builder notification,
Bundle bundle, int notificationId) {
final String PUSH_NOTIFICATION_EXTRA_NAME = "pushNotification";
Intent delIntent = new Intent(context, NotificationDismissService.class);
delIntent.putExtra(NOTIFICATION_ID, notificationId);
delIntent.putExtra(PUSH_NOTIFICATION_EXTRA_NAME, bundle);
PendingIntent deleteIntent = PendingIntent.getService(context,
(int) System.currentTimeMillis(), delIntent,
PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
notification.setDeleteIntent(deleteIntent);
}
private static void setMessagingStyleConversationTitle(
NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) {
String channelName = getConversationTitle(bundle);
String senderName = bundle.getString("sender_name");
if (TextUtils.isEmpty(senderName)) senderName = getSenderName(bundle);
if (conversationTitle != null && !channelName.equals(senderName)) {
messagingStyle.setConversationTitle(conversationTitle);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
messagingStyle.setGroupConversation(true);
}
}
}
private static String getConversationTitle(Bundle bundle) {
String title = bundle.getString("channel_name");
if (TextUtils.isEmpty(title)) title = bundle.getString("sender_name");
if (TextUtils.isEmpty(title)) title = bundle.getString("title", "");
return title;
}
private static String getSenderName(Bundle bundle) {
String senderName = bundle.getString("sender_name");
if (senderName != null) return senderName;
String channelName = bundle.getString("channel_name");
if (channelName != null && channelName.startsWith("@")) return channelName;
String message = bundle.getString("message");
if (message != null) {
String name = message.split(":")[0];
if (!name.equals(message)) return name;
}
return getConversationTitle(bundle);
}
private static String removeSenderNameFromMessage(String message, String senderName) {
int index = message.indexOf(senderName);
if (index == 0) message = message.substring(senderName.length());
return message.replaceFirst(": ", "").trim();
}
private static Bitmap getCircleBitmap(Bitmap bitmap) {
final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888);
final Canvas canvas = new Canvas(output);
final Paint paint = new Paint();
final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
final RectF rectF = new RectF(rect);
paint.setAntiAlias(true);
canvas.drawARGB(0, 0, 0, 0);
paint.setColor(Color.RED);
canvas.drawOval(rectF, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
canvas.drawBitmap(bitmap, rect, rect, paint);
bitmap.recycle();
return output;
}
private static Bitmap userAvatar(final Context context, @NonNull final String serverUrl,
final String userId, final String urlOverride) throws IOException {
try {
Response response;
Double lastUpdateAt = 0.0;
if (!TextUtils.isEmpty(urlOverride)) {
Request request = new Request.Builder().url(urlOverride).build();
response = client.newCall(request).execute();
} else {
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
if (dbHelper != null) {
MattermostDatabase db = dbHelper.getDatabaseForServer(context, serverUrl);
if (db != null) {
lastUpdateAt = db.getLastPictureUpdate(userId);
if (lastUpdateAt == null) lastUpdateAt = 0.0;
db.close();
}
}
Bitmap cached = bitmapCache.bitmap(userId, lastUpdateAt, serverUrl);
if (cached != null) {
return getCircleBitmap(cached.copy(cached.getConfig(), false));
}
bitmapCache.removeBitmap(userId, serverUrl);
String url = serverUrl.replaceAll("/+$", "") + "/api/v4/users/" + userId + "/image";
response = Network.INSTANCE.getSync(serverUrl, "api/v4/users/" + userId + "/image", null);
}
if (response.code() == 200 && response.body() != null) {
byte[] bytes = response.body().bytes();
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (TextUtils.isEmpty(urlOverride) && !TextUtils.isEmpty(userId)) {
bitmapCache.insertBitmap(bitmap.copy(bitmap.getConfig(), false), userId, lastUpdateAt, serverUrl);
}
return getCircleBitmap(bitmap);
}
return null;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
private static void putIfNotNull(Bundle bundle, String key, String value) {
if (value != null) bundle.putString(key, value);
}
}

View file

@ -1,102 +0,0 @@
package com.tokilabs.mattermost.helpers
import android.content.Context
import android.util.Log
import com.tokilabs.mattermost.helpers.db.GlobalDatabase
import com.tokilabs.mattermost.helpers.db.GlobalEntity
import com.tokilabs.mattermost.helpers.db.MattermostDatabase
import com.tokilabs.mattermost.helpers.db.ServerEntity
/**
* 서버별 DB 접근 디바이스 토큰 관리.
* mattermost-mobile의 DatabaseHelper (WatermelonDB 기반) Room DB 기반으로 교체.
*
* Global DB: Servers 목록, deviceToken
* Server DB: Config(버전/서명키), User(아바타), System(currentUserId)
*/
class DatabaseHelper private constructor(private val context: Context) {
companion object {
private const val TAG = "DatabaseHelper"
private const val DEVICE_TOKEN_KEY = "deviceToken"
@Volatile
private var instance: DatabaseHelper? = null
fun getInstance(): DatabaseHelper? = instance
fun init(context: Context): DatabaseHelper {
return instance ?: synchronized(this) {
instance ?: DatabaseHelper(context.applicationContext).also { instance = it }
}
}
}
private val globalDb by lazy { GlobalDatabase.getInstance(context) }
// ─── 디바이스 토큰 ────────────────────────────────────────────────────────
fun getDeviceToken(): String? {
return try {
globalDb.globalDao().getValue(DEVICE_TOKEN_KEY)
} catch (e: Exception) {
Log.e(TAG, "getDeviceToken error: ${e.message}")
null
}
}
fun saveDeviceToken(token: String) {
try {
globalDb.globalDao().upsert(GlobalEntity(DEVICE_TOKEN_KEY, token))
Log.i(TAG, "Device token saved")
} catch (e: Exception) {
Log.e(TAG, "saveDeviceToken error: ${e.message}")
}
}
// ─── 서버 목록 ────────────────────────────────────────────────────────────
/** 단일 서버 URL 반환 (서버가 하나만 있는 경우) */
val onlyServerUrl: String?
get() = try {
val servers = globalDb.serversDao().getAll()
if (servers.size == 1) servers.first().url else null
} catch (e: Exception) {
null
}
/** server_id(identifier)로 서버 URL 조회 */
fun getServerUrlForIdentifier(serverId: String): String? {
return try {
globalDb.serversDao().getAll()
.firstOrNull { it.identifier == serverId }?.url
} catch (e: Exception) {
null
}
}
fun saveServerUrl(serverUrl: String, identifier: String? = null) {
try {
globalDb.serversDao().upsert(
ServerEntity(url = serverUrl, dbPath = "", identifier = identifier)
)
} catch (e: Exception) {
Log.e(TAG, "saveServerUrl error: ${e.message}")
}
}
// ─── 서버별 DB ────────────────────────────────────────────────────────────
/**
* 서버 URL에 해당하는 Room DB 반환.
* JWT 검증(CustomPushNotificationHelper), 데이터 페칭(PushNotificationDataHelper)에서 사용.
*/
fun getDatabaseForServer(context: Context, serverUrl: String): MattermostDatabase? {
return try {
MattermostDatabase.getInstance(context, serverUrl)
} catch (e: Exception) {
Log.e(TAG, "getDatabaseForServer error for $serverUrl: ${e.message}")
null
}
}
}

View file

@ -1,116 +0,0 @@
package com.tokilabs.mattermost.helpers
import android.util.Log
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* OkHttp 기반 HTTP 클라이언트.
* mattermost-mobile의 com.mattermost.helpers.Network 대체.
*/
object Network {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private const val TAG = "NomadNetwork"
/**
* 인증 토큰 저장소 (DatabaseHelper에서 읽어 설정)
* key: serverUrl, value: token
*/
private val tokenCache = mutableMapOf<String, String>()
fun setToken(serverUrl: String, token: String) {
tokenCache[serverUrl] = token
}
fun clearToken(serverUrl: String) {
tokenCache.remove(serverUrl)
}
/**
* 동기 POST 요청 (코루틴 내부에서 사용)
*/
fun postSync(serverUrl: String, endpoint: String, body: JSONObject, headers: Map<String, String>? = null): Response {
val url = buildUrl(serverUrl, endpoint)
val requestBody = body.toString().toRequestBody(JSON_MEDIA_TYPE)
val requestBuilder = Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Content-Type", "application/json")
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "POST $url")
return client.newCall(requestBuilder.build()).execute()
}
/**
* 비동기 POST 요청 (BroadcastReceiver 등에서 사용)
*/
fun post(serverUrl: String, endpoint: String, body: JSONObject,
headers: Map<String, String>? = null,
onSuccess: (JSONObject?) -> Unit,
onFailure: (Exception) -> Unit) {
val url = buildUrl(serverUrl, endpoint)
val requestBody = body.toString().toRequestBody(JSON_MEDIA_TYPE)
val requestBuilder = Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Content-Type", "application/json")
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "POST async $url")
client.newCall(requestBuilder.build()).enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
Log.e(TAG, "POST failed: ${e.message}")
onFailure(e)
}
override fun onResponse(call: okhttp3.Call, response: Response) {
try {
val responseBody = response.body?.string()
val json = if (!responseBody.isNullOrEmpty()) JSONObject(responseBody) else null
onSuccess(json)
} catch (e: Exception) {
Log.e(TAG, "POST response parse error: ${e.message}")
onSuccess(null)
}
}
})
}
/**
* 동기 GET 요청
*/
fun getSync(serverUrl: String, endpoint: String, headers: Map<String, String>? = null): Response {
val url = buildUrl(serverUrl, endpoint)
val requestBuilder = Request.Builder()
.url(url)
.get()
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "GET $url")
return client.newCall(requestBuilder.build()).execute()
}
private fun buildUrl(serverUrl: String, endpoint: String): String {
val base = serverUrl.trimEnd('/')
val path = endpoint.trimStart('/')
return "$base/$path"
}
}

View file

@ -1,93 +0,0 @@
package com.tokilabs.mattermost.helpers
import android.app.NotificationManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.core.app.NotificationManagerCompat
/**
* 알림 유틸리티.
* mattermost-mobile의 com.mattermost.rnutils.helpers.NotificationHelper 대응.
*/
object NotificationHelper {
private const val TAG = "NotificationHelper"
const val MESSAGE_NOTIFICATION_ID = -1
// 알림 ID → Bundle 저장소 (요약 알림 처리용)
private val notificationPreferences = mutableMapOf<Int, Bundle>()
fun getNotificationId(bundle: Bundle): Int {
val postId = bundle.getString("post_id")
return postId?.hashCode() ?: MESSAGE_NOTIFICATION_ID
}
/**
* 알림을 preferences에 추가.
* @return true if this is the first notification for the group ( createSummary)
*/
fun addNotificationToPreferences(context: Context, notificationId: Int, bundle: Bundle): Boolean {
val channelId = bundle.getString("channel_id") ?: return true
val rootId = bundle.getString("root_id")
val isCRTEnabled = bundle.getString("is_crt_enabled") == "true"
val groupId = if (isCRTEnabled && !rootId.isNullOrEmpty()) rootId else channelId
val isFirst = notificationPreferences.none { (_, b) ->
val bGroupId = if (isCRTEnabled && !b.getString("root_id").isNullOrEmpty())
b.getString("root_id") else b.getString("channel_id")
bGroupId == groupId
}
notificationPreferences[notificationId] = bundle
return isFirst
}
/**
* 채널/스레드 알림 전체 초기화.
*/
fun clearChannelOrThreadNotifications(context: Context, bundle: Bundle) {
val channelId = bundle.getString("channel_id") ?: return
val rootId = bundle.getString("root_id")
val isCRTEnabled = bundle.getString("is_crt_enabled") == "true"
val targetGroupId = if (isCRTEnabled && !rootId.isNullOrEmpty()) rootId else channelId
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val toRemove = mutableListOf<Int>()
notificationPreferences.forEach { (id, b) ->
val bRootId = b.getString("root_id")
val bChannelId = b.getString("channel_id")
val bGroupId = if (isCRTEnabled && !bRootId.isNullOrEmpty()) bRootId else bChannelId
if (bGroupId == targetGroupId) {
notificationManager.cancel(id)
toRemove.add(id)
Log.i(TAG, "Cancelled notification id=$id for group=$targetGroupId")
}
}
toRemove.forEach { notificationPreferences.remove(it) }
}
/**
* 단일 알림 해제.
*/
fun dismissNotification(context: Context, bundle: Bundle) {
val notificationId = getNotificationId(bundle)
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.cancel(notificationId)
notificationPreferences.remove(notificationId)
Log.i(TAG, "Dismissed notification id=$notificationId")
}
/**
* 알림 표시.
*/
fun postNotification(context: Context, notification: android.app.Notification, notificationId: Int) {
try {
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.notify(notificationId, notification)
} catch (e: SecurityException) {
Log.e(TAG, "No notification permission: ${e.message}")
}
}
}

View file

@ -1,145 +0,0 @@
package com.tokilabs.mattermost.helpers
import android.content.Context
import android.os.Bundle
import android.util.Log
import com.tokilabs.mattermost.helpers.db.MattermostDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* 알림 수신 필요한 데이터(채널/포스트/유저/스레드) DB에서 페칭.
* mattermost-mobile의 PushNotificationDataHelper.kt 이식 버전.
*
* 주요 변경:
* - com.facebook.react.bridge.* (Arguments, ReadableMap ) android.os.Bundle 사용
* - WatermelonDB (WMDatabase) Room DB (MattermostDatabase)
* - 데이터 페칭 로직은 구조 유지, 실제 DB 쿼리는 MattermostDatabase.kt에서 구현
*/
class PushNotificationDataHelper(private val context: Context) {
suspend fun fetchAndStoreDataForPushNotification(initialData: Bundle, isFlutterInit: Boolean): Bundle? {
return withContext(Dispatchers.Default) {
PushNotificationDataRunnable.start(context, initialData, isFlutterInit)
}
}
}
object PushNotificationDataRunnable {
private const val TAG = "PushNotifDataHelper"
private val mutex = Mutex()
suspend fun start(context: Context, initialData: Bundle, isFlutterInit: Boolean): Bundle? {
mutex.withLock {
val serverUrl = initialData.getString("server_url") ?: return null
val dbHelper = DatabaseHelper.getInstance() ?: run {
Log.e(TAG, "DatabaseHelper not initialized")
return null
}
val db = dbHelper.getDatabaseForServer(context, serverUrl)
var result: Bundle? = null
try {
if (db != null) {
val teamId = initialData.getString("team_id")
val channelId = initialData.getString("channel_id")
val postId = initialData.getString("post_id")
val rootId = initialData.getString("root_id")
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
val ackId = initialData.getString("ack_id")
Log.i(TAG, "Start fetching notification data server=$serverUrl channel=$channelId ack=$ackId")
val notificationData = Bundle()
// 팀 정보 (TODO: Room DAO 구현 후 실제 쿼리로 교체)
if (!teamId.isNullOrEmpty()) {
fetchTeamIfNeeded(db, serverUrl, teamId)?.let {
notificationData.putBundle("team", it)
}
}
// 채널 / 포스트 정보
if (channelId != null && postId != null) {
fetchMyChannel(db, channelId)?.let { notificationData.putBundle("channel", it) }
fetchPosts(db, serverUrl, channelId, isCRTEnabled, rootId)?.let {
notificationData.putBundle("posts", it)
}
// 스레드 (CRT 활성화 시)
if (isCRTEnabled && !rootId.isNullOrEmpty()) {
fetchThread(db, serverUrl, rootId)?.let {
notificationData.putBundle("thread", it)
}
}
// 사용자 목록
fetchNeededUsers(serverUrl, channelId)?.let {
notificationData.putParcelableArray("users", it.toTypedArray())
}
}
result = notificationData
// Flutter가 초기화되지 않은 경우 DB에 저장 (다음 Flutter 부팅 시 로드)
if (!isFlutterInit) {
saveToDatabase(db, notificationData, teamId, channelId, isCRTEnabled)
}
Log.i(TAG, "Done processing push notification server=$serverUrl channel=$channelId ack=$ackId")
}
} catch (e: Exception) {
Log.e(TAG, "Error processing push notification: ${e.message}")
e.printStackTrace()
} finally {
db?.close()
Log.i(TAG, "DONE fetching notification data")
}
return result
}
}
// ─── 개별 데이터 페칭 (TODO: Room DAO 실제 쿼리로 교체) ──────────────
private fun fetchTeamIfNeeded(db: MattermostDatabase, serverUrl: String, teamId: String): Bundle? {
// TODO: db.teamDao().getTeam(teamId)
Log.i(TAG, "fetchTeamIfNeeded: teamId=$teamId (Room DAO 구현 필요)")
return null
}
private fun fetchMyChannel(db: MattermostDatabase, channelId: String): Bundle? {
// TODO: db.channelDao().getChannel(channelId)
Log.i(TAG, "fetchMyChannel: channelId=$channelId (Room DAO 구현 필요)")
return null
}
private fun fetchPosts(db: MattermostDatabase, serverUrl: String, channelId: String,
isCRTEnabled: Boolean, rootId: String?): Bundle? {
// TODO: db.postDao().getPostsForChannel(channelId)
Log.i(TAG, "fetchPosts: channelId=$channelId (Room DAO 구현 필요)")
return null
}
private fun fetchThread(db: MattermostDatabase, serverUrl: String, rootId: String): Bundle? {
// TODO: db.threadDao().getThread(rootId)
Log.i(TAG, "fetchThread: rootId=$rootId (Room DAO 구현 필요)")
return null
}
private fun fetchNeededUsers(serverUrl: String, channelId: String): List<Bundle>? {
// TODO: db.userDao().getUsersForChannel(channelId)
Log.i(TAG, "fetchNeededUsers: channelId=$channelId (Room DAO 구현 필요)")
return null
}
private fun saveToDatabase(db: MattermostDatabase, data: Bundle, teamId: String?,
channelId: String?, isCRTEnabled: Boolean) {
// TODO: Room DB에 알림 관련 데이터 저장 (Flutter 부팅 전 수신된 경우)
Log.i(TAG, "saveToDatabase: channelId=$channelId (Room DAO 구현 필요)")
}
}

View file

@ -1,38 +0,0 @@
package com.tokilabs.mattermost.helpers.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
/**
* 전역 Room DB. 서버 목록과 디바이스 토큰 등을 저장.
* mattermost-mobile의 defaultDatabase (Global WatermelonDB) 대응.
*/
@Database(
entities = [GlobalEntity::class, ServerEntity::class],
version = 1,
exportSchema = false
)
abstract class GlobalDatabase : RoomDatabase() {
abstract fun globalDao(): GlobalDao
abstract fun serversDao(): ServersDao
companion object {
@Volatile private var instance: GlobalDatabase? = null
fun getInstance(context: Context): GlobalDatabase {
return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
GlobalDatabase::class.java,
"mattermost_global.db"
)
.allowMainThreadQueries()
.fallbackToDestructiveMigration()
.build()
.also { instance = it }
}
}
}
}

View file

@ -1,53 +0,0 @@
package com.tokilabs.mattermost.helpers.db
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
// ─── Global table (deviceToken 등 전역 설정) ────────────────────────────────
@Entity(tableName = "Global")
data class GlobalEntity(
@PrimaryKey val id: String,
val value: String
)
@Dao
interface GlobalDao {
@Query("SELECT value FROM Global WHERE id = :id LIMIT 1")
fun getValue(id: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: GlobalEntity)
}
// ─── Servers table (등록된 서버 목록) ─────────────────────────────────────────
@Entity(tableName = "Servers")
data class ServerEntity(
@PrimaryKey val url: String,
@ColumnInfo(name = "db_path") val dbPath: String,
val identifier: String? = null
)
@Dao
interface ServersDao {
@Query("SELECT * FROM Servers WHERE url = :url LIMIT 1")
fun getServer(url: String): ServerEntity?
@Query("SELECT COUNT(*) FROM Servers")
fun count(): Int
@Query("SELECT url FROM Servers LIMIT 1")
fun getFirstUrl(): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: ServerEntity)
@Query("SELECT * FROM Servers")
fun getAll(): List<ServerEntity>
}

View file

@ -1,50 +0,0 @@
package com.tokilabs.mattermost.helpers.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
/**
* 서버별 Room DB. Config / User / System 테이블 포함.
* mattermost-mobile의 서버별 WatermelonDB 대응.
*/
@Database(
entities = [ConfigEntity::class, UserEntity::class, SystemEntity::class],
version = 1,
exportSchema = false
)
abstract class MattermostDatabase : RoomDatabase() {
abstract fun configDao(): ConfigDao
abstract fun userDao(): UserDao
abstract fun systemDao(): SystemDao
// ─── JWT 검증용 편의 메서드 ───────────────────────────────────────────────
fun queryConfigServerVersion(): String? = configDao().getValue("Version")
fun queryConfigSigningKey(): String? = configDao().getValue("AsymmetricSigningPublicKey")
fun getLastPictureUpdate(userId: String): Double? {
val id = if (userId == "me") systemDao().getValue("currentUserId") ?: userId else userId
return userDao().getLastPictureUpdate(id)
}
companion object {
@Volatile private var instances = mutableMapOf<String, MattermostDatabase>()
fun getInstance(context: Context, serverUrl: String): MattermostDatabase {
return instances.getOrPut(serverUrl) {
val dbName = "mattermost_server_${serverUrl.hashCode()}.db"
Room.databaseBuilder(
context.applicationContext,
MattermostDatabase::class.java,
dbName
)
.allowMainThreadQueries()
.fallbackToDestructiveMigration()
.build()
}
}
}
}

View file

@ -1,71 +0,0 @@
package com.tokilabs.mattermost.helpers.db
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
// ─── Config table (서버 버전, 서명키 등) ──────────────────────────────────────
@Entity(tableName = "Config")
data class ConfigEntity(
@PrimaryKey val id: String,
val value: String?
)
@Dao
interface ConfigDao {
@Query("SELECT value FROM Config WHERE id = :id LIMIT 1")
fun getValue(id: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: ConfigEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertAll(entities: List<ConfigEntity>)
}
// ─── User table (아바타 캐시용) ───────────────────────────────────────────────
@Entity(tableName = "User")
data class UserEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "last_picture_update") val lastPictureUpdate: Double = 0.0,
val username: String? = null,
val email: String? = null,
@ColumnInfo(name = "first_name") val firstName: String? = null,
@ColumnInfo(name = "last_name") val lastName: String? = null,
val roles: String? = null
)
@Dao
interface UserDao {
@Query("SELECT last_picture_update FROM User WHERE id = :userId LIMIT 1")
fun getLastPictureUpdate(userId: String): Double?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: UserEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertAll(entities: List<UserEntity>)
}
// ─── System table (currentUserId, currentTeamId 등) ─────────────────────────
@Entity(tableName = "System")
data class SystemEntity(
@PrimaryKey val id: String,
val value: String?
)
@Dao
interface SystemDao {
@Query("SELECT value FROM System WHERE id = :id LIMIT 1")
fun getValue(id: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: SystemEntity)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -1,25 +1,25 @@
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:mattermost_push_plugin/mattermost_push_plugin.dart';
import 'services/mattermost_auth_service.dart';
import 'services/push_notification_background.dart';
import 'services/push_notification_service.dart';
import 'services/workspace_launcher.dart';
import 'screens/workspace_home_page.dart';
final _navigatorKey = GlobalKey<NavigatorState>();
final _pushService = PushNotificationService();
final _pushService = MattermostPushPlugin.instance;
final _launcher = WorkspaceLauncher();
Future<void> _applyFullscreenMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
));
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
),
);
}
void main() async {
@ -29,9 +29,7 @@ void main() async {
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
_pushService.init();
await _pushService.initialize();
// Mattermost + FCM
final authService = MattermostAuthService(_pushService);

View file

@ -1,8 +1,4 @@
enum ProjectStatus {
active,
paused,
pending,
}
enum ProjectStatus { active, paused, pending }
class ProjectWorkspace {
final String id;
@ -44,7 +40,8 @@ const List<ProjectWorkspace> mockProjectWorkspaces = [
name: 'Proto Socket',
description: '고성능 전이중 RPC 채널 및 웹소켓 메시징 엔진',
status: ProjectStatus.paused,
codeServerUrl: 'http://localhost:8080/?folder=/config/workspace/proto-socket',
codeServerUrl:
'http://localhost:8080/?folder=/config/workspace/proto-socket',
tasksCount: 0,
),
];

View file

@ -4,10 +4,7 @@ import '../models/project_workspace.dart';
class WorkspaceHomePage extends StatefulWidget {
final Future<bool> Function(ProjectWorkspace)? onLaunchCodeServer;
const WorkspaceHomePage({
super.key,
this.onLaunchCodeServer,
});
const WorkspaceHomePage({super.key, this.onLaunchCodeServer});
@override
State<WorkspaceHomePage> createState() => _WorkspaceHomePageState();
@ -41,7 +38,11 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
color: Colors.deepPurpleAccent.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.blur_on, color: Colors.deepPurpleAccent, size: 24),
child: const Icon(
Icons.blur_on,
color: Colors.deepPurpleAccent,
size: 24,
),
),
const SizedBox(width: 12),
const Text(
@ -65,7 +66,9 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
),
],
),
body: isLargeScreen ? _buildDualPaneLayout(theme) : _buildSinglePaneLayout(theme),
body: isLargeScreen
? _buildDualPaneLayout(theme)
: _buildSinglePaneLayout(theme),
);
}
@ -105,9 +108,7 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
Widget _buildSinglePaneLayout(ThemeData theme) {
return Column(
children: [
Expanded(
child: _buildWorkspaceList(),
),
Expanded(child: _buildWorkspaceList()),
if (_selectedProject != null)
Container(
padding: const EdgeInsets.all(16),
@ -156,7 +157,9 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isSelected ? Colors.deepPurpleAccent : const Color(0xFF26264A),
color: isSelected
? Colors.deepPurpleAccent
: const Color(0xFF26264A),
width: 1.5,
),
),
@ -220,7 +223,10 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
const SizedBox(width: 8),
if (project.tasksCount > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.deepPurpleAccent.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
@ -267,7 +273,10 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: project.status == ProjectStatus.active
? Colors.cyanAccent.withValues(alpha: 0.15)
@ -374,13 +383,19 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
const SizedBox(height: 4),
const Text(
'Running workflow core optimizations...',
style: TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(
color: Colors.white54,
fontSize: 12,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.cyanAccent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
@ -410,19 +425,14 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
backgroundColor: Colors.deepPurpleAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 6,
shadowColor: Colors.deepPurpleAccent.withValues(alpha: 0.4),
),
icon: const Icon(Icons.rocket_launch, size: 20),
label: const Text(
'Open Workspace',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.5),
),
onPressed: () async {
if (widget.onLaunchCodeServer != null) {

View file

@ -2,11 +2,11 @@ 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 'push_notification_service.dart';
import 'package:mattermost_push_plugin/mattermost_push_plugin.dart';
/// Mattermost + FCM .
class MattermostAuthService {
final PushNotificationService _pushService;
final MattermostPushPlugin _pushService;
String? _serverUrl;
String? _authToken;
@ -22,7 +22,9 @@ class MattermostAuthService {
Future<void> autoLoginAndRegister() async {
final creds = await _loadCredentials();
if (creds == null) {
print('[MattermostAuth] Credentials asset not found, skipping auto login.');
print(
'[MattermostAuth] Credentials asset not found, skipping auto login.',
);
return;
}
_serverUrl = creds['serverUrl']!;
@ -36,7 +38,9 @@ class MattermostAuthService {
print('[MattermostAuth] FCM token already available, registering ...');
await _registerDeviceToken(existingToken);
} else {
print('[MattermostAuth] FCM token not ready yet, waiting for callback ...');
print(
'[MattermostAuth] FCM token not ready yet, waiting for callback ...',
);
}
// FCM /
@ -51,7 +55,9 @@ class MattermostAuthService {
Future<Map<String, String>?> _loadCredentials() async {
final String jsonStr;
try {
jsonStr = await rootBundle.loadString('assets/mattermost_credentials.json');
jsonStr = await rootBundle.loadString(
'assets/mattermost_credentials.json',
);
} on FlutterError {
return null;
}
@ -105,7 +111,9 @@ class MattermostAuthService {
headers: {'Authorization': 'Bearer $_authToken'},
);
if (response.statusCode != 200) {
print('[MattermostAuth] Failed to fetch config (${response.statusCode})');
print(
'[MattermostAuth] Failed to fetch config (${response.statusCode})',
);
return;
}
final config = json.decode(response.body) as Map<String, dynamic>;
@ -123,8 +131,7 @@ class MattermostAuthService {
/// 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 url = Uri.parse('$_serverUrl/api/v4/users/sessions/device');
final response = await http.put(
url,
headers: {
@ -142,5 +149,4 @@ class MattermostAuthService {
);
}
}
}

View file

@ -1,12 +0,0 @@
import 'package:firebase_messaging/firebase_messaging.dart';
/// FCM .
/// main() top-level .
/// push_notifications.ts의 .
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// NOTE: Android(MattermostFirebaseMessagingService)
// FCM을 Flutter가 .
// .
print('[PushNotification] Background message: ${message.messageId}');
}

View file

@ -1,250 +0,0 @@
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart';
/// .
class PushNotificationType {
static const String message = 'message';
static const String clear = 'clear';
static const String session = 'session';
static const String tokenRefresh = 'token_refresh';
static const String opened = 'opened';
}
/// .
class NotificationOpenedEvent {
final String? serverUrl;
final String? channelId;
final String? rootId;
final bool isCRTEnabled;
const NotificationOpenedEvent({
this.serverUrl,
this.channelId,
this.rootId,
this.isCRTEnabled = false,
});
}
/// .
/// mattermost-mobile의 push_notifications.ts .
class PushNotificationService {
static final PushNotificationService _instance =
PushNotificationService._internal();
factory PushNotificationService() => _instance;
PushNotificationService._internal();
static const EventChannel _notificationChannel =
EventChannel('com.tokilabs.mattermost/notifications');
static const MethodChannel _actionChannel =
MethodChannel('com.tokilabs.mattermost/notification_actions');
StreamSubscription? _channelSubscription;
/// ( UI에서 )
final StreamController<Map<String, dynamic>> _notificationController =
StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get onNotification =>
_notificationController.stream;
/// (/ )
final StreamController<NotificationOpenedEvent> _openedController =
StreamController<NotificationOpenedEvent>.broadcast();
Stream<NotificationOpenedEvent> get onNotificationOpened =>
_openedController.stream;
// ( )
void Function(String serverUrl, String channelId)? onNavigateToChannel;
void Function(String serverUrl, String rootId)? onNavigateToThread;
// FCM /
void Function(String deviceToken)? onDeviceTokenReady;
// /
void init() {
_listenNativeChannel();
_listenFcmTokenRefresh();
_requestPermission();
}
void dispose() {
_channelSubscription?.cancel();
_notificationController.close();
_openedController.close();
}
//
void _listenNativeChannel() {
_channelSubscription = _notificationChannel
.receiveBroadcastStream()
.listen(
(dynamic event) {
if (event is Map) {
_processNotification(Map<String, dynamic>.from(event));
}
},
onError: (error) => print('[PushNotification] EventChannel error: $error'),
);
}
void _listenFcmTokenRefresh() {
FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
await _saveDeviceToken(token);
});
}
Future<void> _requestPermission() async {
final settings = await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
print('[PushNotification] Permission: ${settings.authorizationStatus}');
final token = await FirebaseMessaging.instance.getToken();
if (token != null) await _saveDeviceToken(token);
}
//
void _processNotification(Map<String, dynamic> data) {
final type = data['type'] as String?;
switch (type) {
case PushNotificationType.message:
_handleMessageNotification(data);
break;
case PushNotificationType.clear:
_handleClearNotification(data);
break;
case PushNotificationType.session:
_handleSessionNotification(data);
break;
case PushNotificationType.tokenRefresh:
final token = data['token'] as String?;
if (token != null) _saveDeviceToken(token);
break;
case PushNotificationType.opened:
_handleNotificationOpened(data);
break;
}
if (!_notificationController.isClosed) {
_notificationController.add(data);
}
}
void _handleMessageNotification(Map<String, dynamic> data) {
final isUserInteraction = data['userInteraction'] == true;
if (isUserInteraction) {
_handleNotificationOpened(data);
}
// onNotification UI에서
}
void _handleClearNotification(Map<String, dynamic> data) {
print('[PushNotification] Clear: channelId=${data['channel_id']}');
// TODO: ,
}
void _handleSessionNotification(Map<String, dynamic> data) {
print('[PushNotification] Session expired: serverUrl=${data['server_url']}');
// TODO:
// clearAuthToken(data['server_url'])
}
/// - .
/// mattermost-mobile의 openNotification() + pushNotificationEntry() .
void _handleNotificationOpened(Map<String, dynamic> data) {
final serverUrl = data['server_url'] as String?;
final channelId = data['channel_id'] as String?;
final rootId = data['root_id'] as String?;
final isCRTEnabled = data['is_crt_enabled'] == 'true';
print('[PushNotification] Opened: channelId=$channelId rootId=$rootId');
final event = NotificationOpenedEvent(
serverUrl: serverUrl,
channelId: channelId,
rootId: rootId,
isCRTEnabled: isCRTEnabled,
);
if (!_openedController.isClosed) {
_openedController.add(event);
}
//
if (serverUrl == null) return;
if (isCRTEnabled && rootId != null && rootId.isNotEmpty) {
onNavigateToThread?.call(serverUrl, rootId);
} else if (channelId != null) {
onNavigateToChannel?.call(serverUrl, channelId);
}
}
//
/// . URL + Bearer .
/// ReceiptDelivery(ACK), NotificationReplyBroadcastReceiver() API .
Future<void> setAuthToken(String serverUrl, String token,
{String? identifier}) async {
try {
await _actionChannel.invokeMethod('setAuthToken', {
'serverUrl': serverUrl,
'token': token,
'identifier': identifier,
});
print('[PushNotification] Auth token saved for $serverUrl');
} catch (e) {
print('[PushNotification] Failed to save auth token: $e');
}
}
/// .
Future<void> clearAuthToken(String serverUrl) async {
try {
await _actionChannel.invokeMethod('clearAuthToken', {'serverUrl': serverUrl});
} catch (e) {
print('[PushNotification] Failed to clear auth token: $e');
}
}
/// JWT .
Future<void> setSigningKey(String serverUrl, String signingKey) async {
try {
await _actionChannel.invokeMethod('setSigningKey', {
'serverUrl': serverUrl,
'signingKey': signingKey,
});
} catch (e) {
print('[PushNotification] Failed to save signing key: $e');
}
}
/// FCM ( DB에 android_rn-v2:{token} ).
Future<void> _saveDeviceToken(String token) async {
try {
const prefix = 'android_rn';
final formattedToken = '$prefix-v2:$token';
await _actionChannel
.invokeMethod('saveDeviceToken', {'token': formattedToken});
print('[PushNotification] Device token saved');
//
onDeviceTokenReady?.call(formattedToken);
} catch (e) {
print('[PushNotification] Failed to save device token: $e');
}
}
Future<String?> getDeviceToken() async {
try {
return await _actionChannel.invokeMethod<String>('getDeviceToken');
} catch (e) {
return null;
}
}
}

View file

@ -7,12 +7,12 @@ import Foundation
import firebase_core
import firebase_messaging
import flutter_local_notifications
import mattermost_push_plugin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
MattermostPushPlugin.register(with: registry.registrar(forPlugin: "MattermostPushPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View file

@ -9,14 +9,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.59"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@ -65,14 +57,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
fake_async:
dependency: transitive
description:
@ -81,14 +65,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
firebase_core:
dependency: "direct main"
description:
@ -114,7 +90,7 @@ packages:
source: hosted
version: "2.24.1"
firebase_messaging:
dependency: "direct main"
dependency: transitive
description:
name: firebase_messaging
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
@ -150,30 +126,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
url: "https://pub.dev"
source: hosted
version: "18.0.1"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
@ -248,6 +200,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.0"
mattermost_push_plugin:
dependency: "direct main"
description:
path: "../../../mattermost-push-plugin"
relative: true
source: path
version: "0.0.1"
meta:
dependency: transitive
description:
@ -264,14 +223,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
plugin_platform_interface:
dependency: transitive
description:
@ -333,14 +284,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.10"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data:
dependency: transitive
description:
@ -437,22 +380,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
sdks:
dart: ">=3.11.0 <4.0.0"
dart: ">=3.11.3 <4.0.0"
flutter: ">=3.38.0"

View file

@ -38,13 +38,14 @@ dependencies:
# Firebase
firebase_core: ^3.13.0
firebase_messaging: ^15.2.5
# Mattermost push plugin
mattermost_push_plugin:
path: ../../../mattermost-push-plugin
# HTTP client
http: ^1.3.0
# Local notifications (foreground display)
flutter_local_notifications: ^18.0.1
url_launcher: ^6.3.2
dev_dependencies:

View file

@ -1,432 +1,34 @@
# Push Notification Migration TODO
# Push Notification Integration Handoff & Migration Notes
## 배경
> [!IMPORTANT]
> **This document is preserved for historical reference only.**
> As of the `mattermost-push-plugin-extraction` milestone, all native FCM services, Room DB structures, JJWT signature verification, inline reply/dismiss receivers, notification builders, and ACK request handling have been successfully extracted into the local Flutter plugin repository: `../mattermost-push-plugin`.
>
> **Do NOT edit native push files directly inside this host app repository (`apps/mobile/android`).** All native push logic and platform services now reside in and must be maintained within the plugin repository.
mattermost-mobile(React Native) 프로젝트의 안드로이드 푸시 알림 시스템을 이 Flutter 프로젝트(nomadcode-app)로 마이그레이션한 작업 기록 및 잔여 TODO.
## Current Responsibilities in Host App (`apps/mobile`)
**패키지명:** `com.tokilabs.mattermost` (google-services.json과 일치)
**원본 참조:** `/config/workspace/mattermost-mobile`
The host application acts strictly as a consumer of the `mattermost_push_plugin` package. What remains in `apps/mobile` regarding push notifications are:
---
1. **Firebase Manifest & Metadata**:
- `google-services.json` inside `apps/mobile/android/app/`
- Initializing Firebase inside `lib/main.dart`
2. **Credential / Secret Plumbing**:
- Storing session authorization tokens via `MattermostPushPlugin.instance.setAuthToken()` on login and clearing them on logout.
- Registering Mattermost server public signing keys via `MattermostPushPlugin.instance.setSigningKey()`.
3. **App Navigation Routings**:
- Handling user taps on notifications by implementing `onNavigateToChannel` and `onNavigateToThread` routing callbacks on `MattermostPushPlugin.instance`.
## 전체 아키텍처 요약
## Legacy Work Documentation (Reference)
```
FCM 서버
MattermostFirebaseMessagingService.kt ← 백그라운드/포그라운드 FCM 수신
├─ JWT 서명 검증 (CustomPushNotificationHelper.java)
├─ ReceiptDelivery.java → /api/v4/notifications/ack POST
├─ PushNotificationDataHelper.kt → Room DB에서 채널/포스트/유저 조회
├─ CustomPushNotificationHelper.java → 시스템 알림 표시
└─ EventChannel (com.tokilabs.mattermost/notifications)
push_notification_service.dart ← Flutter에서 이벤트 수신
├─ onNotification stream → UI에서 포그라운드 알림 표시
└─ onNotificationOpened stream → 알림 탭 시 채널/스레드 이동
main.dart (onNavigateToChannel / onNavigateToThread 콜백)
```
All native Kotlin/Java files, Room DB configurations, and broadcast receivers documented below are now managed within the `mattermost_push_plugin` repository:
---
- `MattermostFirebaseMessagingService.kt`
- `NotificationReplyBroadcastReceiver.java`
- `NotificationDismissService.java`
- `ReceiptDelivery.java`
- `PushNotificationDataHelper.kt`
- `helpers/db/MattermostDatabase.kt` & `GlobalDatabase.kt`
## 완료된 작업 (건드리지 말 것)
### Android 네이티브 레이어 ✅
| 파일 | 역할 |
|------|------|
| `MattermostFirebaseMessagingService.kt` | FCM 수신, JWT 검증, Flutter EventSink로 전달 |
| `helpers/CustomPushNotificationHelper.java` | 알림 채널 생성, 시스템 알림 빌드, 아바타 처리 |
| `helpers/NotificationHelper.kt` | 알림 ID 관리, 그룹 알림 요약 |
| `helpers/DatabaseHelper.kt` | GlobalDatabase + MattermostDatabase 접근 |
| `helpers/Network.kt` | OkHttp 기반 HTTP 클라이언트 (토큰 캐시 포함) |
| `helpers/db/GlobalDatabase.kt` | deviceToken, Servers 테이블 |
| `helpers/db/MattermostDatabase.kt` | Config, User, System 테이블 (서버별) |
| `helpers/db/GlobalEntities.kt` | GlobalEntity, ServerEntity, DAO 정의 |
| `helpers/db/ServerEntities.kt` | ConfigEntity, UserEntity, SystemEntity, DAO 정의 |
| `ReceiptDelivery.java` | `/api/v4/notifications/ack` ACK 전송 |
| `NotificationReplyBroadcastReceiver.java` | 알림에서 인라인 답장 처리 |
| `NotificationDismissService.java` | 알림 삭제 처리 |
| `AppLifecycleTracker.kt` | 앱 포그라운드/백그라운드 상태 추적 |
| `MainApplication.kt` | DB 초기화, 알림 채널 생성, 라이프사이클 추적 |
| `MainActivity.kt` | EventChannel + MethodChannel 등록 |
### Flutter 레이어 ✅
| 파일 | 역할 |
|------|------|
| `lib/services/push_notification_service.dart` | 싱글톤, 채널 수신, 스트림 노출, MethodChannel 호출 |
| `lib/services/push_notification_background.dart` | 백그라운드 FCM 핸들러 |
| `lib/main.dart` | Firebase 초기화, PushNotificationService 초기화, 네비게이션 콜백 등록 |
### 리소스 ✅
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` + `ic_launcher_background.png` + `ic_launcher_foreground.png`
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` (Adaptive Icon)
- `drawable-*/ic_notif_action_reply.png` (답장 버튼 아이콘)
- `mipmap-*/ic_notification.png` (알림 상태바 아이콘)
- `AndroidManifest.xml` — 권한, 서비스, 리시버 등록 완료
---
## 잔여 TODO
---
### [TODO-1] Android: PushNotificationDataHelper.kt — Room DAO 실제 쿼리 구현
**파일:** `android/app/src/main/java/com/tokilabs/mattermost/helpers/PushNotificationDataHelper.kt`
현재 다음 메서드들이 `Log.i(...) return null` stub 상태:
```kotlin
private fun fetchTeamIfNeeded(db: MattermostDatabase, serverUrl: String, teamId: String): Bundle?
private fun fetchMyChannel(db: MattermostDatabase, channelId: String): Bundle?
private fun fetchPosts(db: MattermostDatabase, serverUrl: String, channelId: String, isCRTEnabled: Boolean, rootId: String?): Bundle?
private fun fetchThread(db: MattermostDatabase, serverUrl: String, rootId: String): Bundle?
private fun fetchNeededUsers(serverUrl: String, channelId: String): List<Bundle>?
private fun saveToDatabase(db: MattermostDatabase, data: Bundle, teamId: String?, channelId: String?, isCRTEnabled: Boolean)
```
**구현 방법:**
1. `ServerEntities.kt`에 아래 Entity + DAO 추가:
```kotlin
// Channel 테이블
@Entity(tableName = "Channel")
data class ChannelEntity(
@PrimaryKey val id: String,
val display_name: String? = null,
val type: String? = null, // O=public, P=private, D=DM, G=Group
val team_id: String? = null,
val delete_at: Long = 0
)
@Dao
interface ChannelDao {
@Query("SELECT * FROM Channel WHERE id = :id LIMIT 1")
fun getChannel(id: String): ChannelEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: ChannelEntity)
}
// Post 테이블
@Entity(tableName = "Post")
data class PostEntity(
@PrimaryKey val id: String,
val channel_id: String? = null,
val root_id: String? = null,
val user_id: String? = null,
val message: String? = null,
val create_at: Long = 0,
val delete_at: Long = 0
)
@Dao
interface PostDao {
@Query("SELECT * FROM Post WHERE channel_id = :channelId ORDER BY create_at DESC LIMIT 20")
fun getPostsForChannel(channelId: String): List<PostEntity>
@Query("SELECT * FROM Post WHERE root_id = :rootId ORDER BY create_at ASC")
fun getThreadPosts(rootId: String): List<PostEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: PostEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertAll(entities: List<PostEntity>)
}
// Team 테이블
@Entity(tableName = "Team")
data class TeamEntity(
@PrimaryKey val id: String,
val display_name: String? = null,
val name: String? = null
)
@Dao
interface TeamDao {
@Query("SELECT * FROM Team WHERE id = :id LIMIT 1")
fun getTeam(id: String): TeamEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: TeamEntity)
}
```
2. `MattermostDatabase.kt`에 새 DAO 등록:
```kotlin
@Database(
entities = [ConfigEntity::class, UserEntity::class, SystemEntity::class,
ChannelEntity::class, PostEntity::class, TeamEntity::class],
version = 2, // 버전 올리기
exportSchema = false
)
abstract class MattermostDatabase : RoomDatabase() {
abstract fun configDao(): ConfigDao
abstract fun userDao(): UserDao
abstract fun systemDao(): SystemDao
abstract fun channelDao(): ChannelDao
abstract fun postDao(): PostDao
abstract fun teamDao(): TeamDao
...
}
```
3. `PushNotificationDataHelper.kt` stub 메서드 구현:
```kotlin
private fun fetchMyChannel(db: MattermostDatabase, channelId: String): Bundle? {
val channel = db.channelDao().getChannel(channelId) ?: return null
return Bundle().apply {
putString("id", channel.id)
putString("display_name", channel.display_name)
putString("type", channel.type)
putString("team_id", channel.team_id)
}
}
private fun fetchPosts(db: MattermostDatabase, serverUrl: String, channelId: String,
isCRTEnabled: Boolean, rootId: String?): Bundle? {
val posts = if (isCRTEnabled && rootId != null)
db.postDao().getThreadPosts(rootId)
else
db.postDao().getPostsForChannel(channelId)
if (posts.isEmpty()) return null
return Bundle().apply {
posts.forEachIndexed { i, post ->
putBundle("post_$i", Bundle().apply {
putString("id", post.id)
putString("message", post.message)
putString("user_id", post.user_id)
putLong("create_at", post.create_at)
})
}
}
}
private fun fetchTeamIfNeeded(db: MattermostDatabase, serverUrl: String, teamId: String): Bundle? {
val team = db.teamDao().getTeam(teamId) ?: return null
return Bundle().apply {
putString("id", team.id)
putString("display_name", team.display_name)
}
}
private fun fetchThread(db: MattermostDatabase, serverUrl: String, rootId: String): Bundle? {
val posts = db.postDao().getThreadPosts(rootId)
if (posts.isEmpty()) return null
return Bundle().apply { putInt("reply_count", posts.size) }
}
private fun fetchNeededUsers(serverUrl: String, channelId: String): List<Bundle>? {
// Note: 현재 DB에 channel_members 테이블 없음 → 추후 구현
return null
}
private fun saveToDatabase(db: MattermostDatabase, data: Bundle, teamId: String?,
channelId: String?, isCRTEnabled: Boolean) {
// Flutter 부팅 전 수신된 알림 데이터 저장
// System 테이블에 "pendingNotification_{channelId}" 키로 JSON 저장 권장
channelId ?: return
val json = org.json.JSONObject().apply {
data.keySet()?.forEach { key -> put(key, data.getString(key)) }
}
db.systemDao().upsert(SystemEntity("pendingNotification_$channelId", json.toString()))
}
```
**주의:** `db.close()` 호출이 `PushNotificationDataHelper.kt` 99번째 줄 finally 블록에 있음.
Room은 싱글톤이므로 `close()` 호출 시 이후 쿼리가 실패함. **해당 줄 제거 필요.**
```kotlin
// 제거 대상 (PushNotificationDataHelper.kt:99)
db?.close() // ← 삭제
```
---
### [TODO-2] Flutter: 앱 라우터 및 화면 구현
**파일:** `lib/main.dart`
현재 상태:
```dart
pushService.onNavigateToChannel = (serverUrl, channelId) {
// TODO: 채널 화면으로 이동
print('[Nav] Navigate to channel: $channelId on $serverUrl');
};
pushService.onNavigateToThread = (serverUrl, rootId) {
// TODO: 스레드 화면으로 이동
print('[Nav] Navigate to thread: $rootId on $serverUrl');
};
```
**구현 방향:**
1. go_router 또는 Navigator 2.0 기반 라우터 설정
2. 최소 필요 화면:
- `LoginScreen` — 서버 URL 입력 + 로그인 → `pushService.setAuthToken()` 호출
- `ChannelScreen` — 채널 메시지 목록
- `ThreadScreen` — 스레드 메시지 목록
3. `main.dart` 콜백 연결 예시:
```dart
pushService.onNavigateToChannel = (serverUrl, channelId) {
_navigatorKey.currentState?.pushNamed(
'/channel',
arguments: {'serverUrl': serverUrl, 'channelId': channelId},
);
};
pushService.onNavigateToThread = (serverUrl, rootId) {
_navigatorKey.currentState?.pushNamed(
'/thread',
arguments: {'serverUrl': serverUrl, 'rootId': rootId},
);
};
```
---
### [TODO-3] Flutter: 로그인 화면 및 setAuthToken 연결
알림 답장(NotificationReplyBroadcastReceiver)과 ACK(ReceiptDelivery)가 서버 인증 토큰을 필요로 함.
Flutter 로그인 성공 시 반드시 아래 호출 필요:
```dart
// 로그인 성공 후
await PushNotificationService().setAuthToken(serverUrl, bearerToken);
// 로그아웃 시
await PushNotificationService().clearAuthToken(serverUrl);
```
이 호출이 없으면 인라인 답장 API와 ACK API 요청이 401로 실패함.
---
### [TODO-4] Flutter: 포그라운드 알림 UI
앱이 포그라운드 상태일 때 FCM 메시지는 시스템 알림 대신 인앱 UI로 표시해야 함.
`push_notification_service.dart``onNotification` 스트림을 구독해서 구현:
```dart
// 예시: 메인 화면 또는 글로벌 오버레이에서
PushNotificationService().onNotification.listen((data) {
final type = data['type'];
if (type == 'message') {
// 상단 배너 또는 SnackBar 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(data['message'] ?? '새 메시지')),
);
}
});
```
---
### [TODO-5] Flutter: firebase_options.dart 생성 (선택 사항)
현재 `main.dart`에서 `Firebase.initializeApp()`을 options 없이 호출 중 (google-services.json 사용).
FlutterFire CLI를 사용하면 더 명시적인 초기화 가능:
```bash
# 프로젝트 루트에서
dart pub global activate flutterfire_cli
flutterfire configure --project=<firebase-project-id>
```
실행 후 생성된 `lib/firebase_options.dart`를 사용:
```dart
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
```
현재도 동작에는 문제 없음. 멀티플랫폼(iOS 추가 시) 필수.
---
## Flutter에서 사용 가능한 인터페이스 (push_notification_service.dart)
```dart
final service = PushNotificationService();
// 초기화 (main에서 1회 호출)
service.init();
// 인증 토큰 관리
await service.setAuthToken(serverUrl, token, identifier: serverId);
await service.clearAuthToken(serverUrl);
// FCM 디바이스 토큰 조회
final token = await service.getDeviceToken();
// 포그라운드 알림 수신 스트림
service.onNotification.listen((Map<String, dynamic> data) { ... });
// 알림 탭 이벤트 스트림
service.onNotificationOpened.listen((NotificationOpenedEvent event) {
// event.serverUrl, event.channelId, event.rootId, event.isCRTEnabled
});
// 알림 탭 시 네비게이션 콜백 (main.dart builder에서 등록)
service.onNavigateToChannel = (serverUrl, channelId) { ... };
service.onNavigateToThread = (serverUrl, rootId) { ... };
```
---
## MethodChannel 등록 목록 (MainActivity.kt)
| 메서드명 | 인자 | 설명 |
|---------|------|------|
| `saveDeviceToken` | `token: String` | FCM 토큰 → Room GlobalDB 저장 |
| `getDeviceToken` | 없음 | Room GlobalDB에서 토큰 반환 |
| `setAuthToken` | `serverUrl`, `token`, `identifier?` | Network 캐시 + Room Servers 저장 |
| `clearAuthToken` | `serverUrl` | Network 캐시에서 토큰 제거 |
---
## EventChannel 이벤트 타입 (com.tokilabs.mattermost/notifications)
네이티브에서 Flutter로 전달되는 Map의 `type` 필드:
| type | 설명 | 주요 필드 |
|------|------|---------|
| `message` | 새 메시지 알림 | `server_url`, `channel_id`, `post_id`, `root_id`, `message`, `sender_name`, `is_crt_enabled` |
| `clear` | 채널 읽음 처리 (알림 뱃지 초기화) | `server_url`, `channel_id` |
| `session` | 세션 만료 | `server_url` |
| `token_refresh` | FCM 토큰 갱신 | `token` |
| `opened` | 알림 탭으로 앱 진입 | `server_url`, `channel_id`, `root_id`, `is_crt_enabled`, `userInteraction: true` |
---
## 우선순위 정리
| 우선순위 | 항목 | 영향 |
|---------|------|------|
| 🔴 필수 | TODO-2: 라우터/화면 | 알림 탭 후 이동 불가 |
| 🔴 필수 | TODO-3: setAuthToken 연결 | 인라인 답장/ACK 인증 실패 |
| 🟡 권장 | TODO-1: Room DAO 구현 | 알림 표시는 되나 채널명/메시지 미리보기 없음 |
| 🟡 권장 | TODO-1의 db.close() 제거 | 간헐적 DB 접근 오류 |
| 🟢 선택 | TODO-4: 포그라운드 알림 UI | 포그라운드에서 알림 표시 없음 |
| 🟢 선택 | TODO-5: firebase_options.dart | iOS 추가 시 필요 |
---
## 참고: 원본 파일 위치
| 역할 | 원본 (mattermost-mobile) |
|------|--------------------------|
| FCM 서비스 | `android/app/src/main/java/com/tokilabs/mattermost/CustomPushNotification.kt` |
| 알림 헬퍼 | `android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java` |
| 데이터 헬퍼 | `android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt` |
| ACK 전송 | `android/app/src/main/java/com/tokilabs/mattermost/ReceiptDelivery.java` |
| Flutter 서비스 | `app/utils/push_notifications.ts` |
For local modifications, testing guidelines, and detailed API specs, please refer to the plugin's README:
See `../mattermost-push-plugin/README.md` from the NomadCode repository root.

View file

@ -5,7 +5,9 @@ import 'package:nomadcode_app/screens/workspace_home_page.dart';
import 'package:nomadcode_app/models/project_workspace.dart';
void main() {
testWidgets('App renders console dashboard title', (WidgetTester tester) async {
testWidgets('App renders console dashboard title', (
WidgetTester tester,
) async {
await tester.pumpWidget(const MyApp());
expect(find.text('NomadCode Console'), findsOneWidget);
@ -13,7 +15,9 @@ void main() {
expect(find.text('IOP Bridge'), findsOneWidget);
});
testWidgets('WorkspaceHomePage triggers launcher callback on button tap', (WidgetTester tester) async {
testWidgets('WorkspaceHomePage triggers launcher callback on button tap', (
WidgetTester tester,
) async {
ProjectWorkspace? launchedProject;
await tester.pumpWidget(