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