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
|
|
@ -4,58 +4,58 @@
|
|||
|
||||
NomadCode는 Flutter 기반 앱, core 서비스, 공유 계약, agent-operation 규칙을 하나의 원레포로 묶어 AI-assisted development workflow를 조율하는 프로젝트다.
|
||||
|
||||
현재 로드맵은 기존 `services/core/README.md`의 단계별 다음 작업을 `agent-ops/roadmap/` 구조로 옮긴 뒤, 클라이언트 전략을 Flutter-first로 재정렬한 것이다. React/Vite 웹 콘솔 제거와 Flutter 앱 기준 클라이언트 정리, provider-neutral work item pipeline 설계는 완료되었다. 현재는 Flutter 앱에 섞인 Mattermost push notification migration을 별도 `mattermost-push-plugin` Flutter plugin repo로 분리하는 작업을 최상위 진행 항목으로 둔다. 이후 core 백엔드 오케스트레이션의 workflow 상태 전이 안정화, 외부 통합, Flutter-first 프로젝트 제어 UX를 이어간다. IOP 연결은 NomadCode가 직접 모델 런타임을 소유하지 않고 IOP의 외부 입력 표면을 호출하는 방향으로 정리한다.
|
||||
현재 로드맵은 `ROADMAP.md -> phase/<phase-slug>/PHASE.md -> phase/<phase-slug>/milestones/<milestone-slug>.md` scaffold를 기준으로 관리한다. React/Vite 웹 콘솔 제거, 서버/Plane/provider 기반 작업, Flutter-first 클라이언트 정리는 완료되었고, Mattermost push plugin extraction은 사용자 완료 확인과 archive 승인 전의 검토중 상태다. 이후 core workflow 안정화, 외부 통합, Flutter-first 프로젝트 제어 UX를 이어간다.
|
||||
|
||||
## Phase 흐름
|
||||
|
||||
- Client Platform Consolidation: React/Vite 웹 콘솔을 제품 UI 경로에서 걷어내고 Flutter 앱을 NomadCode 클라이언트 source of truth로 정리한다.
|
||||
- Mattermost Push Plugin Extraction: Flutter 앱에 직접 들어간 Mattermost push notification Dart/API, Android native service, Room DB, notification resource를 `../mattermost-push-plugin` Flutter plugin repo로 분리하고 앱 통합 경계를 정리한다.
|
||||
- Server Skeleton: 서버 실행 골격, task 저장 구조, 비동기 job 실행, Adapter stub을 구성한다.
|
||||
- Plane Communication Foundation: Plane self-hosted 인스턴스와 통신하기 위한 인증, API client, 외부 참조 저장, smoke 검증 토대를 만든다.
|
||||
- Work Item Provider Pipeline Design: Plane/Jira 등 work item provider와 core task 사이의 생성, enqueue, 상태 투영, 결과 발행 계약을 provider-neutral하게 정리한다.
|
||||
- Workflow Core: Plane task pipeline 설계가 정리된 뒤 상태 전이, retry, timeout, notification event의 기본 구조를 안정화한다.
|
||||
- External Integration: Plane 통신 토대와 workflow core 이후 Mattermost, Agent Integrator, IOP OpenAI API Responses-compatible 호출을 실제 통합 흐름으로 확장한다.
|
||||
- Project Workspace Management UX: Flutter-first 클라이언트 구조 위에서 Project/Session/Workspace 방향을 유지하면서 앱 기반 프로젝트 단위 제어 화면을 정리한다.
|
||||
위에서 아래로 진행된 순서와 예정 흐름을 나타낸다.
|
||||
완료된 Phase도 로드맵에서 제거하지 않고, archive의 Phase 문서로 연결한다.
|
||||
검토중 또는 진행중 Phase는 계획 Phase보다 위에 두어, 아래로 갈수록 미래 계획에 가까워지게 정렬한다.
|
||||
|
||||
## Milestone 목록
|
||||
|
||||
### Mattermost Push Plugin Extraction
|
||||
|
||||
- [Mattermost Push Plugin Extraction](milestones/mattermost-push-plugin-extraction.md) - 상태: 진행 중; 목표: Flutter 앱에 섞인 Mattermost push notification migration을 `../mattermost-push-plugin` 독립 Flutter plugin repo로 분리하고 NomadCode 앱은 plugin consumer로 전환한다.
|
||||
|
||||
### Workflow Core
|
||||
|
||||
- [Workflow Core](milestones/workflow-core.md) - 상태: 진행 중; 목표: 실제 e2e 흐름을 기준으로 task lifecycle, retry, timeout, notification event를 안정화한다.
|
||||
|
||||
### External Integration
|
||||
|
||||
- [External Integration](milestones/external-integration.md) - 상태: 계획; 목표: Plane 확장, Mattermost, Agent Integrator, IOP OpenAI API Responses-compatible 호출을 실제 통합 흐름으로 확장한다.
|
||||
|
||||
### Project Workspace Management UX
|
||||
|
||||
- [Project Workspace Management UX](milestones/project-workspace-management-ux.md) - 상태: 계획; 목표: Flutter-first 클라이언트 구조 위에서 프로젝트 단위 제어 UX를 앱 기반 desktop/mobile layout 기준으로 정리한다.
|
||||
|
||||
## 아카이브 Milestone 요약
|
||||
|
||||
- Server Skeleton - 상태: 완료; 아카이브일: 2026-05-24; 요약: core 서버 실행 골격, task 저장, 비동기 job, 초기 외부 Adapter stub을 구성했다.; 핵심 산출물/근거: 모든 필수 기능과 완료 기준이 체크되어 있으며 server 실행, task API/storage, River job, Plane/Mattermost stub이 기록되어 있다.; 후속 영향: Plane Communication Foundation과 후속 workflow/provider 작업의 기반이 되었다.
|
||||
- Plane Communication Foundation - 상태: 완료; 아카이브일: 2026-05-24; 요약: Plane self-hosted 인스턴스와의 인증/API 통신, 외부 참조 저장, 수동 smoke 검증 토대를 만들었다.; 핵심 산출물/근거: 모든 필수 기능과 완료 기준이 체크되어 있으며 Plane adapter 테스트, external ref 저장, 실제 comment/state smoke, README/운영 문서 근거가 기록되어 있다.; 후속 영향: Work Item Provider Pipeline Design과 Workflow Core의 선행 기반이 되었다.
|
||||
- Flutter-first Client Consolidation - 상태: 완료; 아카이브일: 2026-05-25; 요약: Flutter 앱을 제품 UI 기준 구현으로 세우고 React/Vite 웹 콘솔 scaffold를 제거했다.; 핵심 산출물/근거: `apps/web` 제거, root `bin/*`의 Flutter-first 검증 흐름, Flutter code-server 직접 실행 표면, `packages/contracts` 계약 후보 문서가 반영되어 있다.; 후속 영향: Project Workspace Management UX와 사용자-facing task state UX의 기반이 되었다.
|
||||
- Work Item Provider Pipeline Design - 상태: 완료; 아카이브일: 2026-05-25; 요약: Plane/Jira 등 work item provider와 core task 사이의 생성, enqueue, 상태 투영, 결과 발행 계약을 provider-neutral하게 정리했다.; 핵심 산출물/근거: 모든 필수 기능과 완료 기준이 체크되어 있으며 provider-neutral adapter, task mapper, pipeline service, projection/idempotency/retry 정책이 기록되어 있다.; 후속 영향: Workflow Core가 상태 전이 구현을 시작할 수 있는 기준이 되었다.
|
||||
- [완료] Server Skeleton
|
||||
- 경로: `agent-ops/roadmap/archive/phase/server-skeleton/PHASE.md`
|
||||
- 요약: core 서버 실행 골격, task 저장 구조, 비동기 job 실행, Adapter stub을 구성했다.
|
||||
- [완료] Plane Communication Foundation
|
||||
- 경로: `agent-ops/roadmap/archive/phase/plane-communication-foundation/PHASE.md`
|
||||
- 요약: Plane self-hosted 인스턴스와 통신하기 위한 인증, API client, 외부 참조 저장, smoke 검증 토대를 만들었다.
|
||||
- [완료] Client Platform Consolidation
|
||||
- 경로: `agent-ops/roadmap/archive/phase/client-platform-consolidation/PHASE.md`
|
||||
- 요약: React/Vite 웹 콘솔을 제품 UI 경로에서 걷어내고 Flutter 앱을 NomadCode 클라이언트 source of truth로 정리했다.
|
||||
- [완료] Work Item Provider Pipeline Design
|
||||
- 경로: `agent-ops/roadmap/archive/phase/work-item-provider-pipeline-design/PHASE.md`
|
||||
- 요약: Plane/Jira 등 work item provider와 core task 사이의 생성, enqueue, 상태 투영, 결과 발행 계약을 provider-neutral하게 정리했다.
|
||||
- [검토중] Mattermost Push Plugin Extraction
|
||||
- 경로: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/PHASE.md`
|
||||
- 요약: Flutter 앱에 섞인 Mattermost push notification migration을 `../mattermost-push-plugin` Flutter plugin repo로 분리했고, 사용자 완료 확인과 archive 승인을 기다린다.
|
||||
- [진행중] Workflow Core
|
||||
- 경로: `agent-ops/roadmap/phase/workflow-core/PHASE.md`
|
||||
- 요약: 실제 e2e 흐름을 기준으로 task lifecycle, retry, timeout, notification event를 안정화한다.
|
||||
- [계획] External Integration
|
||||
- 경로: `agent-ops/roadmap/phase/external-integration/PHASE.md`
|
||||
- 요약: Plane 확장, Mattermost, Agent Integrator, IOP OpenAI API Responses-compatible 호출을 실제 통합 흐름으로 확장한다.
|
||||
- [계획] Project Workspace Management UX
|
||||
- 경로: `agent-ops/roadmap/phase/project-workspace-management-ux/PHASE.md`
|
||||
- 요약: Flutter-first 클라이언트 구조 위에서 프로젝트 단위 제어 UX를 앱 기반 desktop/mobile layout 기준으로 정리한다.
|
||||
|
||||
## 로딩 정책
|
||||
|
||||
- 일반 작업에서는 `agent-ops/roadmap/ROADMAP.md`를 매번 읽지 않는다.
|
||||
- 기능 추가, 구조 변경, 스킬 추가/수정, 문서 구조 변경 작업을 수행할 때는 `agent-ops/roadmap/current.md`를 먼저 읽는다.
|
||||
- `current.md`는 현재 작업 위치가 아니라 활성 Milestone 후보 목록이다.
|
||||
- `current.md`는 현재 작업 위치가 아니라 활성 Phase와 활성 Milestone 후보 목록이다.
|
||||
- `current.md`에는 개인별 현재 작업 위치나 완료 상태를 기록하지 않는다.
|
||||
- `current.md`의 활성 Milestone은 `agent-ops/roadmap/milestones/` 하위 문서만 가리키며, `agent-ops/roadmap/archive/**`는 포함하지 않는다.
|
||||
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 활성 Milestone 문서를 같은 세션에서 1회 읽는다.
|
||||
- 활성 Milestone 밖의 작업이면 이 문서의 Milestone 목록을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
|
||||
- 이 문서는 로드맵 생성/갱신, Phase 전환, Milestone 추가/수정 요청이 있을 때만 읽는다.
|
||||
- 상세 작업과 완료 기준은 각 Milestone 문서의 체크리스트로 관리한다.
|
||||
- 완료 또는 폐기되어 아카이브된 Milestone은 이 문서의 `아카이브 Milestone 요약`에 당시 요약만 남기고, 아카이브 문서 링크나 상세 경로는 남기지 않는다.
|
||||
- 상세 문서가 있는 `agent-ops/roadmap/archive/**`는 사용자가 명시적으로 요청한 경우에만 읽는다.
|
||||
- 아카이브된 Milestone 문서는 최신 템플릿이나 스킬 규약에 맞춰 재포맷하지 않는다.
|
||||
- `current.md`의 활성 Phase는 `agent-ops/roadmap/phase/<phase-slug>/PHASE.md`를 가리킨다.
|
||||
- `current.md`의 활성 Milestone은 `agent-ops/roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md`를 가리킨다.
|
||||
- `current.md`는 `agent-ops/roadmap/archive/**` 경로를 활성 항목으로 포함하지 않는다.
|
||||
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 활성 Phase와 Milestone 문서를 같은 세션에서 1회 읽는다.
|
||||
- 활성 Phase 또는 Milestone 밖의 작업이면 이 문서의 Phase 흐름을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
|
||||
- 이 문서는 로드맵 생성/갱신, Phase 전환, Phase 추가/수정, 전체 구조 변경 요청이 있을 때만 읽는다.
|
||||
- 상세 작업과 완료 기준은 각 Milestone 문서의 `필수 기능`, `완료 기준`으로 관리한다.
|
||||
- 모든 필수 작업과 완료 기준이 충족된 Milestone은 먼저 `[검토중]`으로 두고, 사용자 완료 확인과 archive 승인을 받은 뒤 `[완료]`로 전환한다.
|
||||
- 완료된 Phase는 `agent-ops/roadmap/archive/phase/<phase-slug>/PHASE.md`로 이동하고, 하위 Milestone도 같은 archive Phase scaffold 아래에 둔다.
|
||||
- 진행중 Phase 안에서 완료된 Milestone은 활성 Phase 문서에 짧은 링크를 남기고, 상세 문서는 `agent-ops/roadmap/archive/phase/<phase-slug>/milestones/`로 이동한다.
|
||||
- archive `PHASE.md`는 Phase 자체가 완료 또는 폐기될 때만 만들며, 진행중 Phase의 완료 Milestone만 archive된 경우 archive Phase 디렉터리에 `milestones/`만 있을 수 있다.
|
||||
- `agent-ops/roadmap/archive/**`는 일반 작업에서 읽지 않는다. 과거 완료 내용, 완료 근거, 복원, 비교가 필요한 경우에만 `ROADMAP.md` 또는 `PHASE.md`의 archive 링크를 따라가서 읽는다.
|
||||
- 아카이브된 Phase/Milestone 문서는 최신 템플릿이나 스킬 규약에 맞춰 재포맷하지 않는다.
|
||||
- 선택된 Milestone의 `구현 잠금` 섹션이 없거나 상태가 `잠금`이면 코드 구현, `agent-task` 구현 계획 생성, 세부 API/파일 구조 확정을 시작하기 전에 현재 요청에 직접 영향을 주는 `결정 필요` 항목만 확인한다.
|
||||
- 현재 요청과 직접 관련 없는 미정 항목은 잠금 상태로 남겨도 되며, 기존 구조/도메인 rule/플랫폼 관례로 정할 수 있는 작업은 표준선으로 기록하고 진행할 수 있다.
|
||||
- Milestone 전체에서 사용자만 결정할 항목이 더 이상 없고 에이전트가 표준선에 따라 실행하면 되는 상태라면 `구현 잠금` 상태를 `해제`로 둔다.
|
||||
|
|
|
|||
|
|
@ -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 스냅샷을 따른다.
|
||||
|
|
@ -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 스냅샷을 따른다.
|
||||
20
agent-ops/roadmap/archive/phase/server-skeleton/PHASE.md
Normal 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 스냅샷을 따른다.
|
||||
|
|
@ -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 스냅샷을 따른다.
|
||||
|
|
@ -1,14 +1,31 @@
|
|||
# 현재 로드맵 컨텍스트
|
||||
|
||||
## 활성 Phase
|
||||
|
||||
- [검토중] Mattermost Push Plugin Extraction
|
||||
- 경로: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/PHASE.md`
|
||||
- [진행중] Workflow Core
|
||||
- 경로: `agent-ops/roadmap/phase/workflow-core/PHASE.md`
|
||||
|
||||
## 활성 Milestone
|
||||
|
||||
- Mattermost Push Plugin Extraction: agent-ops/roadmap/milestones/mattermost-push-plugin-extraction.md
|
||||
- [검토중] Mattermost Push Plugin Extraction
|
||||
- Phase: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/PHASE.md`
|
||||
- 경로: `agent-ops/roadmap/phase/mattermost-push-plugin-extraction/milestones/mattermost-push-plugin-extraction.md`
|
||||
- [진행중] Workflow Core
|
||||
- Phase: `agent-ops/roadmap/phase/workflow-core/PHASE.md`
|
||||
- 경로: `agent-ops/roadmap/phase/workflow-core/milestones/workflow-core.md`
|
||||
|
||||
## 선택 규칙
|
||||
|
||||
- 이 문서는 활성 Milestone 후보 목록이며, 개인별 현재 작업 위치나 완료 상태를 기록하지 않는다.
|
||||
- 활성 Milestone은 `agent-ops/roadmap/milestones/` 하위 문서만 가리키며, `agent-ops/roadmap/archive/**`는 포함하지 않는다.
|
||||
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 Milestone을 선택하고 같은 세션에서 1회 읽는다.
|
||||
- 활성 Milestone 둘 이상에 걸치면 필요한 Milestone 문서를 모두 읽고 작업 범위를 좁힌다.
|
||||
- 활성 Milestone 밖의 작업이면 `agent-ops/roadmap/ROADMAP.md`의 Milestone 목록을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
|
||||
- 이 문서는 활성 Phase와 활성 Milestone 후보 목록이며, 개인별 현재 작업 위치나 완료 상태를 기록하지 않는다.
|
||||
- 활성 Phase는 `agent-ops/roadmap/phase/<phase-slug>/PHASE.md`를 가리킨다.
|
||||
- 활성 Milestone은 `agent-ops/roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md`를 가리킨다.
|
||||
- 활성 항목은 아카이브 경로를 포함하지 않는다.
|
||||
- `[검토중]` 항목은 사용자 완료 확인 전까지 활성 항목으로 남길 수 있다.
|
||||
- `[완료]` 또는 `[폐기]` 항목은 archive 링크를 남긴 뒤 활성 항목에서 제거한다.
|
||||
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 Phase와 Milestone을 선택하고 같은 세션에서 1회 읽는다.
|
||||
- 활성 Phase 또는 Milestone 둘 이상에 걸치면 필요한 문서를 모두 읽고 작업 범위를 좁힌다.
|
||||
- 활성 범위 밖의 작업이면 `agent-ops/roadmap/ROADMAP.md`의 Phase 흐름을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
|
||||
- 완료된 과거 내용이 필요할 때만 `ROADMAP.md` 또는 `PHASE.md`에 있는 archive 링크를 따라가서 읽는다.
|
||||
- 선택된 Milestone의 `구현 잠금` 섹션이 없거나 상태가 `잠금`이면 구현이나 구현 계획을 시작하기 전에 현재 요청에 직접 영향을 주는 `결정 필요` 항목만 확인한다. 관련 결정이 없고 표준선으로 처리 가능하면 잠금을 유지한 채 진행할 수 있으며, Milestone 전체에서 사용자만 결정할 항목이 더 이상 없을 때만 `구현 잠금` 상태를 `해제`로 둔다.
|
||||
|
|
|
|||
|
|
@ -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 의존성을 앱에 직접 둔다.
|
||||
- 확인 필요: 없음
|
||||
24
agent-ops/roadmap/phase/external-integration/PHASE.md
Normal 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 유지 여부는 사용자 결정이 필요하다.
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
# External Integration
|
||||
# Milestone: External Integration
|
||||
|
||||
## 위치
|
||||
|
||||
- Roadmap: `agent-ops/roadmap/ROADMAP.md`
|
||||
- Phase: `agent-ops/roadmap/phase/external-integration/PHASE.md`
|
||||
|
||||
## 목표
|
||||
|
||||
Work Item Provider Pipeline Design과 workflow core 이후 남은 Plane/Jira 확장, Mattermost, Agent Integrator, IOP 연결을 stub 또는 호환 호출 경로에서 실제 통합 흐름으로 확장한다. IOP 호출은 현재 단계에서 OpenAI API Responses-compatible 경로를 기본으로 하며, NomadCode가 직접 모델 런타임을 호출하거나 IOP native protocol을 외부 호출 표면으로 사용하지 않는다.
|
||||
|
||||
## 단계
|
||||
|
||||
External Integration
|
||||
|
||||
## 상태
|
||||
|
||||
계획
|
||||
[계획]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
|
|
@ -32,6 +33,10 @@ External Integration
|
|||
|
||||
## 필수 기능
|
||||
|
||||
### Epic: [external-integration] Provider and execution adapters
|
||||
|
||||
외부 work item provider, 협업 도구, 실행 표면을 adapter 경계 안에서 실제 통합 흐름으로 확장한다.
|
||||
|
||||
- [ ] [plane-adapter-expand] Plane issue 생성, comment, status update adapter 확장
|
||||
- [ ] [jira-adapter] Jira issue 조회, comment, status transition adapter 구현
|
||||
- [ ] [mattermost-adapter] Mattermost 메시지 발송 adapter 구현
|
||||
|
|
@ -49,6 +54,16 @@ External Integration
|
|||
- [x] NomadCode의 기본 실행 경로가 직접 모델 호출이 아니라 IOP 경유 호출임이 로드맵과 운영 문서에서 일관되게 읽힌다.
|
||||
- [ ] 외부 provider별 세부 구현이 adapter 경계 밖으로 새지 않는다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 없음
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- Outline / Forgejo / Nextcloud 연동
|
||||
|
|
@ -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 범위에서 제외한다.
|
||||
|
|
@ -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 이동 승인.
|
||||
|
|
@ -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 책임 경계와 우선순위는 사용자 결정이 필요한 잠금 항목으로 유지한다.
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
# Project Workspace Management UX
|
||||
# Milestone: Project Workspace Management UX
|
||||
|
||||
## 위치
|
||||
|
||||
- Roadmap: `agent-ops/roadmap/ROADMAP.md`
|
||||
- Phase: `agent-ops/roadmap/phase/project-workspace-management-ux/PHASE.md`
|
||||
|
||||
## 목표
|
||||
|
||||
Flutter-first 클라이언트 구조 위에서 기존 Project/Session/Workspace 방향을 유지하면서 프로젝트 단위 제어 UX를 앱 기반 desktop과 mobile layout으로 정리한다. Mobile에서 가능한 프로젝트 관리 기능은 desktop에도 포함하고, desktop은 프로젝트 단위 확장 표면을 code-server와 Agent Chat을 다루는 앱 shell로 정리한다.
|
||||
|
||||
## 단계
|
||||
|
||||
Project Workspace Management UX
|
||||
|
||||
## 상태
|
||||
|
||||
계획
|
||||
[계획]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
|
|
@ -31,6 +32,10 @@ Project Workspace Management UX
|
|||
|
||||
## 필수 기능
|
||||
|
||||
### Epic: [workspace-ux] Project workspace UX definition
|
||||
|
||||
Flutter-first 앱에서 project/session/workspace 제어 표면과 desktop/mobile 역할 경계를 정의한다.
|
||||
|
||||
- [ ] [common-capability] 프로젝트 단위 제어 화면의 공통 capability를 정의한다.
|
||||
- [ ] [mobile-tabs] mobile 상단탭 기반 프로젝트 관리 구조를 정의한다.
|
||||
- [ ] [desktop-tabs] Flutter 앱 desktop 탭 기반 프로젝트 관리 구조를 정의한다.
|
||||
|
|
@ -46,6 +51,16 @@ Project Workspace Management UX
|
|||
- [ ] IOP 실행 결과, task 상태, 파일 변경 결과가 NomadCode UX에서 어떻게 보이는지 최소 기준이 정리되어 있다.
|
||||
- [ ] 기존 Project/Session/Workspace 방향을 대체하지 않고 보강하는 Milestone으로 읽힌다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 없음
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- IOP 내부 모델 라우팅, 모델 프로파일, RAG, MCP, output validation 구현
|
||||
24
agent-ops/roadmap/phase/workflow-core/PHASE.md
Normal 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 책임으로 둔다.
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
# Workflow Core
|
||||
# Milestone: Workflow Core
|
||||
|
||||
## 위치
|
||||
|
||||
- Roadmap: `agent-ops/roadmap/ROADMAP.md`
|
||||
- Phase: `agent-ops/roadmap/phase/workflow-core/PHASE.md`
|
||||
|
||||
## 목표
|
||||
|
||||
Plane 통신 토대와 별도 pipeline 설계가 정리된 뒤 실제 상태 변화를 기준으로 task 상태 전이를 정리하고 enqueue / running / completed / failed 흐름을 안정화한다. retry, timeout, notification event의 기본 구조를 추가해 core workflow의 운영 기준을 만든다.
|
||||
|
||||
## 단계
|
||||
|
||||
Workflow Core
|
||||
|
||||
## 상태
|
||||
|
||||
진행 중
|
||||
[진행중]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
|
|
@ -27,6 +28,10 @@ Workflow Core
|
|||
|
||||
## 필수 기능
|
||||
|
||||
### Epic: [workflow-core] Workflow lifecycle stability
|
||||
|
||||
Core task lifecycle과 상태 전이 책임, 실패/재시도/timeout/notification event 기준을 안정화한다.
|
||||
|
||||
- [ ] [lifecycle-model] task lifecycle 상태 모델 점검
|
||||
- [ ] [transition-ownership] scheduler와 workflow service의 상태 전이 책임 정리
|
||||
- [ ] [failure-policy] 실패 task 처리 기준
|
||||
|
|
@ -40,6 +45,16 @@ Workflow Core
|
|||
- [ ] retry 정책의 최소 동작 방식이 구현되거나 명확히 문서화된다.
|
||||
- [ ] notification event가 workflow 상태 변화와 연결된다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 없음
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- 실제 외부 협업 도구로 notification 발송
|
||||
|
|
@ -1,6 +1,28 @@
|
|||
# nomadcode-app
|
||||
|
||||
nomadcode-app Flutter project.
|
||||
The Flutter-first NomadCode client application.
|
||||
|
||||
## Mattermost Push Notification Integration
|
||||
|
||||
Mattermost push notification delivery is implemented via the local `mattermost_push_plugin` path dependency.
|
||||
|
||||
### Architecture & Responsibility Split
|
||||
|
||||
To maintain a clean and maintainable codebase, the responsibilities are split between the host application and the plugin as follows:
|
||||
|
||||
#### Host Application (`apps/mobile`) Responsibilities:
|
||||
- **Firebase Configuration**: Contains the standard Firebase configuration files (`android/app/google-services.json`) and performs the standard `Firebase.initializeApp()` call in Dart main.
|
||||
- **Mattermost Authentication**: Handles login and session lifecycles, and securely registers the user authentication token and server signing key to the plugin by calling `MattermostPushPlugin.instance.setAuthToken()` and `MattermostPushPlugin.instance.setSigningKey()`.
|
||||
- **Navigation Callbacks**: Registers standard Flutter navigation callbacks `onNavigateToChannel` and `onNavigateToThread` to handle workspace channel and CRT thread routing upon user interaction.
|
||||
- **FCM Token Sync**: Listens to device token registrations via `onDeviceTokenReady` and registers the token with the Mattermost server.
|
||||
|
||||
#### Mattermost Push Plugin (`mattermost_push_plugin`) Responsibilities:
|
||||
- **Native Android FCM Service**: Intercepts background/foreground FCM notifications securely via a custom Kotlin FCM service.
|
||||
- **Signature Verification**: Validates incoming push payload signatures against stored public signing keys (using `JJWT`).
|
||||
- **System Notification Builder**: Displays structured notifications with local avatar caches, thread summaries, and action buttons.
|
||||
- **Inline Reply / Dismiss Actions**: Receives native background actions, records responses in a Room database, and forwards replies/ACKs directly to the Mattermost API via custom OkHttp adapters.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("kotlin-kapt")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
|
|
@ -48,25 +47,4 @@ flutter {
|
|||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
// Firebase BOM (버전 통합 관리)
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
|
||||
// JWT 서명 검증 (mattermost와 동일 버전)
|
||||
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-orgjson:0.12.5") {
|
||||
exclude(group = "org.json", module = "json")
|
||||
}
|
||||
|
||||
// HTTP 클라이언트
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// Kotlin Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
// Room DB (로컬 캐시용, Kotlin 2.2.0 지원을 위해 최신 RC 버전 사용)
|
||||
implementation("androidx.room:room-runtime:2.7.0-rc01")
|
||||
implementation("androidx.room:room-ktx:2.7.0-rc01")
|
||||
kapt("androidx.room:room-compiler:2.7.0-rc01")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
|
||||
<application
|
||||
android:label="nomadcode-app"
|
||||
android:name=".MainApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
<activity
|
||||
|
|
@ -37,42 +32,6 @@
|
|||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Flutter firebase_messaging 플러그인 컴포넌트 비활성화 (네이티브 서비스 사용) -->
|
||||
<receiver
|
||||
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
|
||||
tools:node="remove" />
|
||||
<service
|
||||
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
|
||||
tools:node="remove" />
|
||||
<service
|
||||
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingBackgroundService"
|
||||
tools:node="remove" />
|
||||
<provider
|
||||
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingInitProvider"
|
||||
android:authorities="${applicationId}.flutterfirebasemessaginginitprovider"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- Firebase Messaging Service (네이티브) -->
|
||||
<service
|
||||
android:name=".MattermostFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- 알림 삭제 서비스 -->
|
||||
<service
|
||||
android:name=".NotificationDismissService"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- 알림 답장 브로드캐스트 리시버 -->
|
||||
<receiver
|
||||
android:name=".NotificationReplyBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -2,118 +2,11 @@ package com.tokilabs.mattermost
|
|||
|
||||
import android.os.Bundle
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.tokilabs.mattermost.helpers.DatabaseHelper
|
||||
import com.tokilabs.mattermost.helpers.Network
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Flutter 메인 액티비티.
|
||||
* - Flutter EventChannel 등록 (네이티브 알림 이벤트 → Dart 전달)
|
||||
* - Flutter MethodChannel 등록 (Dart → 네이티브 토큰 저장 등)
|
||||
* - FlutterEngineCache에 엔진 등록 (백그라운드에서 Flutter 초기화 여부 확인용)
|
||||
*/
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
companion object {
|
||||
const val NOTIFICATION_EVENT_CHANNEL = "com.tokilabs.mattermost/notifications"
|
||||
const val NOTIFICATION_METHOD_CHANNEL = "com.tokilabs.mattermost/notification_actions"
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
// FlutterEngineCache에 등록 → 백그라운드 서비스에서 초기화 여부 확인 가능
|
||||
FlutterEngineCache.getInstance()
|
||||
.put(MattermostFirebaseMessagingService.FLUTTER_ENGINE_ID, flutterEngine)
|
||||
|
||||
// EventChannel: 네이티브 알림 → Dart
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, NOTIFICATION_EVENT_CHANNEL)
|
||||
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
MattermostFirebaseMessagingService.eventSink = events
|
||||
}
|
||||
override fun onCancel(arguments: Any?) {
|
||||
MattermostFirebaseMessagingService.eventSink = null
|
||||
}
|
||||
})
|
||||
|
||||
// MethodChannel: Dart → 네이티브 (토큰 저장, 채널 클리어 등)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, NOTIFICATION_METHOD_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"saveDeviceToken" -> {
|
||||
val token = call.argument<String>("token")
|
||||
if (token != null) {
|
||||
DatabaseHelper.getInstance()?.saveDeviceToken(token)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("INVALID_ARG", "token is null", null)
|
||||
}
|
||||
}
|
||||
"getDeviceToken" -> {
|
||||
result.success(DatabaseHelper.getInstance()?.getDeviceToken())
|
||||
}
|
||||
// 로그인 성공 시 Dart에서 호출 → 서버 URL + 인증 토큰 저장
|
||||
"setAuthToken" -> {
|
||||
val serverUrl = call.argument<String>("serverUrl")
|
||||
val token = call.argument<String>("token")
|
||||
val identifier = call.argument<String>("identifier")
|
||||
if (serverUrl != null && token != null) {
|
||||
Network.setToken(serverUrl, token)
|
||||
DatabaseHelper.getInstance()?.saveServerUrl(serverUrl, identifier)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("INVALID_ARG", "serverUrl or token is null", null)
|
||||
}
|
||||
}
|
||||
// 서버의 JWT 서명 검증용 공개키 저장
|
||||
"setSigningKey" -> {
|
||||
val serverUrl = call.argument<String>("serverUrl")
|
||||
val signingKey = call.argument<String>("signingKey")
|
||||
if (serverUrl != null && signingKey != null) {
|
||||
getSharedPreferences("mattermost_signing", MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("signing_key_$serverUrl", signingKey)
|
||||
.apply()
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("INVALID_ARG", "serverUrl or signingKey is null", null)
|
||||
}
|
||||
}
|
||||
// 로그아웃 시 토큰 제거
|
||||
"clearAuthToken" -> {
|
||||
val serverUrl = call.argument<String>("serverUrl")
|
||||
if (serverUrl != null) {
|
||||
Network.clearToken(serverUrl)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("INVALID_ARG", "serverUrl is null", null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
// 알림 탭으로 앱 진입 시 Intent 데이터를 Flutter로 전달
|
||||
intent?.extras?.let { extras ->
|
||||
val data = mutableMapOf<String, Any?>()
|
||||
for (key in extras.keySet()) {
|
||||
data[key] = extras.get(key)
|
||||
}
|
||||
if (data.isNotEmpty()) {
|
||||
MattermostFirebaseMessagingService.eventSink?.success(
|
||||
data + mapOf("type" to (data["type"] ?: "opened"), "userInteraction" to true)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 구현 필요)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 610 B |
|
Before Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 610 B |
|
Before Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 958 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
|
@ -1,25 +1,25 @@
|
|||
import 'dart:async';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mattermost_push_plugin/mattermost_push_plugin.dart';
|
||||
import 'services/mattermost_auth_service.dart';
|
||||
import 'services/push_notification_background.dart';
|
||||
import 'services/push_notification_service.dart';
|
||||
import 'services/workspace_launcher.dart';
|
||||
import 'screens/workspace_home_page.dart';
|
||||
|
||||
final _navigatorKey = GlobalKey<NavigatorState>();
|
||||
final _pushService = PushNotificationService();
|
||||
final _pushService = MattermostPushPlugin.instance;
|
||||
final _launcher = WorkspaceLauncher();
|
||||
|
||||
Future<void> _applyFullscreenMode() async {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() async {
|
||||
|
|
@ -29,9 +29,7 @@ void main() async {
|
|||
|
||||
await Firebase.initializeApp();
|
||||
|
||||
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
|
||||
|
||||
_pushService.init();
|
||||
await _pushService.initialize();
|
||||
|
||||
// Mattermost 자동 로그인 + FCM 토큰 서버 등록
|
||||
final authService = MattermostAuthService(_pushService);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
enum ProjectStatus {
|
||||
active,
|
||||
paused,
|
||||
pending,
|
||||
}
|
||||
enum ProjectStatus { active, paused, pending }
|
||||
|
||||
class ProjectWorkspace {
|
||||
final String id;
|
||||
|
|
@ -44,7 +40,8 @@ const List<ProjectWorkspace> mockProjectWorkspaces = [
|
|||
name: 'Proto Socket',
|
||||
description: '고성능 전이중 RPC 채널 및 웹소켓 메시징 엔진',
|
||||
status: ProjectStatus.paused,
|
||||
codeServerUrl: 'http://localhost:8080/?folder=/config/workspace/proto-socket',
|
||||
codeServerUrl:
|
||||
'http://localhost:8080/?folder=/config/workspace/proto-socket',
|
||||
tasksCount: 0,
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ import '../models/project_workspace.dart';
|
|||
class WorkspaceHomePage extends StatefulWidget {
|
||||
final Future<bool> Function(ProjectWorkspace)? onLaunchCodeServer;
|
||||
|
||||
const WorkspaceHomePage({
|
||||
super.key,
|
||||
this.onLaunchCodeServer,
|
||||
});
|
||||
const WorkspaceHomePage({super.key, this.onLaunchCodeServer});
|
||||
|
||||
@override
|
||||
State<WorkspaceHomePage> createState() => _WorkspaceHomePageState();
|
||||
|
|
@ -41,7 +38,11 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
|
|||
color: Colors.deepPurpleAccent.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.blur_on, color: Colors.deepPurpleAccent, size: 24),
|
||||
child: const Icon(
|
||||
Icons.blur_on,
|
||||
color: Colors.deepPurpleAccent,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
|
|
@ -65,7 +66,9 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: isLargeScreen ? _buildDualPaneLayout(theme) : _buildSinglePaneLayout(theme),
|
||||
body: isLargeScreen
|
||||
? _buildDualPaneLayout(theme)
|
||||
: _buildSinglePaneLayout(theme),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -105,9 +108,7 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
|
|||
Widget _buildSinglePaneLayout(ThemeData theme) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildWorkspaceList(),
|
||||
),
|
||||
Expanded(child: _buildWorkspaceList()),
|
||||
if (_selectedProject != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
@ -156,7 +157,9 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
|
|||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: isSelected ? Colors.deepPurpleAccent : const Color(0xFF26264A),
|
||||
color: isSelected
|
||||
? Colors.deepPurpleAccent
|
||||
: const Color(0xFF26264A),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
|
|
@ -220,7 +223,10 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
|
|||
const SizedBox(width: 8),
|
||||
if (project.tasksCount > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.deepPurpleAccent.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
|
@ -267,7 +273,10 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: project.status == ProjectStatus.active
|
||||
? Colors.cyanAccent.withValues(alpha: 0.15)
|
||||
|
|
@ -374,13 +383,19 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
|
|||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Running workflow core optimizations...',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.cyanAccent.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
|
|
@ -410,19 +425,14 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
|
|||
backgroundColor: Colors.deepPurpleAccent,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 6,
|
||||
shadowColor: Colors.deepPurpleAccent.withValues(alpha: 0.4),
|
||||
),
|
||||
icon: const Icon(Icons.rocket_launch, size: 20),
|
||||
label: const Text(
|
||||
'Open Workspace',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.5),
|
||||
),
|
||||
onPressed: () async {
|
||||
if (widget.onLaunchCodeServer != null) {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import 'dart:convert';
|
|||
import 'package:flutter/foundation.dart' show FlutterError;
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'push_notification_service.dart';
|
||||
import 'package:mattermost_push_plugin/mattermost_push_plugin.dart';
|
||||
|
||||
/// Mattermost 자동 로그인 + FCM 디바이스 토큰 서버 등록 서비스.
|
||||
class MattermostAuthService {
|
||||
final PushNotificationService _pushService;
|
||||
final MattermostPushPlugin _pushService;
|
||||
|
||||
String? _serverUrl;
|
||||
String? _authToken;
|
||||
|
|
@ -22,7 +22,9 @@ class MattermostAuthService {
|
|||
Future<void> autoLoginAndRegister() async {
|
||||
final creds = await _loadCredentials();
|
||||
if (creds == null) {
|
||||
print('[MattermostAuth] Credentials asset not found, skipping auto login.');
|
||||
print(
|
||||
'[MattermostAuth] Credentials asset not found, skipping auto login.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_serverUrl = creds['serverUrl']!;
|
||||
|
|
@ -36,7 +38,9 @@ class MattermostAuthService {
|
|||
print('[MattermostAuth] FCM token already available, registering ...');
|
||||
await _registerDeviceToken(existingToken);
|
||||
} else {
|
||||
print('[MattermostAuth] FCM token not ready yet, waiting for callback ...');
|
||||
print(
|
||||
'[MattermostAuth] FCM token not ready yet, waiting for callback ...',
|
||||
);
|
||||
}
|
||||
|
||||
// FCM 토큰 발급/갱신 시 자동으로 서버에 등록
|
||||
|
|
@ -51,7 +55,9 @@ class MattermostAuthService {
|
|||
Future<Map<String, String>?> _loadCredentials() async {
|
||||
final String jsonStr;
|
||||
try {
|
||||
jsonStr = await rootBundle.loadString('assets/mattermost_credentials.json');
|
||||
jsonStr = await rootBundle.loadString(
|
||||
'assets/mattermost_credentials.json',
|
||||
);
|
||||
} on FlutterError {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -105,7 +111,9 @@ class MattermostAuthService {
|
|||
headers: {'Authorization': 'Bearer $_authToken'},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
print('[MattermostAuth] Failed to fetch config (${response.statusCode})');
|
||||
print(
|
||||
'[MattermostAuth] Failed to fetch config (${response.statusCode})',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final config = json.decode(response.body) as Map<String, dynamic>;
|
||||
|
|
@ -123,8 +131,7 @@ class MattermostAuthService {
|
|||
|
||||
/// PUT /api/v4/users/sessions/device → device_id 등록.
|
||||
Future<void> _registerDeviceToken(String deviceToken) async {
|
||||
final url =
|
||||
Uri.parse('$_serverUrl/api/v4/users/sessions/device');
|
||||
final url = Uri.parse('$_serverUrl/api/v4/users/sessions/device');
|
||||
final response = await http.put(
|
||||
url,
|
||||
headers: {
|
||||
|
|
@ -142,5 +149,4 @@ class MattermostAuthService {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}');
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,12 @@ import Foundation
|
|||
|
||||
import firebase_core
|
||||
import firebase_messaging
|
||||
import flutter_local_notifications
|
||||
import mattermost_push_plugin
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
MattermostPushPlugin.register(with: registry.registrar(forPlugin: "MattermostPushPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.59"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -65,14 +57,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -81,14 +65,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -114,7 +90,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.24.1"
|
||||
firebase_messaging:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging
|
||||
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
|
||||
|
|
@ -150,30 +126,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
|
@ -248,6 +200,13 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
mattermost_push_plugin:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../../mattermost-push-plugin"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -264,14 +223,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -333,14 +284,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -437,22 +380,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
dart: ">=3.11.3 <4.0.0"
|
||||
flutter: ">=3.38.0"
|
||||
|
|
|
|||
|
|
@ -38,13 +38,14 @@ dependencies:
|
|||
|
||||
# Firebase
|
||||
firebase_core: ^3.13.0
|
||||
firebase_messaging: ^15.2.5
|
||||
|
||||
# Mattermost push plugin
|
||||
mattermost_push_plugin:
|
||||
path: ../../../mattermost-push-plugin
|
||||
|
||||
# HTTP client
|
||||
http: ^1.3.0
|
||||
|
||||
# Local notifications (foreground display)
|
||||
flutter_local_notifications: ^18.0.1
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
|
|
@ -1,432 +1,34 @@
|
|||
# Push Notification Migration TODO
|
||||
# Push Notification Integration Handoff & Migration Notes
|
||||
|
||||
## 배경
|
||||
> [!IMPORTANT]
|
||||
> **This document is preserved for historical reference only.**
|
||||
> As of the `mattermost-push-plugin-extraction` milestone, all native FCM services, Room DB structures, JJWT signature verification, inline reply/dismiss receivers, notification builders, and ACK request handling have been successfully extracted into the local Flutter plugin repository: `../mattermost-push-plugin`.
|
||||
>
|
||||
> **Do NOT edit native push files directly inside this host app repository (`apps/mobile/android`).** All native push logic and platform services now reside in and must be maintained within the plugin repository.
|
||||
|
||||
mattermost-mobile(React Native) 프로젝트의 안드로이드 푸시 알림 시스템을 이 Flutter 프로젝트(nomadcode-app)로 마이그레이션한 작업 기록 및 잔여 TODO.
|
||||
## Current Responsibilities in Host App (`apps/mobile`)
|
||||
|
||||
**패키지명:** `com.tokilabs.mattermost` (google-services.json과 일치)
|
||||
**원본 참조:** `/config/workspace/mattermost-mobile`
|
||||
The host application acts strictly as a consumer of the `mattermost_push_plugin` package. What remains in `apps/mobile` regarding push notifications are:
|
||||
|
||||
---
|
||||
1. **Firebase Manifest & Metadata**:
|
||||
- `google-services.json` inside `apps/mobile/android/app/`
|
||||
- Initializing Firebase inside `lib/main.dart`
|
||||
2. **Credential / Secret Plumbing**:
|
||||
- Storing session authorization tokens via `MattermostPushPlugin.instance.setAuthToken()` on login and clearing them on logout.
|
||||
- Registering Mattermost server public signing keys via `MattermostPushPlugin.instance.setSigningKey()`.
|
||||
3. **App Navigation Routings**:
|
||||
- Handling user taps on notifications by implementing `onNavigateToChannel` and `onNavigateToThread` routing callbacks on `MattermostPushPlugin.instance`.
|
||||
|
||||
## 전체 아키텍처 요약
|
||||
## Legacy Work Documentation (Reference)
|
||||
|
||||
```
|
||||
FCM 서버
|
||||
│
|
||||
▼
|
||||
MattermostFirebaseMessagingService.kt ← 백그라운드/포그라운드 FCM 수신
|
||||
│
|
||||
├─ JWT 서명 검증 (CustomPushNotificationHelper.java)
|
||||
├─ ReceiptDelivery.java → /api/v4/notifications/ack POST
|
||||
├─ PushNotificationDataHelper.kt → Room DB에서 채널/포스트/유저 조회
|
||||
├─ CustomPushNotificationHelper.java → 시스템 알림 표시
|
||||
│
|
||||
└─ EventChannel (com.tokilabs.mattermost/notifications)
|
||||
│
|
||||
▼
|
||||
push_notification_service.dart ← Flutter에서 이벤트 수신
|
||||
│
|
||||
├─ onNotification stream → UI에서 포그라운드 알림 표시
|
||||
└─ onNotificationOpened stream → 알림 탭 시 채널/스레드 이동
|
||||
│
|
||||
▼
|
||||
main.dart (onNavigateToChannel / onNavigateToThread 콜백)
|
||||
```
|
||||
All native Kotlin/Java files, Room DB configurations, and broadcast receivers documented below are now managed within the `mattermost_push_plugin` repository:
|
||||
|
||||
---
|
||||
- `MattermostFirebaseMessagingService.kt`
|
||||
- `NotificationReplyBroadcastReceiver.java`
|
||||
- `NotificationDismissService.java`
|
||||
- `ReceiptDelivery.java`
|
||||
- `PushNotificationDataHelper.kt`
|
||||
- `helpers/db/MattermostDatabase.kt` & `GlobalDatabase.kt`
|
||||
|
||||
## 완료된 작업 (건드리지 말 것)
|
||||
|
||||
### Android 네이티브 레이어 ✅
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `MattermostFirebaseMessagingService.kt` | FCM 수신, JWT 검증, Flutter EventSink로 전달 |
|
||||
| `helpers/CustomPushNotificationHelper.java` | 알림 채널 생성, 시스템 알림 빌드, 아바타 처리 |
|
||||
| `helpers/NotificationHelper.kt` | 알림 ID 관리, 그룹 알림 요약 |
|
||||
| `helpers/DatabaseHelper.kt` | GlobalDatabase + MattermostDatabase 접근 |
|
||||
| `helpers/Network.kt` | OkHttp 기반 HTTP 클라이언트 (토큰 캐시 포함) |
|
||||
| `helpers/db/GlobalDatabase.kt` | deviceToken, Servers 테이블 |
|
||||
| `helpers/db/MattermostDatabase.kt` | Config, User, System 테이블 (서버별) |
|
||||
| `helpers/db/GlobalEntities.kt` | GlobalEntity, ServerEntity, DAO 정의 |
|
||||
| `helpers/db/ServerEntities.kt` | ConfigEntity, UserEntity, SystemEntity, DAO 정의 |
|
||||
| `ReceiptDelivery.java` | `/api/v4/notifications/ack` ACK 전송 |
|
||||
| `NotificationReplyBroadcastReceiver.java` | 알림에서 인라인 답장 처리 |
|
||||
| `NotificationDismissService.java` | 알림 삭제 처리 |
|
||||
| `AppLifecycleTracker.kt` | 앱 포그라운드/백그라운드 상태 추적 |
|
||||
| `MainApplication.kt` | DB 초기화, 알림 채널 생성, 라이프사이클 추적 |
|
||||
| `MainActivity.kt` | EventChannel + MethodChannel 등록 |
|
||||
|
||||
### Flutter 레이어 ✅
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `lib/services/push_notification_service.dart` | 싱글톤, 채널 수신, 스트림 노출, MethodChannel 호출 |
|
||||
| `lib/services/push_notification_background.dart` | 백그라운드 FCM 핸들러 |
|
||||
| `lib/main.dart` | Firebase 초기화, PushNotificationService 초기화, 네비게이션 콜백 등록 |
|
||||
|
||||
### 리소스 ✅
|
||||
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` + `ic_launcher_background.png` + `ic_launcher_foreground.png`
|
||||
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` (Adaptive Icon)
|
||||
- `drawable-*/ic_notif_action_reply.png` (답장 버튼 아이콘)
|
||||
- `mipmap-*/ic_notification.png` (알림 상태바 아이콘)
|
||||
- `AndroidManifest.xml` — 권한, 서비스, 리시버 등록 완료
|
||||
|
||||
---
|
||||
|
||||
## 잔여 TODO
|
||||
|
||||
---
|
||||
|
||||
### [TODO-1] Android: PushNotificationDataHelper.kt — Room DAO 실제 쿼리 구현
|
||||
|
||||
**파일:** `android/app/src/main/java/com/tokilabs/mattermost/helpers/PushNotificationDataHelper.kt`
|
||||
|
||||
현재 다음 메서드들이 `Log.i(...) return null` stub 상태:
|
||||
|
||||
```kotlin
|
||||
private fun fetchTeamIfNeeded(db: MattermostDatabase, serverUrl: String, teamId: String): Bundle?
|
||||
private fun fetchMyChannel(db: MattermostDatabase, channelId: String): Bundle?
|
||||
private fun fetchPosts(db: MattermostDatabase, serverUrl: String, channelId: String, isCRTEnabled: Boolean, rootId: String?): Bundle?
|
||||
private fun fetchThread(db: MattermostDatabase, serverUrl: String, rootId: String): Bundle?
|
||||
private fun fetchNeededUsers(serverUrl: String, channelId: String): List<Bundle>?
|
||||
private fun saveToDatabase(db: MattermostDatabase, data: Bundle, teamId: String?, channelId: String?, isCRTEnabled: Boolean)
|
||||
```
|
||||
|
||||
**구현 방법:**
|
||||
|
||||
1. `ServerEntities.kt`에 아래 Entity + DAO 추가:
|
||||
|
||||
```kotlin
|
||||
// Channel 테이블
|
||||
@Entity(tableName = "Channel")
|
||||
data class ChannelEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val display_name: String? = null,
|
||||
val type: String? = null, // O=public, P=private, D=DM, G=Group
|
||||
val team_id: String? = null,
|
||||
val delete_at: Long = 0
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface ChannelDao {
|
||||
@Query("SELECT * FROM Channel WHERE id = :id LIMIT 1")
|
||||
fun getChannel(id: String): ChannelEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsert(entity: ChannelEntity)
|
||||
}
|
||||
|
||||
// Post 테이블
|
||||
@Entity(tableName = "Post")
|
||||
data class PostEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val channel_id: String? = null,
|
||||
val root_id: String? = null,
|
||||
val user_id: String? = null,
|
||||
val message: String? = null,
|
||||
val create_at: Long = 0,
|
||||
val delete_at: Long = 0
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface PostDao {
|
||||
@Query("SELECT * FROM Post WHERE channel_id = :channelId ORDER BY create_at DESC LIMIT 20")
|
||||
fun getPostsForChannel(channelId: String): List<PostEntity>
|
||||
|
||||
@Query("SELECT * FROM Post WHERE root_id = :rootId ORDER BY create_at ASC")
|
||||
fun getThreadPosts(rootId: String): List<PostEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsert(entity: PostEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsertAll(entities: List<PostEntity>)
|
||||
}
|
||||
|
||||
// Team 테이블
|
||||
@Entity(tableName = "Team")
|
||||
data class TeamEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val display_name: String? = null,
|
||||
val name: String? = null
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface TeamDao {
|
||||
@Query("SELECT * FROM Team WHERE id = :id LIMIT 1")
|
||||
fun getTeam(id: String): TeamEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun upsert(entity: TeamEntity)
|
||||
}
|
||||
```
|
||||
|
||||
2. `MattermostDatabase.kt`에 새 DAO 등록:
|
||||
|
||||
```kotlin
|
||||
@Database(
|
||||
entities = [ConfigEntity::class, UserEntity::class, SystemEntity::class,
|
||||
ChannelEntity::class, PostEntity::class, TeamEntity::class],
|
||||
version = 2, // 버전 올리기
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class MattermostDatabase : RoomDatabase() {
|
||||
abstract fun configDao(): ConfigDao
|
||||
abstract fun userDao(): UserDao
|
||||
abstract fun systemDao(): SystemDao
|
||||
abstract fun channelDao(): ChannelDao
|
||||
abstract fun postDao(): PostDao
|
||||
abstract fun teamDao(): TeamDao
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
3. `PushNotificationDataHelper.kt` stub 메서드 구현:
|
||||
|
||||
```kotlin
|
||||
private fun fetchMyChannel(db: MattermostDatabase, channelId: String): Bundle? {
|
||||
val channel = db.channelDao().getChannel(channelId) ?: return null
|
||||
return Bundle().apply {
|
||||
putString("id", channel.id)
|
||||
putString("display_name", channel.display_name)
|
||||
putString("type", channel.type)
|
||||
putString("team_id", channel.team_id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchPosts(db: MattermostDatabase, serverUrl: String, channelId: String,
|
||||
isCRTEnabled: Boolean, rootId: String?): Bundle? {
|
||||
val posts = if (isCRTEnabled && rootId != null)
|
||||
db.postDao().getThreadPosts(rootId)
|
||||
else
|
||||
db.postDao().getPostsForChannel(channelId)
|
||||
if (posts.isEmpty()) return null
|
||||
return Bundle().apply {
|
||||
posts.forEachIndexed { i, post ->
|
||||
putBundle("post_$i", Bundle().apply {
|
||||
putString("id", post.id)
|
||||
putString("message", post.message)
|
||||
putString("user_id", post.user_id)
|
||||
putLong("create_at", post.create_at)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchTeamIfNeeded(db: MattermostDatabase, serverUrl: String, teamId: String): Bundle? {
|
||||
val team = db.teamDao().getTeam(teamId) ?: return null
|
||||
return Bundle().apply {
|
||||
putString("id", team.id)
|
||||
putString("display_name", team.display_name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchThread(db: MattermostDatabase, serverUrl: String, rootId: String): Bundle? {
|
||||
val posts = db.postDao().getThreadPosts(rootId)
|
||||
if (posts.isEmpty()) return null
|
||||
return Bundle().apply { putInt("reply_count", posts.size) }
|
||||
}
|
||||
|
||||
private fun fetchNeededUsers(serverUrl: String, channelId: String): List<Bundle>? {
|
||||
// Note: 현재 DB에 channel_members 테이블 없음 → 추후 구현
|
||||
return null
|
||||
}
|
||||
|
||||
private fun saveToDatabase(db: MattermostDatabase, data: Bundle, teamId: String?,
|
||||
channelId: String?, isCRTEnabled: Boolean) {
|
||||
// Flutter 부팅 전 수신된 알림 데이터 저장
|
||||
// System 테이블에 "pendingNotification_{channelId}" 키로 JSON 저장 권장
|
||||
channelId ?: return
|
||||
val json = org.json.JSONObject().apply {
|
||||
data.keySet()?.forEach { key -> put(key, data.getString(key)) }
|
||||
}
|
||||
db.systemDao().upsert(SystemEntity("pendingNotification_$channelId", json.toString()))
|
||||
}
|
||||
```
|
||||
|
||||
**주의:** `db.close()` 호출이 `PushNotificationDataHelper.kt` 99번째 줄 finally 블록에 있음.
|
||||
Room은 싱글톤이므로 `close()` 호출 시 이후 쿼리가 실패함. **해당 줄 제거 필요.**
|
||||
|
||||
```kotlin
|
||||
// 제거 대상 (PushNotificationDataHelper.kt:99)
|
||||
db?.close() // ← 삭제
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [TODO-2] Flutter: 앱 라우터 및 화면 구현
|
||||
|
||||
**파일:** `lib/main.dart`
|
||||
|
||||
현재 상태:
|
||||
```dart
|
||||
pushService.onNavigateToChannel = (serverUrl, channelId) {
|
||||
// TODO: 채널 화면으로 이동
|
||||
print('[Nav] Navigate to channel: $channelId on $serverUrl');
|
||||
};
|
||||
pushService.onNavigateToThread = (serverUrl, rootId) {
|
||||
// TODO: 스레드 화면으로 이동
|
||||
print('[Nav] Navigate to thread: $rootId on $serverUrl');
|
||||
};
|
||||
```
|
||||
|
||||
**구현 방향:**
|
||||
|
||||
1. go_router 또는 Navigator 2.0 기반 라우터 설정
|
||||
2. 최소 필요 화면:
|
||||
- `LoginScreen` — 서버 URL 입력 + 로그인 → `pushService.setAuthToken()` 호출
|
||||
- `ChannelScreen` — 채널 메시지 목록
|
||||
- `ThreadScreen` — 스레드 메시지 목록
|
||||
|
||||
3. `main.dart` 콜백 연결 예시:
|
||||
```dart
|
||||
pushService.onNavigateToChannel = (serverUrl, channelId) {
|
||||
_navigatorKey.currentState?.pushNamed(
|
||||
'/channel',
|
||||
arguments: {'serverUrl': serverUrl, 'channelId': channelId},
|
||||
);
|
||||
};
|
||||
pushService.onNavigateToThread = (serverUrl, rootId) {
|
||||
_navigatorKey.currentState?.pushNamed(
|
||||
'/thread',
|
||||
arguments: {'serverUrl': serverUrl, 'rootId': rootId},
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [TODO-3] Flutter: 로그인 화면 및 setAuthToken 연결
|
||||
|
||||
알림 답장(NotificationReplyBroadcastReceiver)과 ACK(ReceiptDelivery)가 서버 인증 토큰을 필요로 함.
|
||||
Flutter 로그인 성공 시 반드시 아래 호출 필요:
|
||||
|
||||
```dart
|
||||
// 로그인 성공 후
|
||||
await PushNotificationService().setAuthToken(serverUrl, bearerToken);
|
||||
|
||||
// 로그아웃 시
|
||||
await PushNotificationService().clearAuthToken(serverUrl);
|
||||
```
|
||||
|
||||
이 호출이 없으면 인라인 답장 API와 ACK API 요청이 401로 실패함.
|
||||
|
||||
---
|
||||
|
||||
### [TODO-4] Flutter: 포그라운드 알림 UI
|
||||
|
||||
앱이 포그라운드 상태일 때 FCM 메시지는 시스템 알림 대신 인앱 UI로 표시해야 함.
|
||||
`push_notification_service.dart`의 `onNotification` 스트림을 구독해서 구현:
|
||||
|
||||
```dart
|
||||
// 예시: 메인 화면 또는 글로벌 오버레이에서
|
||||
PushNotificationService().onNotification.listen((data) {
|
||||
final type = data['type'];
|
||||
if (type == 'message') {
|
||||
// 상단 배너 또는 SnackBar 표시
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(data['message'] ?? '새 메시지')),
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [TODO-5] Flutter: firebase_options.dart 생성 (선택 사항)
|
||||
|
||||
현재 `main.dart`에서 `Firebase.initializeApp()`을 options 없이 호출 중 (google-services.json 사용).
|
||||
FlutterFire CLI를 사용하면 더 명시적인 초기화 가능:
|
||||
|
||||
```bash
|
||||
# 프로젝트 루트에서
|
||||
dart pub global activate flutterfire_cli
|
||||
flutterfire configure --project=<firebase-project-id>
|
||||
```
|
||||
|
||||
실행 후 생성된 `lib/firebase_options.dart`를 사용:
|
||||
```dart
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
```
|
||||
|
||||
현재도 동작에는 문제 없음. 멀티플랫폼(iOS 추가 시) 필수.
|
||||
|
||||
---
|
||||
|
||||
## Flutter에서 사용 가능한 인터페이스 (push_notification_service.dart)
|
||||
|
||||
```dart
|
||||
final service = PushNotificationService();
|
||||
|
||||
// 초기화 (main에서 1회 호출)
|
||||
service.init();
|
||||
|
||||
// 인증 토큰 관리
|
||||
await service.setAuthToken(serverUrl, token, identifier: serverId);
|
||||
await service.clearAuthToken(serverUrl);
|
||||
|
||||
// FCM 디바이스 토큰 조회
|
||||
final token = await service.getDeviceToken();
|
||||
|
||||
// 포그라운드 알림 수신 스트림
|
||||
service.onNotification.listen((Map<String, dynamic> data) { ... });
|
||||
|
||||
// 알림 탭 이벤트 스트림
|
||||
service.onNotificationOpened.listen((NotificationOpenedEvent event) {
|
||||
// event.serverUrl, event.channelId, event.rootId, event.isCRTEnabled
|
||||
});
|
||||
|
||||
// 알림 탭 시 네비게이션 콜백 (main.dart builder에서 등록)
|
||||
service.onNavigateToChannel = (serverUrl, channelId) { ... };
|
||||
service.onNavigateToThread = (serverUrl, rootId) { ... };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MethodChannel 등록 목록 (MainActivity.kt)
|
||||
|
||||
| 메서드명 | 인자 | 설명 |
|
||||
|---------|------|------|
|
||||
| `saveDeviceToken` | `token: String` | FCM 토큰 → Room GlobalDB 저장 |
|
||||
| `getDeviceToken` | 없음 | Room GlobalDB에서 토큰 반환 |
|
||||
| `setAuthToken` | `serverUrl`, `token`, `identifier?` | Network 캐시 + Room Servers 저장 |
|
||||
| `clearAuthToken` | `serverUrl` | Network 캐시에서 토큰 제거 |
|
||||
|
||||
---
|
||||
|
||||
## EventChannel 이벤트 타입 (com.tokilabs.mattermost/notifications)
|
||||
|
||||
네이티브에서 Flutter로 전달되는 Map의 `type` 필드:
|
||||
|
||||
| type | 설명 | 주요 필드 |
|
||||
|------|------|---------|
|
||||
| `message` | 새 메시지 알림 | `server_url`, `channel_id`, `post_id`, `root_id`, `message`, `sender_name`, `is_crt_enabled` |
|
||||
| `clear` | 채널 읽음 처리 (알림 뱃지 초기화) | `server_url`, `channel_id` |
|
||||
| `session` | 세션 만료 | `server_url` |
|
||||
| `token_refresh` | FCM 토큰 갱신 | `token` |
|
||||
| `opened` | 알림 탭으로 앱 진입 | `server_url`, `channel_id`, `root_id`, `is_crt_enabled`, `userInteraction: true` |
|
||||
|
||||
---
|
||||
|
||||
## 우선순위 정리
|
||||
|
||||
| 우선순위 | 항목 | 영향 |
|
||||
|---------|------|------|
|
||||
| 🔴 필수 | TODO-2: 라우터/화면 | 알림 탭 후 이동 불가 |
|
||||
| 🔴 필수 | TODO-3: setAuthToken 연결 | 인라인 답장/ACK 인증 실패 |
|
||||
| 🟡 권장 | TODO-1: Room DAO 구현 | 알림 표시는 되나 채널명/메시지 미리보기 없음 |
|
||||
| 🟡 권장 | TODO-1의 db.close() 제거 | 간헐적 DB 접근 오류 |
|
||||
| 🟢 선택 | TODO-4: 포그라운드 알림 UI | 포그라운드에서 알림 표시 없음 |
|
||||
| 🟢 선택 | TODO-5: firebase_options.dart | iOS 추가 시 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 참고: 원본 파일 위치
|
||||
|
||||
| 역할 | 원본 (mattermost-mobile) |
|
||||
|------|--------------------------|
|
||||
| FCM 서비스 | `android/app/src/main/java/com/tokilabs/mattermost/CustomPushNotification.kt` |
|
||||
| 알림 헬퍼 | `android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java` |
|
||||
| 데이터 헬퍼 | `android/app/src/main/java/com/mattermost/helpers/PushNotificationDataHelper.kt` |
|
||||
| ACK 전송 | `android/app/src/main/java/com/tokilabs/mattermost/ReceiptDelivery.java` |
|
||||
| Flutter 서비스 | `app/utils/push_notifications.ts` |
|
||||
For local modifications, testing guidelines, and detailed API specs, please refer to the plugin's README:
|
||||
See `../mattermost-push-plugin/README.md` from the NomadCode repository root.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import 'package:nomadcode_app/screens/workspace_home_page.dart';
|
|||
import 'package:nomadcode_app/models/project_workspace.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App renders console dashboard title', (WidgetTester tester) async {
|
||||
testWidgets('App renders console dashboard title', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
expect(find.text('NomadCode Console'), findsOneWidget);
|
||||
|
|
@ -13,7 +15,9 @@ void main() {
|
|||
expect(find.text('IOP Bridge'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('WorkspaceHomePage triggers launcher callback on button tap', (WidgetTester tester) async {
|
||||
testWidgets('WorkspaceHomePage triggers launcher callback on button tap', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
ProjectWorkspace? launchedProject;
|
||||
|
||||
await tester.pumpWidget(
|
||||
|
|
|
|||