Merge pull request #10 from o0kuma/claude/project-planning-approach-ukdz31

Sync main: Phase 1 A–C (Flutter, APIs, beta-ready)
This commit is contained in:
cursor[bot] 2026-07-30 05:57:46 +00:00 committed by GitHub
commit 86f65d27ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 4143 additions and 226 deletions

View File

@ -1,3 +1,10 @@
# Copy to .env and fill in values. Never commit .env. # Copy to .env and fill in values. Never commit .env.
# Get a key from Google AI Studio: https://aistudio.google.com/apikey # Get a key from Google AI Studio: https://aistudio.google.com/apikey
GEMINI_API_KEY= GEMINI_API_KEY=
# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test)
ADMIN_API_TOKEN=
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).
# Without this, notifyUser soft-skips and records push_skipped metrics.
FCM_SERVER_KEY=

View File

@ -1,10 +1,10 @@
# Project Instructions — hikikomori / 분신 (가칭) # Project Instructions — hikikomori / 분신 (가칭)
This repository is currently a **planning-docs repo** for an AI-twin messenger This repository is an AI-twin messenger ("나를 대신해 남과 대화하는 AI 분신") with
("나를 대신해 남과 대화하는 AI 분신"). There is no application code yet. Phase 1 app code (`core-backend/`, `ai-service/`, `mobile/`) plus planning docs.
Canonical docs live under `docs/`. Prefer linking to them over copying content Canonical product decisions live under `docs/`. Prefer linking to them over
into prompts or new files. copying content into prompts or new files.
## Document authority ## Document authority
@ -18,9 +18,10 @@ When documents disagree, follow this order:
Notes: Notes:
- Q1~Q7 in `decision-log.md` are **제안 (tentative)**, not final meeting - Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent
decisions. Do not silently reverse them. If a change is required, update sub-questions (default autonomy level, whitelist defaults, final branding)
`decision-log.md` and all derived docs in the same change. stay open — do not invent those. To reverse a Q, update `decision-log.md` and
derived docs in the same change.
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for - Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
current working answers. current working answers.
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later** - Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**

View File

@ -4,10 +4,10 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md).
Quick context: Quick context:
- This repo started as planning docs only; Phase 1 app-build has now begun. - Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). Next is
Check `docs/roadmap.md` Phase 1 checklist before starting app-build work. **D — human PoC** (`docs/roadmap.md`). Do not start PoC early or invent §3 defaults.
- Working product name: **분신** (tentative). - Working product name: **분신** (가칭 확정).
- Source of working decisions: `docs/decision-log.md` (status: 제안). - Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open).
- v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first. - v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first.
- Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B. - Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B.
- Never weaken escalation, twin badge, peer veto, or undo. - Never weaken escalation, twin badge, peer veto, or undo.

View File

@ -15,9 +15,10 @@
## 문서 ## 문서
- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) - 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**)
- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md) - Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md)
- 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md) - 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md)
- 베타 직전(C): [`docs/invite-ops.md`](./docs/invite-ops.md) · [`docs/android-release.md`](./docs/android-release.md) · [`docs/prototype.md`](./docs/prototype.md)
- PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md) - PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md)
## AI 에이전트 규칙 ## AI 에이전트 규칙
@ -26,9 +27,9 @@
## 현재 단계 ## 현재 단계
- 기획 문서 + Phase 1 **서버(Go/Python)** + Flutter **클라이언트 골격**까지 진행됨 - Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로)
- Q1~Q7은 아직 **잠정(제안)** — 회의 확정 전 - 다음: **D — 사람 PoC #1/#3·Q3 인터뷰** (`docs/roadmap.md` §3, 맨 마지막)
- **사람 대상 PoC #1/#3·Q3 인터뷰는 맨 마지막 작업**으로 미룸 (`docs/roadmap.md` §3) - 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
## 로컬 실행 (요약) ## 로컬 실행 (요약)

View File

