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를 조율하는 프로젝트다. 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 흐름 ## 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로 분리하고 앱 통합 경계를 정리한다. 완료된 Phase도 로드맵에서 제거하지 않고, archive의 Phase 문서로 연결한다.
- Server Skeleton: 서버 실행 골격, task 저장 구조, 비동기 job 실행, Adapter stub을 구성한다. 검토중 또는 진행중 Phase는 계획 Phase보다 위에 두어, 아래로 갈수록 미래 계획에 가까워지게 정렬한다.
- 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 방향을 유지하면서 앱 기반 프로젝트 단위 제어 화면을 정리한다.
## Milestone 목록 - [완료] Server Skeleton
- 경로: `agent-ops/roadmap/archive/phase/server-skeleton/PHASE.md`
### Mattermost Push Plugin Extraction - 요약: core 서버 실행 골격, task 저장 구조, 비동기 job 실행, Adapter stub을 구성했다.
- [완료] Plane Communication Foundation
- [Mattermost Push Plugin Extraction](milestones/mattermost-push-plugin-extraction.md) - 상태: 진행 중; 목표: Flutter 앱에 섞인 Mattermost push notification migration을 `../mattermost-push-plugin` 독립 Flutter plugin repo로 분리하고 NomadCode 앱은 plugin consumer로 전환한다. - 경로: `agent-ops/roadmap/archive/phase/plane-communication-foundation/PHASE.md`
- 요약: Plane self-hosted 인스턴스와 통신하기 위한 인증, API client, 외부 참조 저장, smoke 검증 토대를 만들었다.
### Workflow Core - [완료] Client Platform Consolidation
- 경로: `agent-ops/roadmap/archive/phase/client-platform-consolidation/PHASE.md`
- [Workflow Core](milestones/workflow-core.md) - 상태: 진행 중; 목표: 실제 e2e 흐름을 기준으로 task lifecycle, retry, timeout, notification event를 안정화한다. - 요약: React/Vite 웹 콘솔을 제품 UI 경로에서 걷어내고 Flutter 앱을 NomadCode 클라이언트 source of truth로 정리했다.
- [완료] Work Item Provider Pipeline Design
### External Integration - 경로: `agent-ops/roadmap/archive/phase/work-item-provider-pipeline-design/PHASE.md`
- 요약: Plane/Jira 등 work item provider와 core task 사이의 생성, enqueue, 상태 투영, 결과 발행 계약을 provider-neutral하게 정리했다.
- [External Integration](milestones/external-integration.md) - 상태: 계획; 목표: Plane 확장, Mattermost, Agent Integrator, IOP OpenAI API Responses-compatible 호출을 실제 통합 흐름으로 확장한다. - [검토중] Mattermost Push Plugin Extraction
- 경로: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/PHASE.md`
### Project Workspace Management UX - 요약: Flutter 앱에 섞인 Mattermost push notification migration을 `../mattermost-push-plugin` Flutter plugin repo로 분리했고, 사용자 완료 확인과 archive 승인을 기다린다.
- [진행중] Workflow Core
- [Project Workspace Management UX](milestones/project-workspace-management-ux.md) - 상태: 계획; 목표: Flutter-first 클라이언트 구조 위에서 프로젝트 단위 제어 UX를 앱 기반 desktop/mobile layout 기준으로 정리한다. - 경로: `agent-ops/roadmap/phase/workflow-core/PHASE.md`
- 요약: 실제 e2e 흐름을 기준으로 task lifecycle, retry, timeout, notification event를 안정화한다.
## 아카이브 Milestone 요약 - [계획] External Integration
- 경로: `agent-ops/roadmap/phase/external-integration/PHASE.md`
- 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 확장, Mattermost, Agent Integrator, IOP OpenAI API Responses-compatible 호출을 실제 통합 흐름으로 확장한다.
- 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의 선행 기반이 되었다. - [계획] Project Workspace Management UX
- 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의 기반이 되었다. - 경로: `agent-ops/roadmap/phase/project-workspace-management-ux/PHASE.md`
- 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가 상태 전이 구현을 시작할 수 있는 기준이 되었다. - 요약: Flutter-first 클라이언트 구조 위에서 프로젝트 단위 제어 UX를 앱 기반 desktop/mobile layout 기준으로 정리한다.
## 로딩 정책 ## 로딩 정책
- 일반 작업에서는 `agent-ops/roadmap/ROADMAP.md`를 매번 읽지 않는다. - 일반 작업에서는 `agent-ops/roadmap/ROADMAP.md`를 매번 읽지 않는다.
- 기능 추가, 구조 변경, 스킬 추가/수정, 문서 구조 변경 작업을 수행할 때는 `agent-ops/roadmap/current.md`를 먼저 읽는다. - 기능 추가, 구조 변경, 스킬 추가/수정, 문서 구조 변경 작업을 수행할 때는 `agent-ops/roadmap/current.md`를 먼저 읽는다.
- `current.md`는 현재 작업 위치가 아니라 활성 Milestone 후보 목록이다. - `current.md`는 현재 작업 위치가 아니라 활성 Phase와 활성 Milestone 후보 목록이다.
- `current.md`에는 개인별 현재 작업 위치나 완료 상태를 기록하지 않는다. - `current.md`에는 개인별 현재 작업 위치나 완료 상태를 기록하지 않는다.
- `current.md`의 활성 Milestone은 `agent-ops/roadmap/milestones/` 하위 문서만 가리키며, `agent-ops/roadmap/archive/**`는 포함하지 않는다. - `current.md`의 활성 Phase는 `agent-ops/roadmap/phase/<phase-slug>/PHASE.md`를 가리킨다.
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 활성 Milestone 문서를 같은 세션에서 1회 읽는다. - `current.md`의 활성 Milestone은 `agent-ops/roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md`를 가리킨다.
- 활성 Milestone 밖의 작업이면 이 문서의 Milestone 목록을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다. - `current.md``agent-ops/roadmap/archive/**` 경로를 활성 항목으로 포함하지 않는다.
- 이 문서는 로드맵 생성/갱신, Phase 전환, Milestone 추가/수정 요청이 있을 때만 읽는다. - 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 활성 Phase와 Milestone 문서를 같은 세션에서 1회 읽는다.
- 상세 작업과 완료 기준은 각 Milestone 문서의 체크리스트로 관리한다. - 활성 Phase 또는 Milestone 밖의 작업이면 이 문서의 Phase 흐름을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
- 완료 또는 폐기되어 아카이브된 Milestone은 이 문서의 `아카이브 Milestone 요약`에 당시 요약만 남기고, 아카이브 문서 링크나 상세 경로는 남기지 않는다. - 이 문서는 로드맵 생성/갱신, Phase 전환, Phase 추가/수정, 전체 구조 변경 요청이 있을 때만 읽는다.
- 상세 문서가 있는 `agent-ops/roadmap/archive/**`는 사용자가 명시적으로 요청한 경우에만 읽는다. - 상세 작업과 완료 기준은 각 Milestone 문서의 `필수 기능`, `완료 기준`으로 관리한다.
- 아카이브된 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/파일 구조 확정을 시작하기 전에 현재 요청에 직접 영향을 주는 `결정 필요` 항목만 확인한다. - 선택된 Milestone의 `구현 잠금` 섹션이 없거나 상태가 `잠금`이면 코드 구현, `agent-task` 구현 계획 생성, 세부 API/파일 구조 확정을 시작하기 전에 현재 요청에 직접 영향을 주는 `결정 필요` 항목만 확인한다.
- 현재 요청과 직접 관련 없는 미정 항목은 잠금 상태로 남겨도 되며, 기존 구조/도메인 rule/플랫폼 관례로 정할 수 있는 작업은 표준선으로 기록하고 진행할 수 있다. - 현재 요청과 직접 관련 없는 미정 항목은 잠금 상태로 남겨도 되며, 기존 구조/도메인 rule/플랫폼 관례로 정할 수 있는 작업은 표준선으로 기록하고 진행할 수 있다.
- Milestone 전체에서 사용자만 결정할 항목이 더 이상 없고 에이전트가 표준선에 따라 실행하면 되는 상태라면 `구현 잠금` 상태를 `해제`로 둔다. - 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 ## 활성 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 후보 목록이며, 개인별 현재 작업 위치나 완료 상태를 기록하지 않는다. - 이 문서는 활성 Phase와 활성 Milestone 후보 목록이며, 개인별 현재 작업 위치나 완료 상태를 기록하지 않는다.
- 활성 Milestone은 `agent-ops/roadmap/milestones/` 하위 문서만 가리키며, `agent-ops/roadmap/archive/**`는 포함하지 않는다. - 활성 Phase는 `agent-ops/roadmap/phase/<phase-slug>/PHASE.md`를 가리킨다.
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 Milestone을 선택하고 같은 세션에서 1회 읽는다. - 활성 Milestone은 `agent-ops/roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md`를 가리킨다.
- 활성 Milestone 둘 이상에 걸치면 필요한 Milestone 문서를 모두 읽고 작업 범위를 좁힌다. - 활성 항목은 아카이브 경로를 포함하지 않는다.
- 활성 Milestone 밖의 작업이면 `agent-ops/roadmap/ROADMAP.md`의 Milestone 목록을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다. - `[검토중]` 항목은 사용자 완료 확인 전까지 활성 항목으로 남길 수 있다.
- `[완료]` 또는 `[폐기]` 항목은 archive 링크를 남긴 뒤 활성 항목에서 제거한다.
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 Phase와 Milestone을 선택하고 같은 세션에서 1회 읽는다.
- 활성 Phase 또는 Milestone 둘 이상에 걸치면 필요한 문서를 모두 읽고 작업 범위를 좁힌다.
- 활성 범위 밖의 작업이면 `agent-ops/roadmap/ROADMAP.md`의 Phase 흐름을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
- 완료된 과거 내용이 필요할 때만 `ROADMAP.md` 또는 `PHASE.md`에 있는 archive 링크를 따라가서 읽는다.
- 선택된 Milestone의 `구현 잠금` 섹션이 없거나 상태가 `잠금`이면 구현이나 구현 계획을 시작하기 전에 현재 요청에 직접 영향을 주는 `결정 필요` 항목만 확인한다. 관련 결정이 없고 표준선으로 처리 가능하면 잠금을 유지한 채 진행할 수 있으며, Milestone 전체에서 사용자만 결정할 항목이 더 이상 없을 때만 `구현 잠금` 상태를 `해제`로 둔다. - 선택된 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을 외부 호출 표면으로 사용하지 않는다. 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 확장 - [ ] [plane-adapter-expand] Plane issue 생성, comment, status update adapter 확장
- [ ] [jira-adapter] Jira issue 조회, comment, status transition adapter 구현 - [ ] [jira-adapter] Jira issue 조회, comment, status transition adapter 구현
- [ ] [mattermost-adapter] Mattermost 메시지 발송 adapter 구현 - [ ] [mattermost-adapter] Mattermost 메시지 발송 adapter 구현
@ -49,6 +54,16 @@ External Integration
- [x] NomadCode의 기본 실행 경로가 직접 모델 호출이 아니라 IOP 경유 호출임이 로드맵과 운영 문서에서 일관되게 읽힌다. - [x] NomadCode의 기본 실행 경로가 직접 모델 호출이 아니라 IOP 경유 호출임이 로드맵과 운영 문서에서 일관되게 읽힌다.
- [ ] 외부 provider별 세부 구현이 adapter 경계 밖으로 새지 않는다. - [ ] 외부 provider별 세부 구현이 adapter 경계 밖으로 새지 않는다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외 ## 범위 제외
- Outline / Forgejo / Nextcloud 연동 - 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로 정리한다. 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를 정의한다. - [ ] [common-capability] 프로젝트 단위 제어 화면의 공통 capability를 정의한다.
- [ ] [mobile-tabs] mobile 상단탭 기반 프로젝트 관리 구조를 정의한다. - [ ] [mobile-tabs] mobile 상단탭 기반 프로젝트 관리 구조를 정의한다.
- [ ] [desktop-tabs] Flutter 앱 desktop 탭 기반 프로젝트 관리 구조를 정의한다. - [ ] [desktop-tabs] Flutter 앱 desktop 탭 기반 프로젝트 관리 구조를 정의한다.
@ -46,6 +51,16 @@ Project Workspace Management UX
- [ ] IOP 실행 결과, task 상태, 파일 변경 결과가 NomadCode UX에서 어떻게 보이는지 최소 기준이 정리되어 있다. - [ ] IOP 실행 결과, task 상태, 파일 변경 결과가 NomadCode UX에서 어떻게 보이는지 최소 기준이 정리되어 있다.
- [ ] 기존 Project/Session/Workspace 방향을 대체하지 않고 보강하는 Milestone으로 읽힌다. - [ ] 기존 Project/Session/Workspace 방향을 대체하지 않고 보강하는 Milestone으로 읽힌다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외 ## 범위 제외
- IOP 내부 모델 라우팅, 모델 프로파일, RAG, MCP, output validation 구현 - 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의 운영 기준을 만든다. 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 상태 모델 점검 - [ ] [lifecycle-model] task lifecycle 상태 모델 점검
- [ ] [transition-ownership] scheduler와 workflow service의 상태 전이 책임 정리 - [ ] [transition-ownership] scheduler와 workflow service의 상태 전이 책임 정리
- [ ] [failure-policy] 실패 task 처리 기준 - [ ] [failure-policy] 실패 task 처리 기준
@ -40,6 +45,16 @@ Workflow Core
- [ ] retry 정책의 최소 동작 방식이 구현되거나 명확히 문서화된다. - [ ] retry 정책의 최소 동작 방식이 구현되거나 명확히 문서화된다.
- [ ] notification event가 workflow 상태 변화와 연결된다. - [ ] notification event가 workflow 상태 변화와 연결된다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외 ## 범위 제외
- 실제 외부 협업 도구로 notification 발송 - 실제 외부 협업 도구로 notification 발송

View file

@ -1,6 +1,28 @@
# nomadcode-app # 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 ## Getting Started

View file

@ -1,7 +1,6 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
id("kotlin-kapt")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services") id("com.google.gms.google-services")
@ -48,25 +47,4 @@ flutter {
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") 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" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/> <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 <application
android:label="nomadcode-app" android:label="nomadcode-app"
android:name=".MainApplication"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"> android:roundIcon="@mipmap/ic_launcher_round">
<activity <activity
@ -37,42 +32,6 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> 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> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and 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 android.os.Bundle
import androidx.core.view.WindowCompat 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.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() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) 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 'dart:async';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mattermost_push_plugin/mattermost_push_plugin.dart';
import 'services/mattermost_auth_service.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 'services/workspace_launcher.dart';
import 'screens/workspace_home_page.dart'; import 'screens/workspace_home_page.dart';
final _navigatorKey = GlobalKey<NavigatorState>(); final _navigatorKey = GlobalKey<NavigatorState>();
final _pushService = PushNotificationService(); final _pushService = MattermostPushPlugin.instance;
final _launcher = WorkspaceLauncher(); final _launcher = WorkspaceLauncher();
Future<void> _applyFullscreenMode() async { Future<void> _applyFullscreenMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
statusBarColor: Colors.transparent, const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent, statusBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent, systemNavigationBarColor: Colors.transparent,
)); systemNavigationBarDividerColor: Colors.transparent,
),
);
} }
void main() async { void main() async {
@ -29,9 +29,7 @@ void main() async {
await Firebase.initializeApp(); await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); await _pushService.initialize();
_pushService.init();
// Mattermost + FCM // Mattermost + FCM
final authService = MattermostAuthService(_pushService); final authService = MattermostAuthService(_pushService);

View file

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

View file

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

View file

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

View file

@ -9,14 +9,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.59" version: "1.3.59"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@ -65,14 +57,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -81,14 +65,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
firebase_core: firebase_core:
dependency: "direct main" dependency: "direct main"
description: description:
@ -114,7 +90,7 @@ packages:
source: hosted source: hosted
version: "2.24.1" version: "2.24.1"
firebase_messaging: firebase_messaging:
dependency: "direct main" dependency: transitive
description: description:
name: firebase_messaging name: firebase_messaging
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
@ -150,30 +126,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -248,6 +200,13 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.13.0"
mattermost_push_plugin:
dependency: "direct main"
description:
path: "../../../mattermost-push-plugin"
relative: true
source: path
version: "0.0.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -264,14 +223,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -333,14 +284,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.10"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -437,22 +380,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.11.3 <4.0.0"
flutter: ">=3.38.0" flutter: ">=3.38.0"

View file

@ -38,13 +38,14 @@ dependencies:
# Firebase # Firebase
firebase_core: ^3.13.0 firebase_core: ^3.13.0
firebase_messaging: ^15.2.5
# Mattermost push plugin
mattermost_push_plugin:
path: ../../../mattermost-push-plugin
# HTTP client # HTTP client
http: ^1.3.0 http: ^1.3.0
# Local notifications (foreground display)
flutter_local_notifications: ^18.0.1
url_launcher: ^6.3.2 url_launcher: ^6.3.2
dev_dependencies: 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과 일치) The host application acts strictly as a consumer of the `mattermost_push_plugin` package. What remains in `apps/mobile` regarding push notifications are:
**원본 참조:** `/config/workspace/mattermost-mobile`
--- 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)
``` All native Kotlin/Java files, Room DB configurations, and broadcast receivers documented below are now managed within the `mattermost_push_plugin` repository:
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 콜백)
```
--- - `MattermostFirebaseMessagingService.kt`
- `NotificationReplyBroadcastReceiver.java`
- `NotificationDismissService.java`
- `ReceiptDelivery.java`
- `PushNotificationDataHelper.kt`
- `helpers/db/MattermostDatabase.kt` & `GlobalDatabase.kt`
## 완료된 작업 (건드리지 말 것) 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.
### 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` |

View file

@ -5,7 +5,9 @@ import 'package:nomadcode_app/screens/workspace_home_page.dart';
import 'package:nomadcode_app/models/project_workspace.dart'; import 'package:nomadcode_app/models/project_workspace.dart';
void main() { 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()); await tester.pumpWidget(const MyApp());
expect(find.text('NomadCode Console'), findsOneWidget); expect(find.text('NomadCode Console'), findsOneWidget);
@ -13,7 +15,9 @@ void main() {
expect(find.text('IOP Bridge'), findsOneWidget); 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; ProjectWorkspace? launchedProject;
await tester.pumpWidget( await tester.pumpWidget(