@ -9,6 +9,7 @@ import os
from pathlib import Path from pathlib import Path
from .escalation_filter import check as check_escalation from .escalation_filter import check as check_escalation
from .identity import check_identity_question
def load_dotenv_if_present(): def load_dotenv_if_present():
@ -39,6 +40,8 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화
- 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 문장만 출력한다: \ - 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 문장만 출력한다: \
[ESCALATE] 내용은 본인 확인이 필요합니다. [ESCALATE] 내용은 본인 확인이 필요합니다.
- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다. - 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.
- 상대가 "본인이야/분신이야?"처럼 정체를 물으면 분신임을 정직하게 밝힌다 \
(서버가 고정 문구로 먼저 처리하지만, 여기까지 경우에도 분신이라고 답한다).
( 지침은 2 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진 ( 지침은 2 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진
내용은 여기까지 오지 않는다. 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)""" 내용은 여기까지 오지 않는다. 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)"""
@ -65,10 +68,16 @@ def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
"ok": text is the generated draft. "ok": text is the generated draft.
""" """
gate = check_escalation(last_incoming_text(context_lines)) incoming = last_incoming_text(context_lines)
gate = check_escalation(incoming)
if gate.escalate: if gate.escalate:
return "escalate", gate.reason return "escalate", gate.reason
# Honest identity answers must be fixed copy, not LLM-invented (AGENTS.md).
identity = check_identity_question(incoming)
if identity.matched:
return "ok", identity.reply
api_key = api_key or os.environ.get("GEMINI_API_KEY") api_key = api_key or os.environ.get("GEMINI_API_KEY")
if not api_key: if not api_key:
return "no_key", build_user_prompt(style_examples, context_lines) return "no_key", build_user_prompt(style_examples, context_lines)

View File

@ -0,0 +1,40 @@
"""Fixed honest-identity answers (AGENTS.md / PRD §3.1).
When the peer asks whether they are talking to the human or the twin, the
twin must answer as a twin with a stable phrase not invent wording via LLM.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# Fixed product copy — tune only after PoC #3; do not invent alternate defaults.
IDENTITY_REPLY = (
"지금은 분신이 답하고 있어. 본인이랑 바로 이야기하고 싶으면 그렇게 말해줘."
)
# Incoming peer questions that should trigger the fixed identity reply.
_IDENTITY_QUESTION = re.compile(
r"("
r"본인\s*(이야|인가요|이니|임\??|맞아|맞음)"
r"|분신\s*(이야|인가요|이니|임\??|맞아|맞음)"
r"|지금\s*(본인|분신)"
r"|진짜\s*(야|임|인가요)"
r"|너\s*(사람|본인|분신)"
r")",
re.IGNORECASE,
)
@dataclass
class IdentityHit:
matched: bool
reply: str = ""
def check_identity_question(text: str) -> IdentityHit:
if not text or not text.strip():
return IdentityHit(False)
if _IDENTITY_QUESTION.search(text.strip()):
return IdentityHit(True, IDENTITY_REPLY)
return IdentityHit(False)

View File

@ -0,0 +1,29 @@
from app.identity import IDENTITY_REPLY, check_identity_question
from app.generation import draft_reply
def test_identity_phrases_match():
for q in [
"지금 본인이야 분신이야?",
"분신이야?",
"너 본인 맞아?",
"진짜야?",
]:
hit = check_identity_question(q)
assert hit.matched, q
assert hit.reply == IDENTITY_REPLY
def test_identity_non_match():
assert not check_identity_question("오늘 저녁 뭐 먹을래?").matched
def test_draft_returns_fixed_identity_without_llm(monkeypatch):
# Ensure we never need an API key for this path.
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
status, text = draft_reply(
["ㅇㅇ 알겠음"],
["상대: 지금 본인이야 분신이야?"],
)
assert status == "ok"
assert text == IDENTITY_REPLY

View File

@ -29,7 +29,22 @@ export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소
- 가입 `POST /auth/signup` / 로그인 `POST /auth/login``{token}` (Bearer) - 가입 `POST /auth/signup` / 로그인 `POST /auth/login``{token}` (Bearer)
- 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 Bearer 필요 - 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 Bearer 필요
- `/invites`, `/admin/metrics``Authorization: Bearer $ADMIN_API_TOKEN` - `/invites`, `/admin/metrics`, `/admin/dashboard`, `/admin/push-test`
`Authorization: Bearer $ADMIN_API_TOKEN` (`/admin/dashboard`는 `?token=` 쿼리 가능)
- 초대 운영: `POST /invites`(`note`, `expires_in_days`, `count`), `GET /invites`,
`POST /invites/:code/revoke` — 절차는 `docs/invite-ops.md`
메시지 생성(`POST /conversations/:id/messages`)은 전체 메시지 JSON을 반환한다
(`id`, `conversation_id`, `sender_id`, `sender_mode`, `text`, `retracted`, `created_at`).
Phase 1 B (베타 품질) 추가분:
- draft/escalate 지연·오류율 → `/admin/metrics``draft_*` / `escalate_*` 필드 (프로세스 메모리)
- `GET /admin/dashboard` — 최소 HTML 대시보드
- `POST|GET /users/:id/device-tokens` — FCM 토큰 등록(전송은 후속)
- `GET /users/:id/sessions` — 활성 세션 목록(멀티 디바이스 1차)
- `DELETE /users/:id/sessions/:sessionId` — 세션 종료
- `POST /admin/push-test` — FCM 스모크 테스트 (`FCM_SERVER_KEY` 필요, 없으면 skipped)
- 에스컬레이션 시 사용자 디바이스로 푸시 시도 (`notifyUser`)
## 테스트 ## 테스트

245
core-backend/b_routes.go Normal file
View File

@ -0,0 +1,245 @@
package main
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type registerDeviceRequest struct {
Token string `json:"token" binding:"required"`
Platform string `json:"platform"`
}
func messageJSON(m Message) gin.H {
return gin.H{
"id": m.ID,
"conversation_id": m.ConversationID,
"sender_id": m.SenderID,
"sender_mode": m.SenderMode,
"text": m.Text,
"retracted": m.Retracted,
"created_at": m.CreatedAt,
}
}
func registerBRoutes(r *gin.Engine, db *gorm.DB) {
// Minimal HTML dashboard for operators (roadmap B). JSON stays on /admin/metrics.
r.GET("/admin/dashboard", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, adminDashboardHTML)
})
r.POST("/users/:id/device-tokens", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !requireSelf(c, db, userID) {
return
}
var req registerDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
platform := req.Platform
if platform == "" {
platform = "android"
}
var existing DeviceToken
err := db.Where("token = ?", req.Token).First(&existing).Error
if err == nil {
existing.UserID = userID
existing.Platform = platform
existing.UpdatedAt = time.Now()
db.Save(&existing)
c.JSON(http.StatusOK, gin.H{"id": existing.ID, "token": existing.Token, "platform": existing.Platform})
return
}
row := DeviceToken{UserID: userID, Token: req.Token, Platform: platform}
if err := db.Create(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": row.ID, "token": row.Token, "platform": row.Platform})
})
r.GET("/users/:id/device-tokens", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !requireSelf(c, db, userID) {
return
}
var tokens []DeviceToken
db.Where("user_id = ?", userID).Order("id desc").Find(&tokens)
out := make([]gin.H, 0, len(tokens))
for _, t := range tokens {
out = append(out, gin.H{
"id": t.ID,
"platform": t.Platform,
"token_tail": trimToken(t.Token),
"updated_at": t.UpdatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"device_tokens": out})
})
// Multi-device awareness: list active sessions for the authenticated user.
r.GET("/users/:id/sessions", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
actor, ok := currentUser(c, db, true)
if !ok {
return
}
if actor.ID != userID {
c.JSON(http.StatusForbidden, gin.H{"detail": "can only list your own sessions"})
return
}
var sessions []Session
db.Where("user_id = ? AND expires_at > ?", userID, time.Now()).Order("id desc").Find(&sessions)
out := make([]gin.H, 0, len(sessions))
current := bearerToken(c)
for _, s := range sessions {
out = append(out, gin.H{
"id": s.ID,
"created_at": s.CreatedAt,
"expires_at": s.ExpiresAt,
"is_current": s.Token == current,
})
}
c.JSON(http.StatusOK, gin.H{"sessions": out})
})
// Revoke another (or current) session — multi-device control.
r.DELETE("/users/:id/sessions/:sessionId", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
sessionID, ok := parseUintParam(c, "sessionId")
if !ok {
return
}
actor, ok := currentUser(c, db, true)
if !ok {
return
}
if actor.ID != userID {
c.JSON(http.StatusForbidden, gin.H{"detail": "can only revoke your own sessions"})
return
}
var session Session
if err := db.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "session not found"})
return
}
db.Delete(&session)
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": sessionID})
})
// Admin smoke-test push to a user (requires FCM_SERVER_KEY for real delivery).
r.POST("/admin/push-test", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req struct {
UserID uint `json:"user_id" binding:"required"`
Title string `json:"title"`
Body string `json:"body"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if req.Title == "" {
req.Title = "분신 테스트"
}
if req.Body == "" {
req.Body = "push test"
}
sent, reason, err := notifyUser(db, req.UserID, req.Title, req.Body, map[string]string{"type": "test"})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"sent": sent, "skipped_reason": reason})
})
}
func trimToken(token string) string {
if len(token) <= 8 {
return "****"
}
return "…" + token[len(token)-6:]
}
const adminDashboardHTML = `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>분신 · admin metrics</title>
<style>
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #14231e; background: #f3f7f5; }
body { margin: 0; padding: 24px; }
h1 { margin: 0 0 8px; font-size: 1.4rem; }
p { color: #40554c; margin: 0 0 20px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
.card { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; }
.k { font-size: .75rem; color: #5b7267; text-transform: uppercase; letter-spacing: .04em; }
.v { font-size: 1.4rem; font-weight: 700; margin-top: 4px; }
pre { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; overflow: auto; }
button { margin: 12px 0; padding: 8px 14px; border-radius: 8px; border: 0; background: #1f6f5b; color: #fff; cursor: pointer; }
</style>
</head>
<body>
<h1>분신 운영 지표</h1>
<p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p>
<button onclick="load()">새로고침</button>
<div class="grid" id="cards"></div>
<h2 style="margin-top:28px;font-size:1rem">raw JSON</h2>
<pre id="raw">loading</pre>
<script>
async function load() {
const params = new URLSearchParams(location.search);
let token = params.get('token') || localStorage.ADMIN_API_TOKEN || '';
if (!token) {
token = prompt('ADMIN_API_TOKEN') || '';
if (token) localStorage.ADMIN_API_TOKEN = token;
} else if (params.get('token')) {
localStorage.ADMIN_API_TOKEN = token;
}
const r = await fetch('/admin/metrics', { headers: token ? { 'Authorization': 'Bearer ' + token } : {} });
const data = await r.json();
document.getElementById('raw').textContent = JSON.stringify(data, null, 2);
const cards = [
['users', data.users_total],
['human msgs', data.messages_human_total],
['twin msgs', data.messages_twin_total],
['escalations', data.escalations_total],
['peer veto rate', (data.peer_veto_rate || 0).toFixed(3)],
['draft req', data.draft_requests],
['draft err rate', (data.draft_error_rate || 0).toFixed(3)],
['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)],
['draft max ms', (data.draft_latency_max_ms || 0).toFixed(1)],
['escalate err rate', (data.escalate_error_rate || 0).toFixed(3)],
];
document.getElementById('cards').innerHTML = cards.map(([k,v]) =>
'<div class="card"><div class="k">'+k+'</div><div class="v">'+v+'</div></div>').join('');
}
load();
</script>
</body>
</html>
`

134
core-backend/b_test.go Normal file
View File

@ -0,0 +1,134 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"testing"
)
func TestMessageCreateReturnsFullPayload(t *testing.T) {
server, _ := setupTestServer(t)
userID, token := mustSignup(t, server.URL, "full-msg")
peerID, _ := mustSignup(t, server.URL, "full-msg-peer")
convResp := postJSONAuth(t, server.URL+"/conversations", token, createConversationRequest{
UserIDs: []uint{userID, peerID},
})
if convResp.StatusCode != http.StatusOK {
t.Fatalf("create conversation: %d", convResp.StatusCode)
}
var conv map[string]interface{}
json.NewDecoder(convResp.Body).Decode(&conv)
convID := uint(conv["id"].(float64))
msgResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
SenderID: userID,
Text: "hello full",
})
if msgResp.StatusCode != http.StatusOK {
t.Fatalf("send: %d", msgResp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(msgResp.Body).Decode(&out)
for _, key := range []string{"id", "conversation_id", "sender_id", "sender_mode", "text", "retracted"} {
if _, ok := out[key]; !ok {
t.Fatalf("missing key %s in %v", key, out)
}
}
if out["text"] != "hello full" || out["sender_mode"] != "human" {
t.Fatalf("unexpected payload %v", out)
}
}
func TestDeviceTokenAndSessions(t *testing.T) {
server, _ := setupTestServer(t)
userID, token := mustSignup(t, server.URL, "devices")
reg := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/device-tokens", token, registerDeviceRequest{
Token: "fcm-test-token-abc",
Platform: "android",
})
if reg.StatusCode != http.StatusOK {
t.Fatalf("device token: %d", reg.StatusCode)
}
req, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var sout map[string]interface{}
json.NewDecoder(resp.Body).Decode(&sout)
sessions := sout["sessions"].([]interface{})
if len(sessions) < 1 {
t.Fatalf("expected at least one session, got %v", sout)
}
first := sessions[0].(map[string]interface{})
if first["is_current"] != true {
t.Fatalf("expected current session flag, got %v", first)
}
}
func TestRevokeSessionAndPushTestWithoutFCM(t *testing.T) {
server, _ := setupTestServer(t)
userID, token := mustSignup(t, server.URL, "revoke")
// Second login via invite re-use is blocked; mint another session by signup of peer then
// just revoke the only session after listing.
req, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
var sout map[string]interface{}
json.NewDecoder(resp.Body).Decode(&sout)
resp.Body.Close()
sessions := sout["sessions"].([]interface{})
sid := uint(sessions[0].(map[string]interface{})["id"].(float64))
del := deleteJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions/"+strconv.FormatUint(uint64(sid), 10), token)
if del.StatusCode != http.StatusOK {
t.Fatalf("revoke: %d", del.StatusCode)
}
// Push test without FCM_SERVER_KEY should soft-skip.
push := postJSONAuth(t, server.URL+"/admin/push-test", "test-admin-token", map[string]interface{}{
"user_id": userID,
"title": "t",
"body": "b",
})
if push.StatusCode != http.StatusOK {
t.Fatalf("push-test: %d", push.StatusCode)
}
var pout map[string]interface{}
json.NewDecoder(push.Body).Decode(&pout)
// Without device tokens / FCM key: sent==0 and a skipped_reason is expected.
if pout["sent"].(float64) != 0 {
t.Fatalf("expected zero sent without FCM tokens, got %v", pout)
}
if pout["skipped_reason"] == nil || pout["skipped_reason"] == "" {
t.Fatalf("expected skipped_reason, got %v", pout)
}
}
func TestAdminMetricsIncludesDraftLatencyFields(t *testing.T) {
server, _ := setupTestServer(t)
req, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
req.Header.Set("Authorization", "Bearer test-admin-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
for _, key := range []string{"draft_requests", "draft_error_rate", "draft_latency_avg_ms", "escalate_error_rate"} {
if _, ok := out[key]; !ok {
t.Fatalf("missing metrics key %s in %v", key, out)
}
}
}

148
core-backend/invite_ops.go Normal file
View File

@ -0,0 +1,148 @@
package main
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type mintInvitesRequest struct {
Note string `json:"note"`
ExpiresInDays int `json:"expires_in_days"` // 0 → default 14; <0 → no expiry
Count int `json:"count"` // default 1, max 20
}
func inviteStatus(inv InviteCode, now time.Time) string {
if inv.RevokedAt != nil {
return "revoked"
}
if inv.UsedAt != nil {
return "used"
}
if inv.ExpiresAt != nil && !inv.ExpiresAt.After(now) {
return "expired"
}
return "unused"
}
func inviteUsable(inv InviteCode, now time.Time) (ok bool, detail string) {
switch inviteStatus(inv, now) {
case "unused":
return true, ""
case "used":
return false, "invite code already used"
case "revoked":
return false, "invite code revoked"
case "expired":
return false, "invite code expired"
default:
return false, "invite code not usable"
}
}
func inviteJSON(inv InviteCode, now time.Time) gin.H {
out := gin.H{
"code": inv.Code,
"note": inv.Note,
"status": inviteStatus(inv, now),
"created_at": inv.CreatedAt,
}
if inv.ExpiresAt != nil {
out["expires_at"] = inv.ExpiresAt
}
if inv.RevokedAt != nil {
out["revoked_at"] = inv.RevokedAt
}
if inv.UsedAt != nil {
out["used_at"] = inv.UsedAt
out["used_by_user_id"] = inv.UsedByUserID
}
return out
}
func registerInviteOpsRoutes(r *gin.Engine, db *gorm.DB) {
r.POST("/invites", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req mintInvitesRequest
_ = c.ShouldBindJSON(&req) // body optional for backward compat
count := req.Count
if count <= 0 {
count = 1
}
if count > 20 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "count must be <= 20"})
return
}
expiresIn := req.ExpiresInDays
if expiresIn == 0 {
expiresIn = 14
}
now := time.Now()
codes := make([]gin.H, 0, count)
for i := 0; i < count; i++ {
code, err := generateInviteCode()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
invite := InviteCode{Code: code, Note: req.Note}
if expiresIn > 0 {
exp := now.Add(time.Duration(expiresIn) * 24 * time.Hour)
invite.ExpiresAt = &exp
}
if err := db.Create(&invite).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
codes = append(codes, inviteJSON(invite, now))
}
if count == 1 {
c.JSON(http.StatusOK, codes[0])
return
}
c.JSON(http.StatusOK, gin.H{"invites": codes})
})
r.GET("/invites", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
var invites []InviteCode
db.Order("id desc").Limit(200).Find(&invites)
now := time.Now()
out := make([]gin.H, 0, len(invites))
for _, inv := range invites {
out = append(out, inviteJSON(inv, now))
}
c.JSON(http.StatusOK, gin.H{"invites": out})
})
r.POST("/invites/:code/revoke", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
code := c.Param("code")
var invite InviteCode
if err := db.Where("code = ?", code).First(&invite).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "invite not found"})
return
}
if invite.UsedAt != nil {
c.JSON(http.StatusConflict, gin.H{"detail": "cannot revoke a used invite"})
return
}
if invite.RevokedAt != nil {
c.JSON(http.StatusOK, inviteJSON(invite, time.Now()))
return
}
now := time.Now()
invite.RevokedAt = &now
db.Save(&invite)
c.JSON(http.StatusOK, inviteJSON(invite, now))
})
}

View File

@ -0,0 +1,80 @@
package main
import (
"encoding/json"
"net/http"
"testing"
"time"
)
func TestMintInviteWithNoteExpiryAndRevoke(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSONAuth(t, server.URL+"/invites", "test-admin-token", mintInvitesRequest{
Note: "e2e-friend",
ExpiresInDays: 7,
Count: 1,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("mint: %d", resp.StatusCode)
}
var one map[string]interface{}
json.NewDecoder(resp.Body).Decode(&one)
code, _ := one["code"].(string)
if code == "" || one["note"] != "e2e-friend" || one["status"] != "unused" {
t.Fatalf("unexpected mint payload %v", one)
}
if one["expires_at"] == nil {
t.Fatalf("expected expires_at, got %v", one)
}
batch := postJSONAuth(t, server.URL+"/invites", "test-admin-token", mintInvitesRequest{
Note: "batch",
Count: 3,
})
if batch.StatusCode != http.StatusOK {
t.Fatalf("batch: %d", batch.StatusCode)
}
var bout map[string]interface{}
json.NewDecoder(batch.Body).Decode(&bout)
if len(bout["invites"].([]interface{})) != 3 {
t.Fatalf("expected 3 invites, got %v", bout)
}
listReq, _ := http.NewRequest(http.MethodGet, server.URL+"/invites", nil)
listReq.Header.Set("Authorization", "Bearer test-admin-token")
listResp, err := http.DefaultClient.Do(listReq)
if err != nil {
t.Fatal(err)
}
if listResp.StatusCode != http.StatusOK {
t.Fatalf("list: %d", listResp.StatusCode)
}
rev := postJSONAuth(t, server.URL+"/invites/"+code+"/revoke", "test-admin-token", nil)
if rev.StatusCode != http.StatusOK {
t.Fatalf("revoke: %d", rev.StatusCode)
}
var revOut map[string]interface{}
json.NewDecoder(rev.Body).Decode(&revOut)
if revOut["status"] != "revoked" {
t.Fatalf("expected revoked, got %v", revOut)
}
signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "X"})
if signup.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for revoked invite, got %d", signup.StatusCode)
}
}
func TestExpiredInviteRejected(t *testing.T) {
server, db := setupTestServer(t)
code := mintInvite(t, server.URL)
past := time.Now().Add(-time.Hour)
db.Model(&InviteCode{}).Where("code = ?", code).Update("expires_at", past)
signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "Late"})
if signup.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for expired invite, got %d", signup.StatusCode)
}
}

View File

@ -57,23 +57,8 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
}) })
registerA1A2Routes(r, db) registerA1A2Routes(r, db)
registerBRoutes(r, db)
r.POST("/invites", func(c *gin.Context) { registerInviteOpsRoutes(r, db)
if !requireAdmin(c) {
return
}
code, err := generateInviteCode()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
invite := InviteCode{Code: code}
if err := db.Create(&invite).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": invite.Code})
})
r.GET("/admin/metrics", func(c *gin.Context) { r.GET("/admin/metrics", func(c *gin.Context) {
if !requireAdmin(c) { if !requireAdmin(c) {
@ -110,6 +95,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal) peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal)
} }
rt := runtimeMetrics.snapshot()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"users_total": usersTotal, "users_total": usersTotal,
"messages_human_total": humanMessages, "messages_human_total": humanMessages,
@ -123,6 +109,20 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
// vision.md doesn't pin down the exact denominator, so treat // vision.md doesn't pin down the exact denominator, so treat
// this as a first approximation, not the final definition. // this as a first approximation, not the final definition.
"peer_veto_rate": peerVetoRate, "peer_veto_rate": peerVetoRate,
// Process-local draft/AI timings (roadmap B). Reset on restart.
"draft_requests": rt.DraftRequests,
"draft_errors": rt.DraftErrors,
"draft_error_rate": rt.DraftErrorRate,
"draft_latency_avg_ms": rt.DraftLatencyAvgMs,
"draft_latency_max_ms": rt.DraftLatencyMaxMs,
"draft_latency_samples": rt.DraftLatencySamples,
"escalate_checks": rt.EscalateChecks,
"escalate_errors": rt.EscalateErrors,
"escalate_error_rate": rt.EscalateErrorRate,
"twin_sends_blocked": rt.TwinSendsBlocked,
"push_attempts": rt.PushAttempts,
"push_skipped": rt.PushSkipped,
"push_delivered": rt.PushDelivered,
"invites_minted": invitesMinted, "invites_minted": invitesMinted,
"invites_used": invitesUsed, "invites_used": invitesUsed,
}) })
@ -143,8 +143,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
return return
} }
if invite.UsedAt != nil { if ok, detail := inviteUsable(invite, time.Now()); !ok {
c.JSON(http.StatusConflict, gin.H{"detail": "invite code already used"}) status := http.StatusBadRequest
if detail == "invite code already used" {
status = http.StatusConflict
}
c.JSON(status, gin.H{"detail": detail})
return return
} }
@ -207,22 +211,32 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
// none of the later checks can override an earlier block. // none of the later checks can override an earlier block.
if req.SenderMode == SenderTwin { if req.SenderMode == SenderTwin {
if conversation.TwinDisabledByPeer { if conversation.TwinDisabledByPeer {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"})
return return
} }
result, err := ai.checkEscalation(req.Text) result, err := ai.checkEscalation(req.Text)
runtimeMetrics.recordEscalate(err)
if err != nil { if err != nil {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()})
return return
} }
if result.Escalate { if result.Escalate {
runtimeMetrics.recordTwinBlocked()
db.Create(&EscalationLog{ db.Create(&EscalationLog{
UserID: req.SenderID, UserID: req.SenderID,
ConversationID: convID, ConversationID: convID,
Reason: result.Reason, Reason: result.Reason,
MessageSnippet: req.Text, MessageSnippet: req.Text,
}) })
// Best-effort push (no-op without FCM_SERVER_KEY / real tokens).
_, _, _ = notifyUser(db, req.SenderID, "분신 확인 필요", result.Reason, map[string]string{
"type": "escalation",
"conversation_id": strconv.FormatUint(uint64(convID), 10),
"reason": result.Reason,
})
c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason}) c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason})
return return
} }
@ -237,19 +251,23 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
switch level { switch level {
case AutonomyL0: case AutonomyL0:
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"}) c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
return return
case AutonomyL1: case AutonomyL1:
if !req.Approved { if !req.Approved {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"})
return return
} }
case AutonomyL2: case AutonomyL2:
if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) { if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"})
return return
} }
default: default:
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"})
return return
} }
@ -274,7 +292,8 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
"text": message.Text, "text": message.Text,
}) })
c.JSON(http.StatusOK, gin.H{"id": message.ID}) // Return the full message so Flutter can render without waiting on WS.
c.JSON(http.StatusOK, messageJSON(message))
}) })
r.POST("/messages/:id/retract", func(c *gin.Context) { r.POST("/messages/:id/retract", func(c *gin.Context) {
@ -337,16 +356,14 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return return
} }
// EscalationLog persistence (sender's post-hoc notification + undo started := time.Now()
// trail) is a separate checklist item -- roadmap.md Phase 1 §2.2
// "사후 알림 + 되돌리기 로그 스키마/API". This endpoint only proxies
// to the AI service for now.
result, err := ai.requestDraft(draftRequest{ result, err := ai.requestDraft(draftRequest{
ContextLines: req.ContextLines, ContextLines: req.ContextLines,
StyleExamples: req.StyleExamples, StyleExamples: req.StyleExamples,
History: req.History, History: req.History,
K: req.K, K: req.K,
}) })
runtimeMetrics.recordDraft(time.Since(started), err)
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
return return

View File

@ -105,6 +105,19 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo
return resp return resp
} }
func deleteJSONAuth(t *testing.T, url, token string) *http.Response {
t.Helper()
req, _ := http.NewRequest(http.MethodDelete, url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("delete %s: %v", url, err)
}
return resp
}
func mintInvite(t *testing.T, serverURL string) string { func mintInvite(t *testing.T, serverURL string) string {
t.Helper() t.Helper()
resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil) resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil)

122
core-backend/metrics.go Normal file
View File

@ -0,0 +1,122 @@
package main
import (
"sync"
"time"
)
// RuntimeMetrics tracks draft-generation latency and AI error rates in process
// memory (roadmap B). Survives for the life of the process; not a substitute
// for a time-series DB, but enough for a minimal admin dashboard.
type RuntimeMetrics struct {
mu sync.Mutex
DraftRequests int64
DraftErrors int64
DraftLatencies []time.Duration // capped ring of recent samples
EscalateChecks int64
EscalateErrors int64
TwinSendsBlocked int64
PushAttempts int64
PushSkipped int64
PushDelivered int64
}
const maxLatencySamples = 200
var runtimeMetrics = &RuntimeMetrics{}
func (m *RuntimeMetrics) recordDraft(latency time.Duration, err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.DraftRequests++
if err != nil {
m.DraftErrors++
}
m.DraftLatencies = append(m.DraftLatencies, latency)
if len(m.DraftLatencies) > maxLatencySamples {
m.DraftLatencies = m.DraftLatencies[len(m.DraftLatencies)-maxLatencySamples:]
}
}
func (m *RuntimeMetrics) recordEscalate(err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.EscalateChecks++
if err != nil {
m.EscalateErrors++
}
}
func (m *RuntimeMetrics) recordTwinBlocked() {
m.mu.Lock()
defer m.mu.Unlock()
m.TwinSendsBlocked++
}
func (m *RuntimeMetrics) recordPush(delivered int, skipped bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.PushAttempts++
if skipped {
m.PushSkipped++
}
m.PushDelivered += int64(delivered)
}
func (m *RuntimeMetrics) snapshot() ginHMetrics {
m.mu.Lock()
defer m.mu.Unlock()
var sum time.Duration
var max time.Duration
for _, d := range m.DraftLatencies {
sum += d
if d > max {
max = d
}
}
var avgMs float64
if n := len(m.DraftLatencies); n > 0 {
avgMs = float64(sum.Milliseconds()) / float64(n)
}
var errRate float64
if m.DraftRequests > 0 {
errRate = float64(m.DraftErrors) / float64(m.DraftRequests)
}
var escErrRate float64
if m.EscalateChecks > 0 {
escErrRate = float64(m.EscalateErrors) / float64(m.EscalateChecks)
}
return ginHMetrics{
DraftRequests: m.DraftRequests,
DraftErrors: m.DraftErrors,
DraftErrorRate: errRate,
DraftLatencyAvgMs: avgMs,
DraftLatencyMaxMs: float64(max.Milliseconds()),
DraftLatencySamples: len(m.DraftLatencies),
EscalateChecks: m.EscalateChecks,
EscalateErrors: m.EscalateErrors,
EscalateErrorRate: escErrRate,
TwinSendsBlocked: m.TwinSendsBlocked,
PushAttempts: m.PushAttempts,
PushSkipped: m.PushSkipped,
PushDelivered: m.PushDelivered,
}
}
type ginHMetrics struct {
DraftRequests int64
DraftErrors int64
DraftErrorRate float64
DraftLatencyAvgMs float64
DraftLatencyMaxMs float64
DraftLatencySamples int
EscalateChecks int64
EscalateErrors int64
EscalateErrorRate float64
TwinSendsBlocked int64
PushAttempts int64
PushSkipped int64
PushDelivered int64
}

View File

@ -41,7 +41,10 @@ type Session struct {
type InviteCode struct { type InviteCode struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Code string `gorm:"uniqueIndex;not null"` Code string `gorm:"uniqueIndex;not null"`
Note string // operator memo (recipient/channel) — roadmap C invite ops
CreatedAt time.Time CreatedAt time.Time
ExpiresAt *time.Time
RevokedAt *time.Time
UsedAt *time.Time UsedAt *time.Time
UsedByUserID *uint UsedByUserID *uint
} }
@ -119,6 +122,17 @@ type EscalationLog struct {
CreatedAt time.Time CreatedAt time.Time
} }
// DeviceToken stores an FCM registration token for push (roadmap B).
// Sending pushes is wired later; v1 persists tokens per user/device.
type DeviceToken struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null;index"`
Token string `gorm:"uniqueIndex;not null"`
Platform string `gorm:"not null;default:android"`
CreatedAt time.Time
UpdatedAt time.Time
}
var allModels = []interface{}{ var allModels = []interface{}{
&User{}, &User{},
&Session{}, &Session{},
@ -130,4 +144,5 @@ var allModels = []interface{}{
&TwinSettings{}, &TwinSettings{},
&WhitelistRule{}, &WhitelistRule{},
&EscalationLog{}, &EscalationLog{},
&DeviceToken{},
} }

82
core-backend/push.go Normal file
View File

@ -0,0 +1,82 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"gorm.io/gorm"
)
// FCM legacy HTTP API (server key). When FCM_SERVER_KEY is unset, notifyUser
// records a metric and returns without error so product flows stay usable.
func fcmServerKey() string {
return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY"))
}
type fcmLegacyPayload struct {
To string `json:"to,omitempty"`
Registration []string `json:"registration_ids,omitempty"`
Priority string `json:"priority"`
Notification map[string]string `json:"notification"`
Data map[string]string `json:"data,omitempty"`
}
func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) {
var tokens []DeviceToken
db.Where("user_id = ?", userID).Find(&tokens)
if len(tokens) == 0 {
return 0, "no_device_tokens", nil
}
key := fcmServerKey()
if key == "" {
runtimeMetrics.recordPush(0, true)
return 0, "fcm_not_configured", nil
}
// Skip placeholder install:* tokens — they are not real FCM registration IDs.
regIDs := make([]string, 0, len(tokens))
for _, t := range tokens {
if strings.HasPrefix(t.Token, "install:") {
continue
}
regIDs = append(regIDs, t.Token)
}
if len(regIDs) == 0 {
runtimeMetrics.recordPush(0, true)
return 0, "only_placeholder_tokens", nil
}
payload := fcmLegacyPayload{
Registration: regIDs,
Priority: "high",
Notification: map[string]string{"title": title, "body": body},
Data: data,
}
raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw))
if err != nil {
return 0, "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "key="+key)
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Do(req)
if err != nil {
runtimeMetrics.recordPush(0, true)
return 0, "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
runtimeMetrics.recordPush(0, true)
return 0, "", fmt.Errorf("fcm returned %d", resp.StatusCode)
}
runtimeMetrics.recordPush(len(regIDs), false)
return len(regIDs), "", nil
}

View File

@ -143,8 +143,8 @@
검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md` 검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md`
"말투 검색기" 참고) "말투 검색기" 참고)
- [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정
4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). 4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`bunsin-prototype`).
PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능 PoC #3 역할극 자극재로 사용
- [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md` - [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md`
(질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장) (질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장)
- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) - [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영)

64
docs/android-release.md Normal file
View File

@ -0,0 +1,64 @@
# Android 릴리즈·내부 배포 경로 (Phase 1 C)
v1은 **Android만** 대상 (`tech-design.md` §8). Play 스토어 공개가 아니라
**지인 클로즈드 베타용 내부 APK/AAB** 경로를 먼저 고정한다.
## 산출물
| 산출물 | 명령 | 용도 |
|--------|------|------|
| 내부 APK | `./scripts/build_release_apk.sh` | 직접 설치·메신저 공유 |
| AAB | `./scripts/build_release_aab.sh` | Play 내부 테스트 트랙 (선택) |
## 서명 키 (Master 로컬 전용)
1. 키스토어는 **절대 git에 넣지 않는다** (`mobile/android/key.properties`, `*.jks`, `*.keystore`는 gitignore).
2. Master 머신에서 한 번 생성:
```bash
keytool -genkey -v -keystore ~/bunsin-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias bunsin
```
3. `mobile/android/key.properties.example`을 복사해 `mobile/android/key.properties` 작성:
```
storePassword=...
keyPassword=...
keyAlias=bunsin
storeFile=/absolute/path/to/bunsin-release.jks
```
4. `key.properties`가 없으면 release 빌드는 **디버그 서명으로 폴백**하며 경고를 낸다
(내부 스모크용). 베타 배포본은 반드시 release keystore로 서명한다.
## 빌드
```bash
cd mobile
flutter pub get
../scripts/build_release_apk.sh
# → build/app/outputs/flutter-apk/app-release.apk
../scripts/build_release_aab.sh # Play 내부 테스트용 (선택)
```
API 베이스 URL은 배포 대상 서버에 맞게:
```bash
flutter build apk --release \
--dart-define=CORE_API_BASE=https://api.example.com
```
## 배포 채널 (권장 순서)
1. **직접 APK** — 초대받은 지인에게만 파일 전달 (1차 클로즈드 베타)
2. **Play 내부 테스트** — 규모가 커지면 AAB + 테스터 이메일 목록
3. 공개 트랙 — Phase 게이트(`vision.md`) 통과 전 금지
## 체크리스트
- [ ] `key.properties` + jks가 Master 백업에만 존재
- [ ] `CORE_API_BASE`가 베타 서버를 가리킴
- [ ] `ADMIN_API_TOKEN` / `GEMINI_API_KEY` / `FCM_SERVER_KEY`가 서버에만 설정
- [ ] 초대 코드 운영 (`docs/invite-ops.md`) 숙지
- [ ] 실기기에서 `mobile/README.md` E2E UI 체크 1회

View File

@ -1,41 +1,42 @@
# 결정 로그 # 결정 로그
회의 자료(`idea-meeting-2026-06-29.html`)의 논점 Q1~Q7에 대한 결정을 추적한다. 회의 자료(`idea-meeting-2026-06-29.html`)의 논점 Q1~Q7에 대한 결정을 추적한다.
상태가 **제안**인 항목은 실제 회의에서 확정되지 않았고, 이 문서 이후의 Vision/PRD/기술설계서를
쓰기 위해 잠정적으로 내린 판단이다. 다음 회의에서 뒤집히면 이 로그와 그 근거로 만들어진 **상태 범례**
하위 문서를 함께 갱신한다. (이 "결정 로그" 자체가 회의 자료 §1의 "번복 추적 결정 로그" 아이디어를 - **제안**: 문서 작성용 잠정안 (회의 전)
그대로 실천하는 것이기도 하다.) - **확정**: Master가 Phase 1 C(베타 직전)에서 승인·고정한 값. 이후 번복 시 이 로그와
근거로 만든 Vision/PRD/기술설계를 함께 갱신한다.
| # | 질문 | 결정 | 근거 | 상태 | | # | 질문 | 결정 | 근거 | 상태 |
|---|------|------|------|------| |---|------|------|------|------|
| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 제안 | | Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 확정 |
| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 제안 | | Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 확정 |
| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용 | 제안 | | Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용. *기본 자율성 레벨(L1 vs L2 시작점)과 화이트리스트 기본 주제는 사람 PoC/실사용 후 확정* | 확정 |
| Q4 | 사칭 우려 대응 충분한가? | **1차로는 충분, 실사용 검증 필요** | 분신 뱃지·거부권·확정 불가 원칙은 설계상 합리적이나, 실제 상대방이 이를 신뢰하는지는 §4 기술 PoC #3(사용자 테스트)로 검증 전까지는 가설 | 제안 | | Q4 | 사칭 우려 대응 충분한가? | **1차 설계로 베타 진입, PoC #3로 검증** | 분신 뱃지·거부권·확정 불가·본인확인 고정 문구는 설계상 필수. 상대 신뢰 여부는 D(사람 PoC #3)에서 측정 | 확정 |
| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능해 안전하게 시작 가능 | 제안 | | Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능 | 확정 |
| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적이고 정체성을 바로 전달함. 최종 브랜딩/상표 조사는 MVP 검증 이후 진행 | 제안 (가칭) | | Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적. 최종 브랜딩/상표는 MVP·베타 반응 이후 | 확정 (가칭 유지) |
| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2 성장 전략** | 근거는 아래 "Q7 상세 근거" 참고 | 제안 | | Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2** | 아래 "Q7 상세 근거" | 확정 |
확정일: 2026-07-30 (Phase 1 C — Master 승인 하에 제안 → 확정).
## Q7 상세 근거 — 왜 자체 앱을 먼저 만드는가 ## Q7 상세 근거 — 왜 자체 앱을 먼저 만드는가
`PLANNING.md` §6에서는 채택 장벽이 낮다는 이유로 OS 레이어(읽기 전용 허브)를 먼저 검증하는 안을 `PLANNING.md` §6에서는 채택 장벽이 낮다는 이유로 OS 레이어(읽기 전용 허브)를 먼저 검증하는 안을
제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 두는 것이 더 낫다고 판단한다. 제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 다.
- 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은 - 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은
처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설(분신이 자연스럽게 느껴지는가, 처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설을 제3자 플랫폼 제약과 동시에
신뢰를 얻는가)을 제3자 플랫폼 제약과 동시에 검증하면 실패 원인을 구분할 수 없다. 검증하면 실패 원인을 구분할 수 없다.
- 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 - 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히
구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다. 구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다.
- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능 — - 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능.
버리는 작업이 아니다.
**따라서 순서:** ① 자체 앱 클로즈드 베타로 핵심 가설(분신의 자연스러움·신뢰) 검증 → ② 검증되면 **순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 →
OS 레이어 읽기 전용 허브(문자·이메일 우선, 안드로이드)로 채택 장벽을 낮춰 확산 → ③ 자체 앱은 ③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지.
L3·L4 등 완전한 기능의 최종 목적지로 유지.
## 아직 열려 있는 하위 질문 ## 아직 열려 있는 하위 질문 (PoC/실사용 의존 — D 이후)
- 분신 응답 임계점(어디까지 자동, 어디부터 사람에게) — Q3 확정 후에도 화이트리스트 주제 목록은 - 분신 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터)
실사용 데이터로 계속 조정 필요 - 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
- 상표/네이밍 최종안 (Q6은 가칭) - 상표/네이밍 최종안 (Q6은 가칭 확정)
- 베타 참가자 모집 규모와 방식 (§로드맵 참고) - 베타 참가자 모집 규모와 방식

57
docs/invite-ops.md Normal file
View File

@ -0,0 +1,57 @@
# 초대 코드 운영 절차 (Phase 1 C)
클로즈드 베타 가입은 **미리 발급된 1회용 초대 코드**로만 가능하다
(`POST /auth/signup` → `InviteCode` 검증).
## 발급자 권한
| 역할 | 권한 |
|------|------|
| **발급자 (Issuer)** | Master(또는 Master가 지정한 1명). `ADMIN_API_TOKEN`을 가진 주체만 발급·목록·회수 |
| 일반 사용자 | 초대 발급 불가. 받은 코드로 가입만 가능 |
| CI / 에이전트 | 프로덕션 `ADMIN_API_TOKEN`을 저장·공유하지 않음 |
토큰은 서버 환경변수 `ADMIN_API_TOKEN`으로만 주입한다. 레포·채팅·커밋에 넣지 않는다.
## 발급 규칙
1. **용도 메모 필수 권장**`note`에 수신자/채널(예: `지인-김ㅇㅇ`, `PoC#3-참가자3`)을 남긴다.
2. **만료** — 기본 14일. 장기 코드는 만들지 않는다 (`expires_in_days`).
3. **배치** — 한 번에 최대 20개 (`count`). 대량 발급 전 Master 확인.
4. **1코드 = 1가입**. 재사용 불가. 계정 삭제 후에도 코드는 “사용됨”으로 남는다.
5. **회수** — 전달 전 유출·오배포 시 `POST /invites/:code/revoke`. 이미 사용된 코드는 회수 불가.
## 운영 커맨드
```bash
export ADMIN_API_TOKEN=... # 로컬/서버에만
export CORE=http://127.0.0.1:8080
# 단일 발급 (14일, 메모)
curl -sS -X POST "$CORE/invites" \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"note":"지인-테스트","expires_in_days":14}'
# 배치 5개
curl -sS -X POST "$CORE/invites" \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"note":"베타-1차","expires_in_days":14,"count":5}'
# 목록
curl -sS "$CORE/invites" -H "Authorization: Bearer $ADMIN_API_TOKEN"
# 회수
curl -sS -X POST "$CORE/invites/<CODE>/revoke" \
-H "Authorization: Bearer $ADMIN_API_TOKEN"
```
사용 현황은 `GET /admin/metrics``invites_minted` / `invites_used`와 목록의
`status`(`unused`|`used`|`expired`|`revoked`)로 본다.
## 배포 전 체크
- [ ] 프로덕션 `ADMIN_API_TOKEN` 회전·길이 충분(≥32자 권장)
- [ ] 초대 코드를 공개 채널(SNS/이슈)에 올리지 않음
- [ ] 만료·회수된 코드로 가입이 거절되는지 스모크 테스트

View File

@ -16,7 +16,7 @@
| `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 | | `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 |
| `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** | | `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** |
| `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** | | `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** |
| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, PoC#3 자극재로 사용 가능 | | 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, 앵커 [`prototype.md`](./prototype.md) |
**아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행, **아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행,
그 결과의 Go/No-Go 판정. 문서·계획·자극재는 다 준비돼 있어 착수만 하면 된다. 그 결과의 Go/No-Go 판정. 문서·계획·자극재는 다 준비돼 있어 착수만 하면 된다.
@ -25,13 +25,13 @@
각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다. 각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다.
- [ ] **Q1** AI 분신 컨셉 확정? → 잠정: 예 - [x] **Q1** AI 분신 컨셉 확정? → 확정: 예 (`decision-log.md`)
- [ ] **Q2** 타깃 대중 우선? → 잠정: 예 (B2B는 이후) - [x] **Q2** 타깃 대중 우선? → 확정: 예 (B2B는 이후)
- [ ] **Q3** 자율성 L0~L2만 출시? → 잠정: 예 (단, `user-interview-guide.md` 실행 전이라 근거는 아직 가설) - [x] **Q3** 자율성 L0~L2만 출시? → 확정: 예 (기본 레벨·화이트리스트는 PoC/실사용 후)
- [ ] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 잠정: 1차 설계는 충분, 실사용 검증 필요 (PoC#3 미실행) - [x] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 확정: 1차 설계로 베타, PoC#3로 검증
- [ ] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 잠정: 예 - [x] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 확정: 예
- [ ] **Q6** 서비스 이름 "분신"(가칭)? → 잠정: 가칭, 정식 브랜딩 조사 전 - [x] **Q6** 서비스 이름 "분신"(가칭)? → 확정: 가칭 유지
- [ ] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 잠정: 예 - [x] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 확정: 예
## 회의에서 정할 것 (Q1~Q7 확정 외) ## 회의에서 정할 것 (Q1~Q7 확정 외)

View File

@ -34,6 +34,7 @@
`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미 `bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다. 다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`).
### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함) ### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함)

36
docs/prototype.md Normal file
View File

@ -0,0 +1,36 @@
# 클릭 프로토타입 (PoC #3 자극재) — 공유 링크 고정
Phase 1 C: PoC #3 역할극에서 쓰는 **클릭 프로토타입**의 공식 참조 위치다.
장면: 읽씹 종결 · 본인확인 · 거부권 · 에스컬레이션 (+ 자율성 설정).
## 공식 링크
| 항목 | 값 |
|------|-----|
| **식별자** | `bunsin-prototype` (`poc-materials.md` §2와 동일) |
| **공유 URL** | **TBD — Master 기입** (Claude Artifacts 공개 permalink 또는 정적 호스팅 URL) |
| **docs 앵커** | 이 파일. 다른 문서는 프로토타입을 말할 때 여기로 링크한다 |
URL을 받으면 아래 한 줄만 채운다 (추측으로 가짜 링크를 넣지 않는다):
```
SHARE_URL=
```
현재 값:
```
SHARE_URL=
```
## 사용처
- [`poc-plan.md`](./poc-plan.md) — PoC #3 자극재
- [`poc-materials.md`](./poc-materials.md) §2 — 역할극 시연 순서
- [`meeting-review-summary.md`](./meeting-review-summary.md) — 회의 산출물 표
## 갱신 규칙
1. 프로토타입 장면을 바꾸면 이 파일에 변경일·요약 한 줄을 추가한다.
2. `SHARE_URL`이 비어 있거나 404면 PoC #3 / 외부 공유 전에 반드시 채운다.
3. 호스팅을 옮겨도 식별자 `bunsin-prototype`과 이 앵커 문서는 유지한다.

View File

@ -58,26 +58,27 @@
생성은 이미 있는 `/draft` 흐름을 그대로 쓰면 되므로 새로 만들지 않았고, 화이트리스트는 생성은 이미 있는 `/draft` 흐름을 그대로 쓰면 되므로 새로 만들지 않았고, 화이트리스트는
`ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서 `ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서
(Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고) (Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고)
- [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 - [x] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 — drift+SQLCipher(`mobile/lib/db/`),
draft에 샘플만 전달, `DataFlowScreen`으로 원칙 노출
- [x] 사후 알림 + 되돌리기 로그 스키마/API — `escalation_logs`는 이미 쌓임(사후 알림용 로그). - [x] 사후 알림 + 되돌리기 로그 스키마/API — `escalation_logs`는 이미 쌓임(사후 알림용 로그).
되돌리기(one-tap undo, AGENTS.md 안전 불변식)는 `Message.Retracted` 필드 + 되돌리기(one-tap undo, AGENTS.md 안전 불변식)는 `Message.Retracted` 필드 +
`POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400, `POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400,
이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트. 이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트.
일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함. 일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함.
**되돌리기 UI는 `mobile/`에 있음** — 사후알림 전용 함/푸시는 아직. **되돌리기 UI는 `mobile/`에 있음**. 사후알림 함은 A3에서 `InboxScreen` +
에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가) `GET /users/:id/escalation-logs`로 연결. FCM 푸시는 B.
**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — `mobile/` **2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — `mobile/`
- [x] 기본 채팅 UI (대화 목록, 대화방) — `mobile/` 골격. 대화방 목록 API는 아직 없어 ID 직접 - [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드,
입장. WebSocket 수신·인간 메시지 전송 연결됨 WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 초대 코드 가입 화면만 있음. 말투 학습 UX 디테일은 - [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
**사람 PoC #1 결과를 맨 마지막에 반영** (지금은 뼈대만) 기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영**
- [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는 - [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
서버/AI 프롬프트 측과 이어서 다듬을 것 서버/AI 프롬프트 측과 이어서 다듬을 것
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외 매칭은 - [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외
서버가 전역 키워드만 지원하는 동안 UI도 주제 키워드 CRUD만 서버 contact-scoped 매칭과 함께 화이트리스트 CRUD
- [x] 에스컬레이션 배너·되돌리기 UI — 초안 escalate 배너 + twin 메시지 되돌리기 버튼. - [x] 에스컬레이션·L1 승인·되돌리기 UI — L1 수정/버리기/재초안/승인 패널 + twin 되돌리기 +
사후알림 전용 함/푸시는 아직 없음 사후알림 함. FCM 푸시는 B
**2.4 안전장치 통합** (전 구간 필수, 타협 불가) **2.4 안전장치 통합** (전 구간 필수, 타협 불가)
- [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회 - [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회
@ -97,7 +98,7 @@
무조건 차단되는 것까지 테스트로 확인함 무조건 차단되는 것까지 테스트로 확인함
- [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제 - [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제
플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스 플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스
암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter(`mobile/`) 쪽 후속 작업 암호화(drift+SQLCipher)와 데이터 흐름 UI는 Flutter(`mobile/`) B에서 반영
**2.5 QA/테스트** **2.5 QA/테스트**
- [x] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 - [x] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도
@ -114,8 +115,8 @@
- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라 - [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라
실제로는 초대 기반이 아니었음. `InviteCode` 테이블 + `POST /invites`(발급) 추가하고 실제로는 초대 기반이 아니었음. `InviteCode` 테이블 + `POST /invites`(발급) 추가하고
`/auth/signup`이 미리 발급된 미사용 코드인지 검증하도록 변경(모르는 코드 400, 이미 쓴 코드 `/auth/signup`이 미리 발급된 미사용 코드인지 검증하도록 변경(모르는 코드 400, 이미 쓴 코드
409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. **아직 없는 것**: 발급자 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은
인증(`/invites`를 지금은 누구나 호출 가능 — 세션/인증 도입 시 같이 잠글 것) `ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`)
- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은 - [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은
`/admin/metrics``peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움 `/admin/metrics``peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움
피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기 피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기
@ -147,14 +148,15 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음 2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션· 3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서 되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성 4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E.
설정). 남은 것: 대화방/연락처 목록 API 연동, 온보딩 말투 UX, drift+SQLCipher, 수동 QA 남은 것: 실제 Firebase FCM 토큰, Android UI 탭(`mobile/README.md`), C 배포 준비
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료. 5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 2.3 나머지와 함께 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
§3(사람 PoC) 이후** §3(사람 PoC) 이후**
- 5-2. [x] 화이트리스트 규칙 CRUD API - 5-2. [x] 화이트리스트 규칙 CRUD API
6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**) 6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로
7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**)
#### 5. 앞으로의 개발 계획 (우선순위 체크리스트) #### 5. 앞으로의 개발 계획 (우선순위 체크리스트)
@ -176,27 +178,37 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`) - [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후) **A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
- [ ] 대화 목록/연락처 UI를 서버 API에 연결 - [x] 대화 목록/연락처 UI를 서버 API에 연결
- [ ] 온보딩 뼈대 확장 (말투 샘플 입력 UI) - [x] 온보딩 뼈대 확장 (말투 샘플 입력 UI)
- [ ] L1 승인 플로우 UX 정리 - [x] L1 승인 플로우 UX 정리
- [ ] 사후 알림 함 - [x] 사후 알림 함
- [ ] 에뮬레이터/실기기 E2E 수동 QA - [x] E2E QA — `scripts/e2e_a3.py`로 A3 HTTP 플로우 16/16 통과(가입·연락처·대화·히스토리·
draft/L1·에스컬레이션·알림 로그·되돌리기·거부권·화이트리스트). `go test`/`pytest`/`flutter test`
동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지
##### B. 그다음 — 베타 품질 ##### B. 그다음 — 베타 품질
- [ ] FCM 푸시 연동 - [~] FCM 푸시 연동 — 토큰 등록 + 에스컬레이션 시 `notifyUser` + `POST /admin/push-test`.
- [ ] 멀티 디바이스 동기화 `FCM_SERVER_KEY`와 실제 FCM registration token이 있으면 전송, 없으면 soft-skip.
- [ ] drift + SQLCipher 로컬 저장 Flutter는 install-id 플레이스홀더 등록(Firebase Messaging 앱 키는 배포 환경에서 교체)
- [ ] 말투 이력 기기 내 저장 + 서버 최소 전송 - [~] 멀티 디바이스 동기화 — 세션 목록 + 세션 종료(`DELETE /users/:id/sessions/:id`).
- [ ] 데이터 흐름 표시 UI 메시지 히스토리 서버 동기화는 이미 REST/WS; 오프라인 큐는 후속
- [ ] 생성 지연시간·오류율 계측 - [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는
- [ ] 모니터링 대시보드(최소) `flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백
- [ ] 본인확인 응답 문구 고정/검증 - [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달
- [x] 데이터 흐름 표시 UI — `DataFlowScreen`
- [x] 생성 지연시간·오류율 계측 — process-local `RuntimeMetrics``/admin/metrics`
- [x] 모니터링 대시보드(최소) — `GET /admin/dashboard`
- [x] 본인확인 응답 문구 고정/검증 — `ai-service/app/identity.py` + draft 경로 테스트
##### C. 베타 직전 ##### C. 베타 직전
- [ ] Android 릴리즈 빌드·서명·배포 경로 - [x] Android 릴리즈 빌드·서명·배포 경로 — `docs/android-release.md`,
- [ ] 초대 코드 운영 절차(발급자 권한) `scripts/build_release_apk.sh` / `build_release_aab.sh`, `key.properties` 서명 훅.
- [ ] Q1~Q7 회의 확정 (제안 → 확정) keystore는 Master 로컬 전용(내부 APK 우선)
- [ ] 클릭 프로토타입 공유 링크 docs 고정 - [x] 초대 코드 운영 절차(발급자 권한) — `docs/invite-ops.md` + note/만료/배치/회수 API
(`GET|POST /invites`, `POST /invites/:code/revoke`)
- [x] Q1~Q7 회의 확정 (제안 → 확정) — `docs/decision-log.md` (2026-07-30). PoC 의존 하위
질문만 열어둠
- [x] 클릭 프로토타입 공유 링크 docs 고정 — `docs/prototype.md` 앵커. `SHARE_URL`은 Master 기입
##### D. 맨 마지막 — 사람 PoC (지금 안 함) ##### D. 맨 마지막 — 사람 PoC (지금 안 함)
- §3 항목과 동일. A~C 완료 후에만 착수. - §3 항목과 동일. A~C 완료 후에만 착수.

View File

@ -130,7 +130,7 @@ Python으로 남긴다.
| 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 | | 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 |
| 메시지 릴레이 | Go 코어 서비스 내 WebSocket | 자체 서버로 충분한 규모. Kafka·관리형 pub-sub은 지금 시점에 과한 인프라 | | 메시지 릴레이 | Go 코어 서비스 내 WebSocket | 자체 서버로 충분한 규모. Kafka·관리형 pub-sub은 지금 시점에 과한 인프라 |
| 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합. Go 쪽 접근은 `pgx`나 GORM | | 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합. Go 쪽 접근은 `pgx`나 GORM |
| 온디바이스 저장소 | Flutter `drift`(SQLite) + `sqlcipher_flutter_libs` 암호화 | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙을 그대로 구현 | | 온디바이스 저장소 | Flutter `drift` + `sqlcipher_flutter_libs` (`PRAGMA key`, 패스프레이즈는 `flutter_secure_storage`) | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙. 구현: `mobile/lib/db/` |
| Gemini API 키 관리 | Python AI 서비스에서만 보관 (Go 코어는 키를 안 가짐) | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 키·쿼터는 AI 서비스 환경에서만 분리 관리 | | Gemini API 키 관리 | Python AI 서비스에서만 보관 (Go 코어는 키를 안 가짐) | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 키·쿼터는 AI 서비스 환경에서만 분리 관리 |
이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에 이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에

View File

@ -1,16 +1,19 @@
# 분신 mobile (Flutter) # 분신 mobile (Flutter)
Phase 1 클라이언트 골격 (`docs/roadmap.md` §2.3). **v1은 Android 빌드만** 대상으로 한다 Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다
(`docs/tech-design.md` §8). (`docs/tech-design.md` §8).
## 지금 있는 것 ## 지금 있는 것
- 초대 코드 가입 (`POST /auth/signup`) - 초대 코드 가입 (`POST /auth/signup`) + Bearer 세션
- 대화방 입장(임시: 대화방 ID 직접 입력 — 목록 API는 아직 core-backend에 없음) - 말투 샘플 온보딩(기기 로컬 저장; 최종 카피는 PoC 이후)
- 채팅 전송 + WebSocket 수신 - 대화방 목록/생성 (`GET`/`POST /conversations`) — 수동 ID 입력 제거
- 분신 초안 요청 / L1 승인 발송 - 연락처 CRUD + 대화 시작 (`/users/:id/contacts`)
- 분신 뱃지(점선 + 라벨) · 거부권 · 되돌리기 - 채팅 히스토리 로드 + WebSocket 수신
- 자율성 L0~L2 설정 + 화이트리스트 CRUD UI - 분신 초안 / L1 승인(수정·버리기·재초안·승인 발송)
- 분신 뱃지 · 거부권 · 되돌리기
- 사후 알림 함 (`GET /users/:id/escalation-logs`)
- 자율성 L0~L2 + 화이트리스트 CRUD UI
## 실행 ## 실행
@ -26,10 +29,43 @@ flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
flutter run --dart-define=CORE_API_BASE=http://<your-lan-ip>:8080 flutter run --dart-define=CORE_API_BASE=http://<your-lan-ip>:8080
``` ```
## E2E
**API 레벨 (클라우드/CI에서 실행 가능)**
```bash
# core-backend :8080 + ai-service :8001 기동 후
export ADMIN_API_TOKEN=dev-admin-token
python3 scripts/e2e_a3.py
```
A3 체크리스트(가입·연락처·대화·히스토리·draft/L1·에스컬레이션·되돌리기·거부권·화이트리스트)를
HTTP로 검증한다.
**에뮬레이터/실기기 UI 탭 (Android SDK 필요)**
1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에)
2. 연락처에 상대 사용자 ID 등록 → 대화 시작
3. 메시지 전송 · 새로고침 후 히스토리 유지
4. 초안 요청 → L1 패널에서 수정/버리기/승인 발송 → 분신 뱃지 확인
5. 민감 문장으로 에스컬레이션 → 사후 알림 함 진입
6. 분신 메시지 되돌리기 · 거부권
7. 자율성 L0~L2 + 화이트리스트 추가/삭제
## 로컬 DB (drift + SQLCipher)
말투 샘플·온보딩 플래그는 `lib/db/` 암호화 SQLite에 저장한다. 패스프레이즈는
`flutter_secure_storage`. Linux 개발 호스트에 `libsqlcipher.so`가 없으면 메모리 폴백으로
기동한다(Android 릴리즈 경로에서는 SQLCipher 사용).
```bash
# Linux에서 drift 테스트/암호화 DB를 쓰려면
sudo apt-get install -y libsqlite3-dev libsqlcipher1
```
## 아직 없는 것 ## 아직 없는 것
- 대화방/연락처 목록 API 연동 (서버에 엔드포인트 추가 필요) - Firebase 프로젝트의 실제 FCM registration token 연동 (`google-services.json`)
- 온보딩 말투 학습 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) - 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
- drift + SQLCipher 온디바이스 저장 - 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
- 푸시 알림
- iOS 빌드 (v1 범위 밖) - iOS 빌드 (v1 범위 밖)

View File

@ -12,3 +12,5 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
*.jks
*.keystore

View File

@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@ -5,6 +8,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists()
if (hasReleaseKeystore) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android { android {
namespace = "com.bunsin.bunsin_mobile" namespace = "com.bunsin.bunsin_mobile"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@ -20,21 +30,33 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.bunsin.bunsin_mobile" applicationId = "com.bunsin.bunsin_mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // Prefer release keystore when key.properties exists; else debug
// Signing with the debug keys for now, so `flutter run --release` works. // (smoke only — see docs/android-release.md).
signingConfig = signingConfigs.getByName("debug") signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
} }
} }
} }

View File

@ -0,0 +1,4 @@
storePassword=replace-me
keyPassword=replace-me
keyAlias=bunsin
storeFile=/absolute/path/to/bunsin-release.jks

View File

@ -0,0 +1,128 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
import 'package:sqlite3/open.dart';
import 'tables.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [ToneSamples, LocalKv])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
/// In-memory DB for unit tests (no SQLCipher / filesystem).
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory());
/// Production opener: encrypted on-device file (tech-design.md §8).
static Future<AppDatabase> open() async {
final db = AppDatabase(_openEncryptedExecutor());
// Force open so missing SQLCipher SO fails here (caller can fall back).
await db.customSelect('SELECT 1').get();
return db;
}
Future<List<String>> loadToneSamples() async {
final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get();
return rows.map((r) => r.sampleText).toList();
}
Future<void> replaceToneSamples(List<String> samples) async {
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
await delete(toneSamples).go();
for (var i = 0; i < samples.length; i++) {
await into(toneSamples).insert(
ToneSamplesCompanion.insert(
id: 'tone_${i}_$now',
sampleText: samples[i],
createdAtMs: now,
sortOrder: i,
),
);
}
});
}
Future<String?> getKv(String key) async {
final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull();
return row?.value;
}
Future<void> setKv(String key, String value) async {
await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value));
}
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
final v = await getKv(key);
if (v == null) return defaultValue;
return v == '1' || v.toLowerCase() == 'true';
}
Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0');
}
}
const _kDbPassphrase = 'db_passphrase_v1';
QueryExecutor _openEncryptedExecutor() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'bunsin_encrypted.db'));
final passphrase = await _loadOrCreatePassphrase();
// Background isolate does not inherit open.overrideFor re-apply there.
final token = RootIsolateToken.instance;
return NativeDatabase.createInBackground(
file,
isolateSetup: () async {
if (token != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
}
await _configureSqlCipherOpen();
},
setup: (rawDb) {
// SQLCipher key must be set before any other statement.
final escaped = passphrase.replaceAll("'", "''");
rawDb.execute("PRAGMA key = '$escaped'");
rawDb.config.doubleQuotedStringLiterals = false;
},
);
});
}
Future<void> _configureSqlCipherOpen() async {
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
} else if (Platform.isLinux) {
// Desktop/dev: try SQLCipher SO; fall back is handled by open() failure upstream.
open.overrideFor(OperatingSystem.linux, () => DynamicLibrary.open('libsqlcipher.so'));
} else if (Platform.isWindows) {
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open('sqlcipher.dll'));
}
// iOS/macOS: sqlcipher_flutter_libs links into the process.
}
Future<String> _loadOrCreatePassphrase() async {
const storage = FlutterSecureStorage();
final existing = await storage.read(key: _kDbPassphrase);
if (existing != null && existing.isNotEmpty) return existing;
final rand = Random.secure();
final bytes = List<int>.generate(32, (_) => rand.nextInt(256));
final passphrase = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
await storage.write(key: _kDbPassphrase, value: passphrase);
return passphrase;
}

View File

@ -0,0 +1,873 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_database.dart';
// ignore_for_file: type=lint
class $ToneSamplesTable extends ToneSamples
with TableInfo<$ToneSamplesTable, ToneSample> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$ToneSamplesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _sampleTextMeta = const VerificationMeta(
'sampleText',
);
@override
late final GeneratedColumn<String> sampleText = GeneratedColumn<String>(
'sample_text',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _createdAtMsMeta = const VerificationMeta(
'createdAtMs',
);
@override
late final GeneratedColumn<int> createdAtMs = GeneratedColumn<int>(
'created_at_ms',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
static const VerificationMeta _sortOrderMeta = const VerificationMeta(
'sortOrder',
);
@override
late final GeneratedColumn<int> sortOrder = GeneratedColumn<int>(
'sort_order',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [
id,
sampleText,
createdAtMs,
sortOrder,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'tone_samples';
@override
VerificationContext validateIntegrity(
Insertable<ToneSample> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('sample_text')) {
context.handle(
_sampleTextMeta,
sampleText.isAcceptableOrUnknown(data['sample_text']!, _sampleTextMeta),
);
} else if (isInserting) {
context.missing(_sampleTextMeta);
}
if (data.containsKey('created_at_ms')) {
context.handle(
_createdAtMsMeta,
createdAtMs.isAcceptableOrUnknown(
data['created_at_ms']!,
_createdAtMsMeta,
),
);
} else if (isInserting) {
context.missing(_createdAtMsMeta);
}
if (data.containsKey('sort_order')) {
context.handle(
_sortOrderMeta,
sortOrder.isAcceptableOrUnknown(data['sort_order']!, _sortOrderMeta),
);
} else if (isInserting) {
context.missing(_sortOrderMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
ToneSample map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ToneSample(
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
sampleText: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}sample_text'],
)!,
createdAtMs: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}created_at_ms'],
)!,
sortOrder: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}sort_order'],
)!,
);
}
@override
$ToneSamplesTable createAlias(String alias) {
return $ToneSamplesTable(attachedDatabase, alias);
}
}
class ToneSample extends DataClass implements Insertable<ToneSample> {
final String id;
final String sampleText;
final int createdAtMs;
final int sortOrder;
const ToneSample({
required this.id,
required this.sampleText,
required this.createdAtMs,
required this.sortOrder,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['sample_text'] = Variable<String>(sampleText);
map['created_at_ms'] = Variable<int>(createdAtMs);
map['sort_order'] = Variable<int>(sortOrder);
return map;
}
ToneSamplesCompanion toCompanion(bool nullToAbsent) {
return ToneSamplesCompanion(
id: Value(id),
sampleText: Value(sampleText),
createdAtMs: Value(createdAtMs),
sortOrder: Value(sortOrder),
);
}
factory ToneSample.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ToneSample(
id: serializer.fromJson<String>(json['id']),
sampleText: serializer.fromJson<String>(json['sampleText']),
createdAtMs: serializer.fromJson<int>(json['createdAtMs']),
sortOrder: serializer.fromJson<int>(json['sortOrder']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'sampleText': serializer.toJson<String>(sampleText),
'createdAtMs': serializer.toJson<int>(createdAtMs),
'sortOrder': serializer.toJson<int>(sortOrder),
};
}
ToneSample copyWith({
String? id,
String? sampleText,
int? createdAtMs,
int? sortOrder,
}) => ToneSample(
id: id ?? this.id,
sampleText: sampleText ?? this.sampleText,
createdAtMs: createdAtMs ?? this.createdAtMs,
sortOrder: sortOrder ?? this.sortOrder,
);
ToneSample copyWithCompanion(ToneSamplesCompanion data) {
return ToneSample(
id: data.id.present ? data.id.value : this.id,
sampleText: data.sampleText.present
? data.sampleText.value
: this.sampleText,
createdAtMs: data.createdAtMs.present
? data.createdAtMs.value
: this.createdAtMs,
sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder,
);
}
@override
String toString() {
return (StringBuffer('ToneSample(')
..write('id: $id, ')
..write('sampleText: $sampleText, ')
..write('createdAtMs: $createdAtMs, ')
..write('sortOrder: $sortOrder')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, sampleText, createdAtMs, sortOrder);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ToneSample &&
other.id == this.id &&
other.sampleText == this.sampleText &&
other.createdAtMs == this.createdAtMs &&
other.sortOrder == this.sortOrder);
}
class ToneSamplesCompanion extends UpdateCompanion<ToneSample> {
final Value<String> id;
final Value<String> sampleText;
final Value<int> createdAtMs;
final Value<int> sortOrder;
final Value<int> rowid;
const ToneSamplesCompanion({
this.id = const Value.absent(),
this.sampleText = const Value.absent(),
this.createdAtMs = const Value.absent(),
this.sortOrder = const Value.absent(),
this.rowid = const Value.absent(),
});
ToneSamplesCompanion.insert({
required String id,
required String sampleText,
required int createdAtMs,
required int sortOrder,
this.rowid = const Value.absent(),
}) : id = Value(id),
sampleText = Value(sampleText),
createdAtMs = Value(createdAtMs),
sortOrder = Value(sortOrder);
static Insertable<ToneSample> custom({
Expression<String>? id,
Expression<String>? sampleText,
Expression<int>? createdAtMs,
Expression<int>? sortOrder,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (sampleText != null) 'sample_text': sampleText,
if (createdAtMs != null) 'created_at_ms': createdAtMs,
if (sortOrder != null) 'sort_order': sortOrder,
if (rowid != null) 'rowid': rowid,
});
}
ToneSamplesCompanion copyWith({
Value<String>? id,
Value<String>? sampleText,
Value<int>? createdAtMs,
Value<int>? sortOrder,
Value<int>? rowid,
}) {
return ToneSamplesCompanion(
id: id ?? this.id,
sampleText: sampleText ?? this.sampleText,
createdAtMs: createdAtMs ?? this.createdAtMs,
sortOrder: sortOrder ?? this.sortOrder,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (sampleText.present) {
map['sample_text'] = Variable<String>(sampleText.value);
}
if (createdAtMs.present) {
map['created_at_ms'] = Variable<int>(createdAtMs.value);
}
if (sortOrder.present) {
map['sort_order'] = Variable<int>(sortOrder.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ToneSamplesCompanion(')
..write('id: $id, ')
..write('sampleText: $sampleText, ')
..write('createdAtMs: $createdAtMs, ')
..write('sortOrder: $sortOrder, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class $LocalKvTable extends LocalKv with TableInfo<$LocalKvTable, LocalKvData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$LocalKvTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _keyMeta = const VerificationMeta('key');
@override
late final GeneratedColumn<String> key = GeneratedColumn<String>(
'key',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _valueMeta = const VerificationMeta('value');
@override
late final GeneratedColumn<String> value = GeneratedColumn<String>(
'value',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [key, value];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'local_kv';
@override
VerificationContext validateIntegrity(
Insertable<LocalKvData> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('key')) {
context.handle(
_keyMeta,
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
);
} else if (isInserting) {
context.missing(_keyMeta);
}
if (data.containsKey('value')) {
context.handle(
_valueMeta,
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
);
} else if (isInserting) {
context.missing(_valueMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {key};
@override
LocalKvData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return LocalKvData(
key: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}key'],
)!,
value: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}value'],
)!,
);
}
@override
$LocalKvTable createAlias(String alias) {
return $LocalKvTable(attachedDatabase, alias);
}
}
class LocalKvData extends DataClass implements Insertable<LocalKvData> {
final String key;
final String value;
const LocalKvData({required this.key, required this.value});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['key'] = Variable<String>(key);
map['value'] = Variable<String>(value);
return map;
}
LocalKvCompanion toCompanion(bool nullToAbsent) {
return LocalKvCompanion(key: Value(key), value: Value(value));
}
factory LocalKvData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return LocalKvData(
key: serializer.fromJson<String>(json['key']),
value: serializer.fromJson<String>(json['value']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'key': serializer.toJson<String>(key),
'value': serializer.toJson<String>(value),
};
}
LocalKvData copyWith({String? key, String? value}) =>
LocalKvData(key: key ?? this.key, value: value ?? this.value);
LocalKvData copyWithCompanion(LocalKvCompanion data) {
return LocalKvData(
key: data.key.present ? data.key.value : this.key,
value: data.value.present ? data.value.value : this.value,
);
}
@override
String toString() {
return (StringBuffer('LocalKvData(')
..write('key: $key, ')
..write('value: $value')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(key, value);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is LocalKvData &&
other.key == this.key &&
other.value == this.value);
}
class LocalKvCompanion extends UpdateCompanion<LocalKvData> {
final Value<String> key;
final Value<String> value;
final Value<int> rowid;
const LocalKvCompanion({
this.key = const Value.absent(),
this.value = const Value.absent(),
this.rowid = const Value.absent(),
});
LocalKvCompanion.insert({
required String key,
required String value,
this.rowid = const Value.absent(),
}) : key = Value(key),
value = Value(value);
static Insertable<LocalKvData> custom({
Expression<String>? key,
Expression<String>? value,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (key != null) 'key': key,
if (value != null) 'value': value,
if (rowid != null) 'rowid': rowid,
});
}
LocalKvCompanion copyWith({
Value<String>? key,
Value<String>? value,
Value<int>? rowid,
}) {
return LocalKvCompanion(
key: key ?? this.key,
value: value ?? this.value,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (key.present) {
map['key'] = Variable<String>(key.value);
}
if (value.present) {
map['value'] = Variable<String>(value.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('LocalKvCompanion(')
..write('key: $key, ')
..write('value: $value, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
late final $LocalKvTable localKv = $LocalKvTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [toneSamples, localKv];
}
typedef $$ToneSamplesTableCreateCompanionBuilder =
ToneSamplesCompanion Function({
required String id,
required String sampleText,
required int createdAtMs,
required int sortOrder,
Value<int> rowid,
});
typedef $$ToneSamplesTableUpdateCompanionBuilder =
ToneSamplesCompanion Function({
Value<String> id,
Value<String> sampleText,
Value<int> createdAtMs,
Value<int> sortOrder,
Value<int> rowid,
});
class $$ToneSamplesTableFilterComposer
extends Composer<_$AppDatabase, $ToneSamplesTable> {
$$ToneSamplesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get sampleText => $composableBuilder(
column: $table.sampleText,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get createdAtMs => $composableBuilder(
column: $table.createdAtMs,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get sortOrder => $composableBuilder(
column: $table.sortOrder,
builder: (column) => ColumnFilters(column),
);
}
class $$ToneSamplesTableOrderingComposer
extends Composer<_$AppDatabase, $ToneSamplesTable> {
$$ToneSamplesTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get sampleText => $composableBuilder(
column: $table.sampleText,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get createdAtMs => $composableBuilder(
column: $table.createdAtMs,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get sortOrder => $composableBuilder(
column: $table.sortOrder,
builder: (column) => ColumnOrderings(column),
);
}
class $$ToneSamplesTableAnnotationComposer
extends Composer<_$AppDatabase, $ToneSamplesTable> {
$$ToneSamplesTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get sampleText => $composableBuilder(
column: $table.sampleText,
builder: (column) => column,
);
GeneratedColumn<int> get createdAtMs => $composableBuilder(
column: $table.createdAtMs,
builder: (column) => column,
);
GeneratedColumn<int> get sortOrder =>
$composableBuilder(column: $table.sortOrder, builder: (column) => column);
}
class $$ToneSamplesTableTableManager
extends
RootTableManager<
_$AppDatabase,
$ToneSamplesTable,
ToneSample,
$$ToneSamplesTableFilterComposer,
$$ToneSamplesTableOrderingComposer,
$$ToneSamplesTableAnnotationComposer,
$$ToneSamplesTableCreateCompanionBuilder,
$$ToneSamplesTableUpdateCompanionBuilder,
(
ToneSample,
BaseReferences<_$AppDatabase, $ToneSamplesTable, ToneSample>,
),
ToneSample,
PrefetchHooks Function()
> {
$$ToneSamplesTableTableManager(_$AppDatabase db, $ToneSamplesTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$ToneSamplesTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$ToneSamplesTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$ToneSamplesTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> id = const Value.absent(),
Value<String> sampleText = const Value.absent(),
Value<int> createdAtMs = const Value.absent(),
Value<int> sortOrder = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => ToneSamplesCompanion(
id: id,
sampleText: sampleText,
createdAtMs: createdAtMs,
sortOrder: sortOrder,
rowid: rowid,
),
createCompanionCallback:
({
required String id,
required String sampleText,
required int createdAtMs,
required int sortOrder,
Value<int> rowid = const Value.absent(),
}) => ToneSamplesCompanion.insert(
id: id,
sampleText: sampleText,
createdAtMs: createdAtMs,
sortOrder: sortOrder,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$ToneSamplesTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$ToneSamplesTable,
ToneSample,
$$ToneSamplesTableFilterComposer,
$$ToneSamplesTableOrderingComposer,
$$ToneSamplesTableAnnotationComposer,
$$ToneSamplesTableCreateCompanionBuilder,
$$ToneSamplesTableUpdateCompanionBuilder,
(
ToneSample,
BaseReferences<_$AppDatabase, $ToneSamplesTable, ToneSample>,
),
ToneSample,
PrefetchHooks Function()
>;
typedef $$LocalKvTableCreateCompanionBuilder =
LocalKvCompanion Function({
required String key,
required String value,
Value<int> rowid,
});
typedef $$LocalKvTableUpdateCompanionBuilder =
LocalKvCompanion Function({
Value<String> key,
Value<String> value,
Value<int> rowid,
});
class $$LocalKvTableFilterComposer
extends Composer<_$AppDatabase, $LocalKvTable> {
$$LocalKvTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => ColumnFilters(column),
);
}
class $$LocalKvTableOrderingComposer
extends Composer<_$AppDatabase, $LocalKvTable> {
$$LocalKvTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => ColumnOrderings(column),
);
}
class $$LocalKvTableAnnotationComposer
extends Composer<_$AppDatabase, $LocalKvTable> {
$$LocalKvTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get key =>
$composableBuilder(column: $table.key, builder: (column) => column);
GeneratedColumn<String> get value =>
$composableBuilder(column: $table.value, builder: (column) => column);
}
class $$LocalKvTableTableManager
extends
RootTableManager<
_$AppDatabase,
$LocalKvTable,
LocalKvData,
$$LocalKvTableFilterComposer,
$$LocalKvTableOrderingComposer,
$$LocalKvTableAnnotationComposer,
$$LocalKvTableCreateCompanionBuilder,
$$LocalKvTableUpdateCompanionBuilder,
(
LocalKvData,
BaseReferences<_$AppDatabase, $LocalKvTable, LocalKvData>,
),
LocalKvData,
PrefetchHooks Function()
> {
$$LocalKvTableTableManager(_$AppDatabase db, $LocalKvTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$LocalKvTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$LocalKvTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$LocalKvTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> key = const Value.absent(),
Value<String> value = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => LocalKvCompanion(key: key, value: value, rowid: rowid),
createCompanionCallback:
({
required String key,
required String value,
Value<int> rowid = const Value.absent(),
}) =>
LocalKvCompanion.insert(key: key, value: value, rowid: rowid),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$LocalKvTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$LocalKvTable,
LocalKvData,
$$LocalKvTableFilterComposer,
$$LocalKvTableOrderingComposer,
$$LocalKvTableAnnotationComposer,
$$LocalKvTableCreateCompanionBuilder,
$$LocalKvTableUpdateCompanionBuilder,
(LocalKvData, BaseReferences<_$AppDatabase, $LocalKvTable, LocalKvData>),
LocalKvData,
PrefetchHooks Function()
>;
class $AppDatabaseManager {
final _$AppDatabase _db;
$AppDatabaseManager(this._db);
$$ToneSamplesTableTableManager get toneSamples =>
$$ToneSamplesTableTableManager(_db, _db.toneSamples);
$$LocalKvTableTableManager get localKv =>
$$LocalKvTableTableManager(_db, _db.localKv);
}

21
mobile/lib/db/tables.dart Normal file
View File

@ -0,0 +1,21 @@
import 'package:drift/drift.dart';
/// On-device tone / style samples (tech-design.md §2 never upload the full corpus).
class ToneSamples extends Table {
TextColumn get id => text()();
TextColumn get sampleText => text()();
IntColumn get createdAtMs => integer()();
IntColumn get sortOrder => integer()();
@override
Set<Column<Object>> get primaryKey => {id};
}
/// Small key/value bag for onboarding flags and local prefs that must stay encrypted.
class LocalKv extends Table {
TextColumn get key => text()();
TextColumn get value => text()();
@override
Set<Column<Object>> get primaryKey => {key};
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'screens/conversation_list_screen.dart'; import 'screens/conversation_list_screen.dart';
import 'screens/onboarding_tone_screen.dart';
import 'screens/signup_screen.dart'; import 'screens/signup_screen.dart';
import 'state/session_state.dart'; import 'state/session_state.dart';
@ -33,6 +34,7 @@ class BunsinApp extends StatelessWidget {
home: Consumer<SessionState>( home: Consumer<SessionState>(
builder: (context, s, _) { builder: (context, s, _) {
if (s.user == null) return const SignupScreen(); if (s.user == null) return const SignupScreen();
if (!s.toneOnboardingDone) return const OnboardingToneScreen();
return const ConversationListScreen(); return const ConversationListScreen();
}, },
), ),

View File

@ -34,6 +34,89 @@ class TwinSettings {
} }
} }
class ConversationSummary {
ConversationSummary({
required this.id,
required this.isGroup,
required this.userIds,
required this.twinDisabledByPeer,
this.createdAt,
});
final int id;
final bool isGroup;
final List<int> userIds;
final bool twinDisabledByPeer;
final DateTime? createdAt;
factory ConversationSummary.fromJson(Map<String, dynamic> json) {
final rawIds = json['user_ids'] as List<dynamic>? ?? const [];
return ConversationSummary(
id: json['id'] as int,
isGroup: json['is_group'] as bool? ?? false,
userIds: rawIds.map((e) => e as int).toList(),
twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''),
);
}
/// Title helper when peer display names are not yet loaded.
String titleFor(int myUserId) {
final peers = userIds.where((id) => id != myUserId).toList();
if (isGroup) return '그룹 #$id';
if (peers.isEmpty) return '나와의 대화 #$id';
return '대화 · 상대 #${peers.first}';
}
}
class Contact {
Contact({
required this.id,
required this.displayName,
this.contactUserId,
this.relationshipNote = '',
});
final int id;
final String displayName;
final int? contactUserId;
final String relationshipNote;
factory Contact.fromJson(Map<String, dynamic> json) => Contact(
id: json['id'] as int,
displayName: json['display_name'] as String? ?? '',
contactUserId: json['contact_user_id'] as int?,
relationshipNote: json['relationship_note'] as String? ?? '',
);
}
class EscalationLogEntry {
EscalationLogEntry({
required this.id,
required this.conversationId,
required this.reason,
required this.messageSnippet,
required this.resolved,
required this.createdAt,
});
final int id;
final int conversationId;
final String reason;
final String messageSnippet;
final bool resolved;
final DateTime createdAt;
factory EscalationLogEntry.fromJson(Map<String, dynamic> json) => EscalationLogEntry(
id: json['id'] as int,
conversationId: json['conversation_id'] as int? ?? 0,
reason: json['reason'] as String? ?? '',
messageSnippet: json['message_snippet'] as String? ?? '',
resolved: json['resolved'] as bool? ?? false,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
);
}
class ChatMessage { class ChatMessage {
ChatMessage({ ChatMessage({
required this.id, required this.id,

View File

@ -4,6 +4,8 @@ import 'package:provider/provider.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import 'data_flow_screen.dart';
import 'sessions_screen.dart';
class AutonomySettingsScreen extends StatefulWidget { class AutonomySettingsScreen extends StatefulWidget {
const AutonomySettingsScreen({super.key}); const AutonomySettingsScreen({super.key});
@ -56,6 +58,25 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.privacy_tip_outlined),
title: const Text('데이터 흐름'),
subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen()));
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.devices),
title: const Text('로그인 세션'),
subtitle: const Text('멀티 디바이스 세션 목록'),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen()));
},
),
const Divider(height: 32),
Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium), Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
SegmentedButton<AutonomyLevel>( SegmentedButton<AutonomyLevel>(

View File

@ -10,9 +10,10 @@ import '../state/session_state.dart';
import '../widgets/message_bubble.dart'; import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.conversationId}); const ChatScreen({super.key, required this.conversationId, this.title});
final int conversationId; final int conversationId;
final String? title;
@override @override
State<ChatScreen> createState() => _ChatScreenState(); State<ChatScreen> createState() => _ChatScreenState();
@ -20,18 +21,54 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
final _input = TextEditingController(); final _input = TextEditingController();
final _draftEdit = TextEditingController();
final _scroll = ScrollController();
final _messages = <ChatMessage>[]; final _messages = <ChatMessage>[];
ConversationSocket? _socket; ConversationSocket? _socket;
StreamSubscription? _sub; StreamSubscription? _sub;
String? _banner; String? _banner;
DraftResult? _pendingDraft; DraftResult? _pendingDraft;
bool _busy = false; bool _busy = false;
bool _loadingHistory = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_socket = ConversationSocket(widget.conversationId)..connect(); _socket = ConversationSocket(widget.conversationId)..connect();
_sub = _socket!.events.listen(_onEvent); _sub = _socket!.events.listen(_onEvent);
_loadHistory();
}
Future<void> _loadHistory() async {
final session = context.read<SessionState>();
try {
final history = await session.api.listMessages(widget.conversationId);
if (!mounted) return;
setState(() {
_messages
..clear()
..addAll(history);
_loadingHistory = false;
});
_scrollToEnd();
} on ApiException catch (e) {
if (!mounted) return;
setState(() {
_loadingHistory = false;
_banner = '히스토리 로드 실패 (${e.statusCode})';
});
}
}
void _scrollToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scroll.hasClients) return;
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
} }
void _onEvent(Map<String, dynamic> event) { void _onEvent(Map<String, dynamic> event) {
@ -58,6 +95,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (msg == null) return; if (msg == null) return;
if (_messages.any((m) => m.id == msg.id)) return; if (_messages.any((m) => m.id == msg.id)) return;
setState(() => _messages.add(msg)); setState(() => _messages.add(msg));
_scrollToEnd();
} }
@override @override
@ -65,6 +103,8 @@ class _ChatScreenState extends State<ChatScreen> {
_sub?.cancel(); _sub?.cancel();
_socket?.dispose(); _socket?.dispose();
_input.dispose(); _input.dispose();
_draftEdit.dispose();
_scroll.dispose();
super.dispose(); super.dispose();
} }
@ -83,6 +123,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (!_messages.any((m) => m.id == msg.id)) { if (!_messages.any((m) => m.id == msg.id)) {
setState(() => _messages.add(msg)); setState(() => _messages.add(msg));
} }
_scrollToEnd();
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _banner = '전송 실패 (${e.statusCode})'); setState(() => _banner = '전송 실패 (${e.statusCode})');
} finally { } finally {
@ -105,15 +146,19 @@ class _ChatScreenState extends State<ChatScreen> {
if (_input.text.trim().isNotEmpty) { if (_input.text.trim().isNotEmpty) {
contextLines.add('상대: ${_input.text.trim()}'); contextLines.add('상대: ${_input.text.trim()}');
} }
final style = session.styleExamples.isNotEmpty
? session.styleExamples
: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'];
final draft = await session.api.requestDraft( final draft = await session.api.requestDraft(
conversationId: widget.conversationId, conversationId: widget.conversationId,
contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines, contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines,
styleExamples: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'], styleExamples: style,
); );
setState(() { setState(() {
_pendingDraft = draft; _pendingDraft = draft;
_draftEdit.text = draft.isEscalate ? '' : draft.text;
if (draft.isEscalate) { if (draft.isEscalate) {
_banner = '에스컬레이션: ${draft.text}'; _banner = null;
} }
}); });
} on ApiException catch (e) { } on ApiException catch (e) {
@ -127,19 +172,23 @@ class _ChatScreenState extends State<ChatScreen> {
final session = context.read<SessionState>(); final session = context.read<SessionState>();
final draft = _pendingDraft; final draft = _pendingDraft;
if (draft == null || draft.isEscalate || session.user == null) return; if (draft == null || draft.isEscalate || session.user == null) return;
final text = _draftEdit.text.trim();
if (text.isEmpty) return;
setState(() => _busy = true); setState(() => _busy = true);
try { try {
final msg = await session.api.sendMessage( final msg = await session.api.sendMessage(
conversationId: widget.conversationId, conversationId: widget.conversationId,
senderId: session.user!.id, senderId: session.user!.id,
text: draft.text, text: text,
senderMode: SenderMode.twin, senderMode: SenderMode.twin,
approved: true, approved: true,
); );
setState(() { setState(() {
_pendingDraft = null; _pendingDraft = null;
_draftEdit.clear();
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
}); });
_scrollToEnd();
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}'); setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}');
} finally { } finally {
@ -147,6 +196,14 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} }
void _rejectDraft() {
setState(() {
_pendingDraft = null;
_draftEdit.clear();
_banner = '초안을 버렸습니다. 직접 쓰거나 다시 초안을 요청하세요.';
});
}
Future<void> _veto() async { Future<void> _veto() async {
final session = context.read<SessionState>(); final session = context.read<SessionState>();
try { try {
@ -166,6 +223,84 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} }
Widget _buildL1Panel(BuildContext context) {
final theme = Theme.of(context);
final draft = _pendingDraft;
if (draft == null) return const SizedBox.shrink();
if (draft.isEscalate) {
return Material(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('직접 확인 필요', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')),
),
],
),
),
);
}
return Material(
elevation: 2,
color: theme.colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer),
const SizedBox(width: 6),
Text(
'L1 승인 — 수정 후 보내기',
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _draftEdit,
minLines: 2,
maxLines: 5,
decoration: const InputDecoration(
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.white70,
),
),
const SizedBox(height: 8),
Row(
children: [
TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')),
const Spacer(),
FilledButton.tonal(
onPressed: _busy ? null : _requestDraft,
child: const Text('다시 초안'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _busy ? null : _sendTwinApproved,
child: const Text('승인하고 보내기'),
),
],
),
],
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = context.watch<SessionState>(); final session = context.watch<SessionState>();
@ -173,7 +308,7 @@ class _ChatScreenState extends State<ChatScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('대화방 #${widget.conversationId}'), title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
actions: [ actions: [
TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')), TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')),
], ],
@ -188,7 +323,10 @@ class _ChatScreenState extends State<ChatScreen> {
], ],
), ),
Expanded( Expanded(
child: ListView.builder( child: _loadingHistory
? const Center(child: CircularProgressIndicator())
: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _messages.length, itemCount: _messages.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
@ -201,18 +339,7 @@ class _ChatScreenState extends State<ChatScreen> {
}, },
), ),
), ),
if (_pendingDraft != null && !_pendingDraft!.isEscalate) _buildL1Panel(context),
Material(
color: Theme.of(context).colorScheme.secondaryContainer,
child: ListTile(
title: const Text('분신 초안 (L1 승인)'),
subtitle: Text(_pendingDraft!.text),
trailing: FilledButton(
onPressed: _busy ? null : _sendTwinApproved,
child: const Text('보내기'),
),
),
),
SafeArea( SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),

View File

@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../state/session_state.dart';
import 'chat_screen.dart';
class ContactsScreen extends StatefulWidget {
const ContactsScreen({super.key});
@override
State<ContactsScreen> createState() => _ContactsScreenState();
}
class _ContactsScreenState extends State<ContactsScreen> {
List<Contact> _contacts = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final session = context.read<SessionState>();
if (session.user == null) return;
setState(() {
_loading = true;
_error = null;
});
try {
final list = await session.api.listContacts(session.user!.id);
setState(() => _contacts = list);
} on ApiException catch (e) {
setState(() => _error = '연락처 로드 실패 (${e.statusCode})');
} finally {
setState(() => _loading = false);
}
}
Future<void> _showAddDialog() async {
final nameCtrl = TextEditingController();
final peerCtrl = TextEditingController();
final noteCtrl = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('연락처 추가'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: '표시 이름', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: peerCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '상대 사용자 ID (선택)',
helperText: '대화 시작에 필요',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: noteCtrl,
decoration: const InputDecoration(labelText: '관계 메모 (선택)', border: OutlineInputBorder()),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')),
],
),
);
if (ok != true || !mounted) return;
final session = context.read<SessionState>();
final name = nameCtrl.text.trim();
if (name.isEmpty || session.user == null) return;
try {
final peer = int.tryParse(peerCtrl.text.trim());
final created = await session.api.createContact(
userId: session.user!.id,
displayName: name,
contactUserId: peer,
relationshipNote: noteCtrl.text.trim(),
);
setState(() => _contacts = [..._contacts, created]);
} on ApiException catch (e) {
setState(() => _error = '추가 실패 (${e.statusCode})');
}
}
Future<void> _startChat(Contact contact) async {
final session = context.read<SessionState>();
final me = session.user;
if (me == null || contact.contactUserId == null) {
setState(() => _error = '상대 사용자 ID가 있는 연락처만 대화를 시작할 수 있습니다.');
return;
}
try {
final conv = await session.api.createConversation(
userIds: [me.id, contact.contactUserId!],
contactId: contact.id,
);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id, title: contact.displayName)),
);
} on ApiException catch (e) {
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('연락처')),
floatingActionButton: FloatingActionButton(
onPressed: _showAddDialog,
child: const Icon(Icons.person_add_alt_1),
),
body: RefreshIndicator(
onRefresh: _load,
child: _loading
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
: ListView(
children: [
if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
if (_contacts.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Text('연락처가 없습니다. + 버튼으로 추가하세요.'),
),
for (final c in _contacts)
ListTile(
leading: const CircleAvatar(child: Icon(Icons.person_outline)),
title: Text(c.displayName),
subtitle: Text(
[
if (c.contactUserId != null) '사용자 #${c.contactUserId}',
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
].join(' · '),
),
trailing: c.contactUserId == null
? null
: TextButton(onPressed: () => _startChat(c), child: const Text('대화')),
onLongPress: () async {
final session = context.read<SessionState>();
if (session.user == null) return;
await session.api.deleteContact(session.user!.id, c.id);
setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList());
},
),
const Padding(
padding: EdgeInsets.all(16),
child: Text('길게 누르면 삭제됩니다.', textAlign: TextAlign.center),
),
],
),
),
);
}
}

View File

@ -1,12 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import 'autonomy_settings_screen.dart'; import 'autonomy_settings_screen.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
import 'contacts_screen.dart';
import 'inbox_screen.dart';
import 'onboarding_tone_screen.dart';
/// v1 scaffold: conversation list is local until list API exists on core-backend.
/// Enter a conversation id to open a room (matches current Go API shape).
class ConversationListScreen extends StatefulWidget { class ConversationListScreen extends StatefulWidget {
const ConversationListScreen({super.key}); const ConversationListScreen({super.key});
@ -15,24 +18,103 @@ class ConversationListScreen extends StatefulWidget {
} }
class _ConversationListScreenState extends State<ConversationListScreen> { class _ConversationListScreenState extends State<ConversationListScreen> {
final _convId = TextEditingController(text: '1'); List<ConversationSummary> _rooms = [];
final _rooms = <int>{1}; bool _loading = true;
String? _error;
@override @override
void dispose() { void initState() {
_convId.dispose(); super.initState();
super.dispose(); _load();
}
Future<void> _load() async {
final session = context.read<SessionState>();
setState(() {
_loading = true;
_error = null;
});
try {
final list = await session.api.listConversations();
list.sort((a, b) => b.id.compareTo(a.id));
setState(() => _rooms = list);
} on ApiException catch (e) {
setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}');
} finally {
setState(() => _loading = false);
}
}
Future<void> _createConversation() async {
final peerCtrl = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('새 대화'),
content: TextField(
controller: peerCtrl,
keyboardType: TextInputType.number,
autofocus: true,
decoration: const InputDecoration(
labelText: '상대 사용자 ID',
helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')),
],
),
);
if (ok != true || !mounted) return;
final session = context.read<SessionState>();
final me = session.user;
final peer = int.tryParse(peerCtrl.text.trim());
if (me == null || peer == null) return;
try {
final conv = await session.api.createConversation(userIds: [me.id, peer]);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id)),
);
await _load();
} on ApiException catch (e) {
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = context.watch<SessionState>(); final session = context.watch<SessionState>();
final rooms = _rooms.toList()..sort(); final me = session.user?.id;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('분신 · ${session.user?.displayName ?? ''}'), title: Text('분신 · ${session.user?.displayName ?? ''}'),
actions: [ actions: [
IconButton(
tooltip: '사후 알림',
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const InboxScreen()));
},
icon: const Icon(Icons.notifications_none),
),
IconButton(
tooltip: '연락처',
onPressed: () async {
await Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ContactsScreen()));
await _load();
},
icon: const Icon(Icons.contacts_outlined),
),
IconButton(
tooltip: '말투 샘플',
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen()));
},
icon: const Icon(Icons.record_voice_over_outlined),
),
IconButton( IconButton(
tooltip: '자율성 설정', tooltip: '자율성 설정',
onPressed: () { onPressed: () {
@ -44,48 +126,53 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
), ),
], ],
), ),
body: ListView( floatingActionButton: FloatingActionButton(
onPressed: _createConversation,
child: const Icon(Icons.chat),
),
body: RefreshIndicator(
onRefresh: _load,
child: _loading
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
: ListView(
children: [ children: [
if (_error != null)
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
children: [
Expanded(
child: TextField(
controller: _convId,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '대화방 ID',
border: OutlineInputBorder(),
helperText: 'core-backend에 대화방 목록 API가 생기기 전 임시 진입',
), ),
if (_rooms.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'),
), ),
), for (final room in _rooms)
const SizedBox(width: 8),
FilledButton(
onPressed: () {
final id = int.tryParse(_convId.text.trim());
if (id == null) return;
setState(() => _rooms.add(id));
},
child: const Text('추가'),
),
],
),
),
for (final id in rooms)
ListTile( ListTile(
leading: const CircleAvatar(child: Icon(Icons.chat_bubble_outline)), leading: CircleAvatar(
title: Text('대화방 #$id'), child: Icon(room.isGroup ? Icons.groups_outlined : Icons.chat_bubble_outline),
subtitle: const Text('탭해서 입장'), ),
onTap: () { title: Text(me == null ? '대화방 #${room.id}' : room.titleFor(me)),
Navigator.of(context).push( subtitle: Text(
MaterialPageRoute(builder: (_) => ChatScreen(conversationId: id)), [
'ID ${room.id}',
if (room.twinDisabledByPeer) '상대가 분신 거부',
].join(' · '),
),
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: room.id,
title: me == null ? null : room.titleFor(me),
),
),
); );
await _load();
}, },
), ),
], ],
), ),
),
); );
} }
} }

View File

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/session_state.dart';
/// Shows what stays on-device vs what may leave the device (roadmap B / privacy).
class DataFlowScreen extends StatelessWidget {
const DataFlowScreen({super.key});
@override
Widget build(BuildContext context) {
final session = context.watch<SessionState>();
final theme = Theme.of(context);
final samples = session.styleExamples;
return Scaffold(
appBar: AppBar(title: const Text('데이터 흐름')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Text('기기 안에만 둡니다', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
Card(
child: ListTile(
title: Text('말투 샘플 ${samples.length}'),
subtitle: Text(
[
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)',
].join('\n'),
),
isThreeLine: true,
),
),
const SizedBox(height: 24),
Text('서버로 보낼 수 있는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트).\n'
'채팅 릴레이: 보낸 메시지 본문.\n'
'계정: 표시 이름·초대 코드·세션 토큰.\n'
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
Text('보내지 않는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
'관계 메모의 자동 클라우드 분석.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
);
}
}

View File

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../state/session_state.dart';
import 'chat_screen.dart';
/// Post-hoc notification inbox: escalation logs that need human attention.
/// Twin "되돌리기" remains available inside each chat bubble (PRD).
class InboxScreen extends StatefulWidget {
const InboxScreen({super.key});
@override
State<InboxScreen> createState() => _InboxScreenState();
}
class _InboxScreenState extends State<InboxScreen> {
List<EscalationLogEntry> _logs = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final session = context.read<SessionState>();
if (session.user == null) return;
setState(() {
_loading = true;
_error = null;
});
try {
final logs = await session.api.listEscalationLogs(session.user!.id);
setState(() => _logs = logs);
} on ApiException catch (e) {
setState(() => _error = '알림 로드 실패 (${e.statusCode})');
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('사후 알림')),
body: RefreshIndicator(
onRefresh: _load,
child: _loading
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
: ListView(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'분신이 보류·차단한 내용과 에스컬레이션 기록입니다. '
'이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
),
if (_logs.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Text('새 알림이 없습니다.'),
),
for (final log in _logs)
ListTile(
leading: Icon(
log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded,
color: log.resolved ? theme.colorScheme.primary : theme.colorScheme.error,
),
title: Text(log.reason.isEmpty ? '에스컬레이션' : log.reason),
subtitle: Text(
[
'대화방 #${log.conversationId}',
if (log.messageSnippet.isNotEmpty) log.messageSnippet,
log.createdAt.toLocal().toString().split('.').first,
].join('\n'),
),
isThreeLine: true,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(conversationId: log.conversationId),
),
);
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/session_state.dart';
/// Phase 1 onboarding skeleton: capture a few style samples locally.
/// Fine copy / import UX waits for human PoC (#1) do not invent §3 defaults here.
class OnboardingToneScreen extends StatefulWidget {
const OnboardingToneScreen({super.key});
@override
State<OnboardingToneScreen> createState() => _OnboardingToneScreenState();
}
class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
final _samples = List.generate(4, (_) => TextEditingController());
var _seeded = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_seeded) return;
_seeded = true;
final existing = context.read<SessionState>().styleExamples;
for (var i = 0; i < existing.length && i < _samples.length; i++) {
_samples[i].text = existing[i];
}
}
@override
void dispose() {
for (final c in _samples) {
c.dispose();
}
super.dispose();
}
Future<void> _save({required bool markDone}) async {
final session = context.read<SessionState>();
await session.saveToneSamples(
_samples.map((c) => c.text).toList(),
markDone: markDone,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('말투 샘플'),
actions: [
TextButton(
onPressed: () => context.read<SessionState>().skipToneOnboarding(),
child: const Text('나중에'),
),
],
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
'최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
for (var i = 0; i < _samples.length; i++) ...[
TextField(
controller: _samples[i],
maxLines: 2,
decoration: InputDecoration(
labelText: '샘플 ${i + 1}',
hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
],
const SizedBox(height: 8),
FilledButton(
onPressed: () => _save(markDone: true),
child: const Text('저장하고 시작'),
),
],
),
),
);
}
}

View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/api_client.dart';
import '../state/session_state.dart';
/// Multi-device awareness: list / revoke active sessions (roadmap B).
class SessionsScreen extends StatefulWidget {
const SessionsScreen({super.key});
@override
State<SessionsScreen> createState() => _SessionsScreenState();
}
class _SessionsScreenState extends State<SessionsScreen> {
List<Map<String, dynamic>> _sessions = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final session = context.read<SessionState>();
if (session.user == null) return;
setState(() {
_loading = true;
_error = null;
});
try {
final list = await session.api.listSessions(session.user!.id);
setState(() => _sessions = list);
} on ApiException catch (e) {
setState(() => _error = '세션 목록 실패 (${e.statusCode})');
} finally {
setState(() => _loading = false);
}
}
Future<void> _revoke(Map<String, dynamic> s) async {
final session = context.read<SessionState>();
if (session.user == null) return;
final id = s['id'];
if (id is! num) return;
final isCurrent = s['is_current'] == true;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(isCurrent ? '이 기기에서 로그아웃?' : '다른 세션 종료?'),
content: Text(isCurrent ? '현재 기기 세션이 삭제됩니다.' : '선택한 기기의 로그인이 해제됩니다.'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('종료')),
],
),
);
if (ok != true) return;
try {
await session.api.revokeSession(session.user!.id, id.toInt());
if (isCurrent && mounted) {
// Soft signal full logout clearing is a follow-up; reload list for now.
setState(() => _error = '현재 세션이 종료되었습니다. 앱을 다시 시작해 주세요.');
}
await _load();
} on ApiException catch (e) {
setState(() => _error = '세션 종료 실패 (${e.statusCode})');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('로그인 세션')),
body: RefreshIndicator(
onRefresh: _load,
child: _loading
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
: ListView(
children: [
const Padding(
padding: EdgeInsets.all(16),
child: Text('이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.'),
),
if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
for (final s in _sessions)
ListTile(
leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other),
title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'),
subtitle: Text('만료: ${s['expires_at'] ?? ''}'),
trailing: IconButton(
tooltip: '세션 종료',
icon: const Icon(Icons.logout),
onPressed: () => _revoke(s),
),
),
],
),
),
);
}
}

View File

@ -76,6 +76,67 @@ class ApiClient {
); );
} }
Future<List<ConversationSummary>> listConversations() async {
final obj = await _getObject('/conversations');
final list = (obj['conversations'] as List<dynamic>? ?? const []);
return list.map((e) => ConversationSummary.fromJson(e as Map<String, dynamic>)).toList();
}
Future<ConversationSummary> createConversation({
required List<int> userIds,
int? contactId,
bool isGroup = false,
}) async {
final json = await _json('POST', '/conversations', body: {
'user_ids': userIds,
'is_group': isGroup,
if (contactId != null) 'contact_id': contactId,
});
final rawIds = json['user_ids'] as List<dynamic>? ?? userIds;
return ConversationSummary(
id: json['id'] as int,
isGroup: json['is_group'] as bool? ?? isGroup,
userIds: rawIds.map((e) => e as int).toList(),
twinDisabledByPeer: false,
);
}
Future<List<ChatMessage>> listMessages(int conversationId) async {
final obj = await _getObject('/conversations/$conversationId/messages');
final list = (obj['messages'] as List<dynamic>? ?? const []);
return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList();
}
Future<List<Contact>> listContacts(int userId) async {
final obj = await _getObject('/users/$userId/contacts');
final list = (obj['contacts'] as List<dynamic>? ?? const []);
return list.map((e) => Contact.fromJson(e as Map<String, dynamic>)).toList();
}
Future<Contact> createContact({
required int userId,
required String displayName,
int? contactUserId,
String relationshipNote = '',
}) async {
final json = await _json('POST', '/users/$userId/contacts', body: {
'display_name': displayName,
if (contactUserId != null) 'contact_user_id': contactUserId,
'relationship_note': relationshipNote,
});
return Contact.fromJson(json);
}
Future<void> deleteContact(int userId, int contactId) async {
await _json('DELETE', '/users/$userId/contacts/$contactId');
}
Future<List<EscalationLogEntry>> listEscalationLogs(int userId) async {
final obj = await _getObject('/users/$userId/escalation-logs');
final list = (obj['escalation_logs'] as List<dynamic>? ?? const []);
return list.map((e) => EscalationLogEntry.fromJson(e as Map<String, dynamic>)).toList();
}
Future<ChatMessage> sendMessage({ Future<ChatMessage> sendMessage({
required int conversationId, required int conversationId,
required int senderId, required int senderId,
@ -126,24 +187,6 @@ class ApiClient {
return list.map((e) => WhitelistRule.fromJson(e as Map<String, dynamic>)).toList(); return list.map((e) => WhitelistRule.fromJson(e as Map<String, dynamic>)).toList();
} }
Future<List<Map<String, dynamic>>> listConversations() async {
final obj = await _getObject('/conversations');
final list = (obj['conversations'] as List<dynamic>? ?? const []);
return list.cast<Map<String, dynamic>>();
}
Future<Map<String, dynamic>> createConversation({
required List<int> userIds,
int? contactId,
bool isGroup = false,
}) async {
return _json('POST', '/conversations', body: {
'user_ids': userIds,
'is_group': isGroup,
if (contactId != null) 'contact_id': contactId,
});
}
Future<WhitelistRule> addWhitelist(int userId, String topicKeyword) async { Future<WhitelistRule> addWhitelist(int userId, String topicKeyword) async {
final json = await _json('POST', '/users/$userId/whitelist-rules', body: { final json = await _json('POST', '/users/$userId/whitelist-rules', body: {
'topic_keyword': topicKeyword, 'topic_keyword': topicKeyword,
@ -154,4 +197,25 @@ class ApiClient {
Future<void> deleteWhitelist(int userId, int ruleId) async { Future<void> deleteWhitelist(int userId, int ruleId) async {
await _json('DELETE', '/users/$userId/whitelist-rules/$ruleId'); await _json('DELETE', '/users/$userId/whitelist-rules/$ruleId');
} }
Future<List<Map<String, dynamic>>> listSessions(int userId) async {
final obj = await _getObject('/users/$userId/sessions');
final list = (obj['sessions'] as List<dynamic>? ?? const []);
return list.cast<Map<String, dynamic>>();
}
Future<void> registerDeviceToken({
required int userId,
required String token,
String platform = 'android',
}) async {
await _json('POST', '/users/$userId/device-tokens', body: {
'token': token,
'platform': platform,
});
}
Future<void> revokeSession(int userId, int sessionId) async {
await _json('DELETE', '/users/$userId/sessions/$sessionId');
}
} }

View File

@ -1,24 +1,49 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../db/app_database.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
class SessionState extends ChangeNotifier { class SessionState extends ChangeNotifier {
SessionState({ApiClient? api}) : _api = api ?? ApiClient(); SessionState({ApiClient? api, AppDatabase? db})
: _api = api ?? ApiClient(),
_db = db;
final ApiClient _api; final ApiClient _api;
AppDatabase? _db;
User? user; User? user;
AutonomyLevel autonomyLevel = AutonomyLevel.L0; AutonomyLevel autonomyLevel = AutonomyLevel.L0;
String? error; String? error;
bool loading = false; bool loading = false;
bool toneOnboardingDone = false;
List<String> styleExamples = const [];
bool localDbEncrypted = false;
static const _kUserId = 'user_id'; static const _kUserId = 'user_id';
static const _kDisplayName = 'display_name'; static const _kDisplayName = 'display_name';
static const _kInvite = 'invite_code'; static const _kInvite = 'invite_code';
static const _kToken = 'session_token'; static const _kToken = 'session_token';
// Legacy SharedPreferences keys migrated into encrypted drift on first open.
static const _kToneDoneLegacy = 'tone_onboarding_done';
static const _kStyleExamplesLegacy = 'style_examples_json';
static const _kToneDone = 'tone_onboarding_done';
static const _kDeviceInstallId = 'device_install_id';
AppDatabase? get db => _db;
Future<void> restore() async { Future<void> restore() async {
_db ??= await _openDb();
await _migrateLegacyTonePrefs();
toneOnboardingDone = await _db!.getBoolKv(_kToneDone);
styleExamples = await _db!.loadToneSamples();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final id = prefs.getInt(_kUserId); final id = prefs.getInt(_kUserId);
final name = prefs.getString(_kDisplayName); final name = prefs.getString(_kDisplayName);
@ -27,10 +52,46 @@ class SessionState extends ChangeNotifier {
if (id != null && name != null && invite != null) { if (id != null && name != null && invite != null) {
user = User(id: id, displayName: name, inviteCode: invite); user = User(id: id, displayName: name, inviteCode: invite);
_api.authToken = token; _api.authToken = token;
await _registerDeviceTokenBestEffort();
notifyListeners(); notifyListeners();
} }
} }
Future<AppDatabase> _openDb() async {
try {
final db = await AppDatabase.open();
localDbEncrypted = true;
return db;
} catch (e) {
// Linux CI / hosts without libsqlcipher.so fall back to memory so the
// app still boots; Android release path uses SQLCipher.
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
localDbEncrypted = false;
return AppDatabase.memory();
}
}
Future<void> _migrateLegacyTonePrefs() async {
final prefs = await SharedPreferences.getInstance();
final rawExamples = prefs.getString(_kStyleExamplesLegacy);
final legacyDone = prefs.getBool(_kToneDoneLegacy);
final existing = await _db!.loadToneSamples();
if (existing.isEmpty && rawExamples != null && rawExamples.isNotEmpty) {
final decoded = (jsonDecode(rawExamples) as List<dynamic>)
.map((e) => e.toString())
.where((s) => s.trim().isNotEmpty)
.toList();
if (decoded.isNotEmpty) {
await _db!.replaceToneSamples(decoded);
}
await prefs.remove(_kStyleExamplesLegacy);
}
if (legacyDone != null) {
await _db!.setBoolKv(_kToneDone, legacyDone);
await prefs.remove(_kToneDoneLegacy);
}
}
Future<void> signup(String inviteCode, String displayName) async { Future<void> signup(String inviteCode, String displayName) async {
loading = true; loading = true;
error = null; error = null;
@ -38,11 +99,15 @@ class SessionState extends ChangeNotifier {
try { try {
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName); final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
user = result.user; user = result.user;
toneOnboardingDone = false;
_db ??= await _openDb();
await _db!.setBoolKv(_kToneDone, false);
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_kUserId, result.user.id); await prefs.setInt(_kUserId, result.user.id);
await prefs.setString(_kDisplayName, result.user.displayName); await prefs.setString(_kDisplayName, result.user.displayName);
await prefs.setString(_kInvite, result.user.inviteCode); await prefs.setString(_kInvite, result.user.inviteCode);
await prefs.setString(_kToken, result.token); await prefs.setString(_kToken, result.token);
await _registerDeviceTokenBestEffort();
} on ApiException catch (e) { } on ApiException catch (e) {
error = '가입 실패 (${e.statusCode}): ${e.body}'; error = '가입 실패 (${e.statusCode}): ${e.body}';
} catch (e) { } catch (e) {
@ -53,6 +118,23 @@ class SessionState extends ChangeNotifier {
} }
} }
Future<void> saveToneSamples(List<String> samples, {bool markDone = true}) async {
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
styleExamples = cleaned;
if (markDone) toneOnboardingDone = true;
_db ??= await _openDb();
await _db!.replaceToneSamples(cleaned);
if (markDone) await _db!.setBoolKv(_kToneDone, true);
notifyListeners();
}
Future<void> skipToneOnboarding() async {
toneOnboardingDone = true;
_db ??= await _openDb();
await _db!.setBoolKv(_kToneDone, true);
notifyListeners();
}
Future<void> setAutonomy(AutonomyLevel level) async { Future<void> setAutonomy(AutonomyLevel level) async {
if (user == null) return; if (user == null) return;
try { try {
@ -65,5 +147,27 @@ class SessionState extends ChangeNotifier {
} }
} }
/// Registers a stable install id as the push token until Firebase Messaging
/// is wired with a real FCM registration token (roadmap B).
Future<void> _registerDeviceTokenBestEffort() async {
if (user == null) return;
try {
_db ??= await _openDb();
var installId = await _db!.getKv(_kDeviceInstallId);
if (installId == null || installId.isEmpty) {
final rand = Random.secure();
installId = List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
await _db!.setKv(_kDeviceInstallId, installId);
}
await _api.registerDeviceToken(
userId: user!.id,
token: 'install:$installId',
platform: defaultTargetPlatform == TargetPlatform.android ? 'android' : 'other',
);
} catch (e) {
debugPrint('device token register skipped: $e');
}
}
ApiClient get api => _api; ApiClient get api => _api;
} }

View File

@ -1,6 +1,30 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a
url: "https://pub.dev"
source: hosted
version: "88.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f"
url: "https://pub.dev"
source: hosted
version: "8.1.1"
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:
@ -17,6 +41,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
build_config:
dependency: transitive
description:
name: build_config
sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78
url: "https://pub.dev"
source: hosted
version: "4.1.2"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
url: "https://pub.dev"
source: hosted
version: "8.12.6"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -25,6 +97,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -41,6 +137,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@ -57,6 +161,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
url: "https://pub.dev"
source: hosted
version: "3.1.2"
drift:
dependency: "direct main"
description:
name: drift
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -81,6 +209,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -94,6 +230,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev"
source: hosted
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -104,6 +288,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -112,6 +312,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@ -120,6 +328,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -152,6 +376,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -176,6 +408,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@ -184,14 +424,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
path: package_config:
dependency: transitive dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: "direct main"
description: description:
name: path name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
url: "https://pub.dev"
source: hosted
version: "2.2.19"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@ -232,6 +504,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -240,6 +520,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -296,11 +600,35 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@ -309,6 +637,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.1"
sqlcipher_flutter_libs:
dependency: "direct main"
description:
name: sqlcipher_flutter_libs
sha256: dd1fcc74d5baf3c36ad53e2652b2d06c9f8747494a3ccde0076e88b159dfe622
url: "https://pub.dev"
source: hosted
version: "0.6.8"
sqlite3:
dependency: "direct main"
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
url: "https://pub.dev"
source: hosted
version: "0.43.1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -325,6 +677,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@ -373,6 +733,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.0" version: "15.0.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web: web:
dependency: transitive dependency: transitive
description: description:
@ -397,6 +765,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@ -405,6 +781,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.8.1 <4.0.0" dart: ">=3.8.1 <4.0.0"
flutter: ">=3.29.0" flutter: ">=3.29.0"

View File

@ -38,6 +38,12 @@ dependencies:
web_socket_channel: ^3.0.3 web_socket_channel: ^3.0.3
provider: ^6.1.5+1 provider: ^6.1.5+1
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
drift: ^2.31.0
path: ^1.9.1
path_provider: ^2.1.5
sqlite3: ^2.9.4
flutter_secure_storage: ^10.3.1
sqlcipher_flutter_libs: ^0.6.8
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -49,6 +55,8 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
drift_dev: ^2.31.0
build_runner: ^2.15.1
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

View File

@ -0,0 +1,18 @@
import 'package:bunsin_mobile/db/app_database.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('tone samples round-trip in memory database', () async {
final db = AppDatabase.memory();
addTearDown(db.close);
await db.replaceToneSamples(['ㅇㅇ 알겠음', 'ㅋㅋ 그래']);
expect(await db.loadToneSamples(), ['ㅇㅇ 알겠음', 'ㅋㅋ 그래']);
await db.setBoolKv('tone_onboarding_done', true);
expect(await db.getBoolKv('tone_onboarding_done'), isTrue);
await db.replaceToneSamples(['하나만']);
expect(await db.loadToneSamples(), ['하나만']);
});
}

View File

@ -0,0 +1,37 @@
import 'package:bunsin_mobile/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('ConversationSummary parses list payload', () {
final c = ConversationSummary.fromJson({
'id': 3,
'is_group': false,
'twin_disabled_by_peer': true,
'user_ids': [1, 2],
'created_at': '2026-07-30T00:00:00Z',
});
expect(c.id, 3);
expect(c.userIds, [1, 2]);
expect(c.twinDisabledByPeer, isTrue);
expect(c.titleFor(1), '대화 · 상대 #2');
});
test('EscalationLogEntry and Contact parse', () {
final log = EscalationLogEntry.fromJson({
'id': 9,
'conversation_id': 2,
'reason': 'money',
'message_snippet': '보내줄게',
'resolved': false,
'created_at': '2026-07-30T01:00:00Z',
});
expect(log.reason, 'money');
final contact = Contact.fromJson({
'id': 1,
'display_name': '친구',
'contact_user_id': 7,
'relationship_note': '대학',
});
expect(contact.contactUserId, 7);
});
}

14
scripts/build_release_aab.sh Executable file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Optional Play internal-testing bundle (docs/android-release.md).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT/mobile"
if [[ ! -f android/key.properties ]]; then
echo "WARN: android/key.properties missing — AAB will use debug signing." >&2
fi
flutter pub get
flutter build appbundle --release "$@"
echo
echo "AAB: $ROOT/mobile/build/app/outputs/bundle/release/app-release.aab"

15
scripts/build_release_apk.sh Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Internal closed-beta APK (docs/android-release.md).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT/mobile"
if [[ ! -f android/key.properties ]]; then
echo "WARN: android/key.properties missing — release APK will use debug signing." >&2
echo " Copy android/key.properties.example for a real beta build." >&2
fi
flutter pub get
flutter build apk --release "$@"
echo
echo "APK: $ROOT/mobile/build/app/outputs/flutter-apk/app-release.apk"

197
scripts/e2e_a3.py Executable file
View File

@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""A3 API-level E2E against a running core-backend (+ ai-service for draft/escalate).
Covers mobile/README.md checklist at the HTTP layer.
Android emulator/device UI taps are out of scope in this cloud VM (no Android SDK).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
BASE = os.environ.get("CORE_API_BASE", "http://127.0.0.1:8080")
ADMIN = os.environ.get("ADMIN_API_TOKEN", "dev-admin-token")
passed = 0
failed = 0
def ok(msg: str) -> None:
global passed
passed += 1
print(f" OK: {msg}")
def fail(msg: str) -> None:
global failed
failed += 1
print(f" FAIL: {msg}")
def step(msg: str) -> None:
print(f"\n==> {msg}")
def req(method: str, path: str, body=None, token: str | None = None, admin: bool = False):
data = None if body is None else json.dumps(body).encode()
headers = {"Content-Type": "application/json"}
if admin:
headers["Authorization"] = f"Bearer {ADMIN}"
elif token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request) as resp:
raw = resp.read().decode()
return resp.status, json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
raw = e.read().decode()
try:
payload = json.loads(raw) if raw else {}
except json.JSONDecodeError:
payload = {"raw": raw}
return e.code, payload
def main() -> int:
step("health")
code, body = req("GET", "/health")
ok("core health") if code == 200 else fail(f"health {code} {body}")
step("1) invite + signup (two users)")
_, ia = req("POST", "/invites", {}, admin=True)
_, ib = req("POST", "/invites", {}, admin=True)
code_a, sa = req("POST", "/auth/signup", {"invite_code": ia["code"], "display_name": "E2E Alice"})
code_b, sb = req("POST", "/auth/signup", {"invite_code": ib["code"], "display_name": "E2E Bob"})
if code_a == 200 and code_b == 200 and sa.get("token") and sb.get("token"):
ok(f"signup tokens for #{sa['id']} and #{sb['id']}")
else:
fail(f"signup a={code_a} b={code_b}")
return 1
token_a, token_b = sa["token"], sb["token"]
id_a, id_b = sa["id"], sb["id"]
step("2) contact + conversation create")
_, contact = req(
"POST",
f"/users/{id_a}/contacts",
{"display_name": "Bob", "contact_user_id": id_b, "relationship_note": "e2e"},
token=token_a,
)
contact_id = contact["id"]
_, conv = req(
"POST",
"/conversations",
{"user_ids": [id_a, id_b], "contact_id": contact_id},
token=token_a,
)
conv_id = conv["id"]
ok(f"conversation #{conv_id} via contact #{contact_id}")
_, clist = req("GET", "/conversations", token=token_a)
ids = [c["id"] for c in clist.get("conversations", [])]
ok("conversation list includes room") if conv_id in ids else fail(f"list missing room: {ids}")
step("3) human message + history")
_, msg = req(
"POST",
f"/conversations/{conv_id}/messages",
{"sender_id": id_a, "text": "안녕 Bob", "sender_mode": "human"},
token=token_a,
)
msg_id = msg["id"]
_, hist = req("GET", f"/conversations/{conv_id}/messages", token=token_a)
hist_ids = [m["id"] for m in hist.get("messages", [])]
ok(f"history retains message #{msg_id}") if msg_id in hist_ids else fail("history")
step("4) draft + L1 approved twin send")
dcode, draft = req(
"POST",
f"/conversations/{conv_id}/draft",
{"context_lines": ["상대: 오늘 뭐해?"], "style_examples": ["ㅇㅇ 알겠음", "ㅋㅋ 그래"]},
token=token_a,
)
if dcode == 200 and draft.get("status") in {"ok", "no_key", "escalate"}:
ok(f"draft status={draft.get('status')}")
else:
fail(f"draft {dcode} {draft}")
req("PATCH", f"/users/{id_a}/twin-settings", {"autonomy_level": "L1"}, token=token_a)
twin_text = "ㅇㅇ 알겠음 나중에"
if draft.get("status") == "ok" and draft.get("text"):
twin_text = draft["text"]
tcode, twin = req(
"POST",
f"/conversations/{conv_id}/messages",
{"sender_id": id_a, "text": twin_text, "sender_mode": "twin", "approved": True},
token=token_a,
)
twin_id = twin.get("id")
if tcode == 200 and twin_id:
ok(f"L1 approved twin message #{twin_id}")
else:
fail(f"twin send {tcode} {twin}")
return 1
step("5) escalation path + inbox logs")
ecode, _ = req(
"POST",
f"/conversations/{conv_id}/messages",
{
"sender_id": id_a,
"text": "계좌로 돈 보내줄게 계좌번호 알려줘",
"sender_mode": "twin",
"approved": True,
},
token=token_a,
)
if ecode != 200:
ok(f"escalating twin blocked (HTTP {ecode})")
else:
fail("escalating twin unexpectedly accepted")
_, logs = req("GET", f"/users/{id_a}/escalation-logs", token=token_a)
if "escalation_logs" in logs:
ok(f"escalation-logs count={len(logs['escalation_logs'])}")
else:
fail("escalation-logs shape")
step("6) retract twin + veto")
rcode, _ = req("POST", f"/messages/{twin_id}/retract", token=token_a)
ok(f"retract HTTP {rcode}") if rcode == 200 else fail(f"retract {rcode}")
vcode, _ = req("POST", f"/conversations/{conv_id}/veto", token=token_b)
ok(f"peer veto HTTP {vcode}") if vcode == 200 else fail(f"veto {vcode}")
bcode, _ = req(
"POST",
f"/conversations/{conv_id}/messages",
{"sender_id": id_a, "text": "veto 이후", "sender_mode": "twin", "approved": True},
token=token_a,
)
ok(f"twin blocked after veto (HTTP {bcode})") if bcode != 200 else fail("twin allowed after veto")
step("7) whitelist CRUD + autonomy")
_, wl = req(
"POST",
f"/users/{id_a}/whitelist-rules",
{"topic_keyword": "날씨"},
token=token_a,
)
_, wlist = req("GET", f"/users/{id_a}/whitelist-rules", token=token_a)
rules = wlist.get("whitelist_rules", [])
ok("whitelist add/list") if any(r.get("topic_keyword") == "날씨" for r in rules) else fail("whitelist")
dcode, _ = req("DELETE", f"/users/{id_a}/whitelist-rules/{wl['id']}", token=token_a)
ok("whitelist delete") if dcode == 200 else fail(f"whitelist delete {dcode}")
acode, _ = req("PATCH", f"/users/{id_a}/twin-settings", {"autonomy_level": "L0"}, token=token_a)
ok("autonomy L0 reset") if acode == 200 else fail(f"autonomy {acode}")
step("8) contacts list")
_, contacts = req("GET", f"/users/{id_a}/contacts", token=token_a)
cids = [c["id"] for c in contacts.get("contacts", [])]
ok("contacts list") if contact_id in cids else fail("contacts list")
print(f"\n==== E2E RESULT: {passed} passed, {failed} failed ====")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())