diff --git a/docs/android/01-architecture.md b/docs/android/01-architecture.md new file mode 100644 index 0000000..c259cca --- /dev/null +++ b/docs/android/01-architecture.md @@ -0,0 +1,371 @@ +# 01 · 架构 + +> 回答 3 个问题:**模块怎么分?数据怎么流?为啥这样选?** + +--- + +## 1. 模块划分 + +原则:**单 module + 按包分层**。MVP 不上多 module,等过 80 个 Kotlin 文件再拆 `:data` `:domain` `:ui` 三个 Gradle module。 + +### 1.1 顶层包结构 + +``` +com.diary.news +├── DiaryNewsApp.kt # @HiltAndroidApp +├── MainActivity.kt # @AndroidEntryPoint,Compose host +│ +├── data/ # ─── 数据层(对外接口 + 缓存)─── +│ ├── api/ +│ │ ├── ApiService.kt # Retrofit interface +│ │ ├── dto/ # 12 个文件,与后端 schema 一一对应 +│ │ ├── AuthInterceptor.kt # 加 Bearer +│ │ └── TokenAuthenticator.kt # 401 自动 refresh +│ ├── auth/ +│ │ ├── TokenStore.kt # EncryptedSharedPreferences 封装 +│ │ └── AuthRepository.kt +│ ├── db/ +│ │ ├── AppDatabase.kt +│ │ ├── dao/ +│ │ └── entity/ +│ └── repository/ # ─── 业务仓储 ─── +│ ├── ArticleRepository.kt +│ ├── BookmarkRepository.kt +│ └── SourceRepository.kt +│ +├── domain/ # ─── 纯 Kotlin 业务模型(无 Android 依赖)─── +│ └── model/ +│ ├── Article.kt +│ ├── Source.kt +│ ├── Bookmark.kt +│ └── ... +│ +├── di/ # ─── Hilt 模块 ─── +│ ├── NetworkModule.kt # Retrofit + OkHttp + Interceptor +│ ├── DatabaseModule.kt # Room +│ └── RepositoryModule.kt # @Binds 绑定 interface → impl +│ +└── ui/ # ─── 表现层(Compose)─── + ├── theme/ + │ ├── Theme.kt # Material3 + 蓝主题 + │ ├── Color.kt + │ └── Type.kt + ├── nav/ + │ └── AppNav.kt # Navigation Compose + ├── login/ + │ ├── LoginScreen.kt + │ └── LoginViewModel.kt + ├── feed/ + │ ├── FeedScreen.kt + │ ├── FeedViewModel.kt + │ └── ArticlePagingSource.kt + ├── article/ + │ ├── ArticleScreen.kt + │ ├── ArticleViewModel.kt + │ └── ArticleTabs.kt # 评论/译文/原文 三段 Tab + ├── bookmarks/ + │ ├── BookmarksScreen.kt + │ └── BookmarksViewModel.kt + ├── sources/ + │ ├── SourcesScreen.kt + │ └── SourcesViewModel.kt + └── common/ # 通用组件 + ├── ArticleCard.kt + ├── CommentaryBox.kt + ├── EmptyState.kt + ├── ErrorState.kt + └── LoadingState.kt +``` + +### 1.2 分层依赖关系(单向) + +``` +ui ──→ domain ──→ data ──→ (Retrofit / Room) + ↑ + └─ TokenStore(被 ApiService 和 AuthRepository 共用) +``` + +**关键约束**: +- `domain` **不依赖 Android SDK**(纯 Kotlin,为了以后能抽出来 JVM 跑单元测试) +- `data` 可以依赖 Android SDK(`Context` for EncryptedSharedPreferences / Room) +- `ui` 唯一允许持有 `Context` 的层,通过 Hilt `@ApplicationContext` 注入 + +### 1.3 为什么不现在分 Gradle module + +| 拆 module 的好处 | 不拆的好处(现阶段)| +|---|---| +| 强制分层 | 编译快(单 module 增量编译 5s,多 module 30s+)| +| 增量 build 更快 | 心智负担低,新功能加上去就完了 | +| 团队人多时并行开发 | 适合 1-2 人小项目 | + +**触发拆 module 的信号**:Kotlin 文件 > 80,或 build 时间 > 60s。 + +--- + +## 2. 数据流 + +### 2.1 三种典型场景 + +#### 场景 A:列表(分页 + 缓存无关) + +``` +FeedScreen + └─ FeedViewModel.pager: Flow> + └─ Pager(config = PagingConfig(pageSize =50)) + └─ ArticlePagingSource + └─ load(page): ApiService.listArticles(page, page_size =50) + └─ 返回 ArticleListResponseDto + └─ DTO → domain.Article mapper + └─ PagingData → LazyColumn +``` + +**为什么用 Paging 3 而不是 LazyColumn + 自己管 offset**: +- 预取(滑到底前 3 个就开始加载下一页) +- 失败重试只重试那一页 +- 内存上限自动管(滑走的会被回收) +- 后续要换成 Room-backed `RemoteMediator` 也是几行代码 + +#### 场景 B:详情(单次拉取 + Room 缓存) + +``` +ArticleScreen(articleId) + └─ ArticleViewModel.uiState: StateFlow + └─ load(id): + ├─ Room.bookmarkDao.findById(id) → 看是否已收藏(乐观更新用) + ├─ ApiService.getArticle(id) + └─ 合并 → UiState.Success(article, isBookmarked) +``` + +**Room 这里只缓存 bookmark 表**(不是 article 表)。原因见 §3。 + +#### 场景 C:登录 + +``` +LoginScreen + └─ LoginViewModel.login(username, password) + └─ AuthRepository.login() + ├─ ApiService.login(LoginRequest) + └─ TokenStore.save(access, refresh, expiresAt) + └─ 触发 SharedFlow + └─ MainActivity 监听 → 导航到 Feed +``` + +### 2.2 异常 / 401 处理 + +``` +ApiService 任意调用 → OkHttp chain + ├─ AuthInterceptor: 加 Authorization: Bearer + └─ server 返回 401 + ↓ +OkHttp 调 TokenAuthenticator.authenticate(route, response) + ├─ 检查:是不是 /auth/refresh 自己 401? 是 → 清 token,返回 null(放弃) + ├─ synchronized(this) { ... } ← 单飞锁,防并发 refresh + ├─ 当前 in-memory token ≠ request 里用的? 是 → 说明别的线程刚刷过,直接用新 token 重试 + ├─ POST /auth/refresh { refresh_token } → 新 token pair + ├─ TokenStore.save(新 token pair) + └─ 重发原 request(自动) +``` + +### 2.3 线程模型 + +| 操作 | 调度器 | 备注 | +|---|---|---| +| UI 渲染 | Main(自动)| `LaunchedEffect` / `collectAsState` | +| Retrofit `suspend fun` | IO(Retrofit 自动切)| 别用 `withContext(Dispatchers.IO)` 包 | +| Room 查询 | IO(Room 自动切)| 同上 | +| Token 读写 SP | IO(用 `flow` API)| 别在主线程 read/write | +| 图片解码 | Coil 自动 | 别手切线程 | + +**铁律**:**不要在 `Main` 干 IO**。Compose 的 `LaunchedEffect` 是 Main,默认别在里面跑阻塞调用。 + +--- + +## 3. 本地存储策略 + +### 3.1 三种数据,三种归宿 + +| 数据 | 存储位置 | 加密 | 生命周期 | +|---|---|---|---| +| `access_token` / `refresh_token` | EncryptedSharedPreferences | ✅ Keystore | 用户清 app 数据才消失 | +| 收藏列表(`bookmarks`)| Room | ❌(本机隐私)| 卸载即清 | +| 文章列表当前可见页 | 不缓存(每次拉)| — | — | +| 已读标记(可选, MVP 不做)| Room | ❌ | 卸载即清 | + +### 3.2 为什么 article 列表不缓存 + +- 后端每次返回最新翻译(LLM 增强是异步的,可能上次拉时是 pending,这次拉变 ok) +- 缓存旧版会让用户疑惑"为啥我刷了半天内容没变" +- Room 全文索引占空间,翻译正文动辄几 KB +- 列表失败时显示 Snackbar 提示 + 空状态,够了 + +### 3.3 Room schema(MVP 只要一张表) + +```kotlin@Entity(tableName = "bookmarks_cache", primaryKeys = ["userId", "articleId"]) +data class BookmarkCacheEntity( + val userId: Long, + val articleId: Long, + val title: String, + val titleZh: String?, + val bodyZhText: String?, // 离线展示用 + val publishedAt: String?, // ISO 8601 + val sourceName: String, + @ColumnInfo(name = "synced_at") val syncedAt: Long = System.currentTimeMillis(), +) + +@Dao +interface BookmarkDao { + @Query("SELECT * FROM bookmarks_cache WHERE userId = :userId ORDER BY synced_at DESC") + fun observe(userId: Long): Flow> + + @Query("SELECT articleId FROM bookmarks_cache WHERE userId = :userId") + suspend fun ids(userId: Long): List + + @Insert(onConflict = REPLACE) + suspend fun upsertAll(items: List) + + @Query("DELETE FROM bookmarks_cache WHERE userId = :userId") + suspend fun clear(userId: Long) +} +``` + +**用途**: +- 离线可看收藏列表(标题 + 译文正文) +- 详情页打开时秒判 `isBookmarked`(不用每次都 GET /bookmarks) + +--- + +## 4. 关键依赖选型理由 + +### 4.1 Retrofit vs Ktor Client + +| 维度 | Retrofit | Ktor Client | +|---|---|---| +| Android 生态 | ✅ 10 年沉淀,Coil/Paging 都有 adapter | ❌ KMP 偏多,Android 教程少 | +| 拦截器 | ✅ 完善(Authenticator / Interceptor)| ✅ 也有但 API 不一样 | +| 团队熟悉度 | ✅ 大多数 Android 团队都会 | ❌ 新 | +| 大小 | ~150KB | ~300KB | + +**结论**:Retrofit,成熟压倒一切。 + +### 4.2 kotlinx.serialization vs Moshi + +| 维度 | kotlinx.serialization | Moshi | +|---|---|---| +| 编译期生成 | ✅ KSP(快)| ❌ KAPT(慢)| +| 与 Kotlin 特性 | ✅ Sealed class / value class 一流 | ⚠️ 需要额外 adapter | +| 学习曲线 | ✅ `@Serializable` 一行 | ⚠️ 需要手动 adapter | + +**结论**:kotlinx.serialization,我们后端 Pydantic 是声明式,客户端也用声明式,心智一致。 + +### 4.3 Hilt vs Koin + +| 维度 | Hilt | Koin | +|---|---|---| +| 编译期 | ✅(编译时检查依赖图) | ❌ 运行时崩 | +| 启动速度 | ✅ 零反射 | ⚠️ 启动慢 | +| 与 Jetpack 集成 | ✅ ViewModel / Worker 原生 | ⚠️ 手动 `viewModel { }` | +| 学习曲线 | ⚠️ 注解多 | ✅ DSL 友好 | + +**结论**:Hilt,Jetpack 集成 + 编译期校验。 + +### 4.4 Coil vs Glide vs Fresco + +| 维度 | Coil | Glide | Fresco | +|---|---|---|---| +| Compose 支持 | ✅ 一等公民 | ⚠️ 用 GlideImage 包一层 | ❌ 没 Compose | +| 包大小 | ~250KB | ~500KB | ~2MB | +| 性能 | 高 | 高 | 极高(过度工程)| + +**结论**:Coil,Compose 原生。 + +### 4.5 Paging 3 vs 手写 + +**结论**:Paging 3,理由见 §2.1。 + +### 4.6 不用的东西 + +| 东西 | 为啥不用 | +|---|---| +| RxJava | 现在 Coroutines + Flow 完全够用 | +| Dagger 2(纯)| Hilt 是 Dagger 的 Android 简化版 | +| LiveData | StateFlow 完全替代 | +| MMKV | EncryptedSharedPreferences 已够,MMKV 是 Tencent 的另选 | +| Volley | 过时 | + +--- + +## 5. 安全架构 + +### 5.1 Token 流转 + +``` +LoginScreen 输入 + ↓ HTTPS? ❌(我们 HTTP,见 network_security_config) + ↓ 走明文 HTTP POST /auth/login + ↓ server 返回 { access_token, refresh_token } + ↓ TokenStore.save: + ├─ MasterKey(AES256_GCM) 由 Android Keystore 保护 + ├─ prefs.put("access", access_token) // AES256_SIV 加密 key + ├─ prefs.put("refresh", refresh_token) // AES256_GCM 加密 value + └─ 写盘 + ↓ 后续每次请求 + ↓ AuthInterceptor 读 SP → in-memory cache → 加 Bearer header +``` + +### 5.2 Network Security Config(精准白名单) + +```xml + + + + + + + + 207.57.129.228 + + +``` + +**为什么是白名单而不是全开 `cleartextTrafficPermitted="true"`**: +- 万一以后引入了第三方 SDK(广告 / 统计),不会偷偷走 HTTP +- 出问题时,review 更容易 +- 业界 best practice + +### 5.3 不做的事 + +- ❌ 不存用户密码(只存服务端返回的 token) +- ❌ 不在 logcat 输出 token(debug build 用 Timber 自动 redact) +- ❌ 不让 WebView 跑 JS(`body_zh_formatted` 是 HTML 片段,不是完整文档) +- ❌ 不 root 检测(私人 app,没必要恶心自己) + +--- + +## 6. 测试策略(MVP 阶段) + +| 层级 | 工具 | 覆盖率目标 | +|---|---|---| +| 单元测试(domain 层)| JUnit 5 + MockK | domain 逻辑 80%+ | +| ViewModel 测试 | Turbine + MockK | 关键 ViewModel | +| Repository 测试 | MockWebServer + Room in-memory | 网络 + DB 集成 | +| UI 测试 | Compose Test | 登录 + 列表各 1 个 happy path | +| 手工 | 真机刷一遍 | 7 天里程碑 DoD(见 04)| + +**先不写 UI 测试**,等主流程跑通再补。 + +--- + +## 7. 未来扩展点 + +下面这些**不影响当前架构**,需要时再加: + +| 扩展 | 改动量 | 备注 | +|---|---|---| +| 多账号切换 | TokenStore 改成 `Map` | 1 天 | +| 推送通知 | 加 Firebase 依赖 + Service | 半天,但要选 FCM 还是国内通道 | +| Wear OS | 新 module,共用 `:data` `:domain` | 2-3 天 | +| 深色主题 | `Theme.kt` 加 dark colorScheme | 2 小时 | +| Tablet adaptive UI | WindowSizeClass + 列表/详情双栏 | 1 天 | +| 平板分屏 | 同上 | 同上 | +| 阅读历史 | Room 加 `history` 表 | 半天 | +| 离线下载 | `DownloadManager` + Room | 1-2 天 | \ No newline at end of file diff --git a/docs/android/02-api-contract.md b/docs/android/02-api-contract.md new file mode 100644 index 0000000..7887ee8 --- /dev/null +++ b/docs/android/02-api-contract.md @@ -0,0 +1,585 @@ +# 02 · API 契约 + +> 后端:`http://207.57.129.228:3000/api/v1` +> +> 所有接口**走 Bearer JWT**(除 `auth/*` 外)。401 自动由 OkHttp `TokenAuthenticator` 处理。 +> +> 本文档是**对后端 OpenAPI 的客户端镜像**,任何 schema 改动两边必须同步更新。 + +--- + +## 1. 鉴权(2 个) + +### 1.1 `POST /auth/login` + +**用途**:用户名密码登录,拿 token。 + +**Request**: +```json +{ + "username": "owner", + "password": "test1234" +} +``` + +**Response 200**: +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs...", + "token_type": "bearer", + "expires_in": 3600 +} +``` + +**DTO**: +```kotlin +@Serializable +data class LoginRequest( + val username: String, + val password: String, +) + +@Serializable +data class TokenPairDto( + val access_token: String, + val refresh_token: String, + @SerialName("token_type") val tokenType: String = "bearer", + @SerialName("expires_in") val expiresIn: Int, +) +``` + +### 1.2 `POST /auth/refresh` + +**用途**:access 过期时换新的。 + +**Request**: +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**Response 200**:同 `login`。 + +**DTO**: +```kotlin +@Serializable +data class RefreshRequest( + @SerialName("refresh_token") val refreshToken: String, +) +``` + +--- + +## 2. 我(2 个) + +### 2.1 `GET /me` + +**Response 200**: +```json +{ + "id": 1, + "username": "owner", + "email": null, + "role": "owner", + "created_at": "2026-01-15T08:00:00Z", + "last_login_at": "2026-06-10T03:42:11Z" +} +``` + +**DTO**: +```kotlin +@Serializable +data class MeDto( + val id: Long, + val username: String, + val email: String? = null, + val role: String, // "owner" | "member" + @SerialName("created_at") val createdAt: String, + @SerialName("last_login_at") val lastLoginAt: String? = null, +) +``` + +### 2.2 `GET /me/usage` + +**用途**:TMT 翻译配额使用情况。 + +**Response 200**: +```json +{ + "month_used": 128000, + "month_quota": 5000000, + "month_remaining": 4872000 +} +``` + +**DTO**: +```kotlin +@Serializable +data class UsageDto( + @SerialName("month_used") val monthUsed: Long, + @SerialName("month_quota") val monthQuota: Long, + @SerialName("month_remaining") val monthRemaining: Long, +) +``` + +--- + +## 3. 文章(2 个) + +### 3.1 `GET /articles` — 列表(分页) + +**Query 参数**: + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `page` | int | 否 | 1 | 页码(从 1 开始)| +| `page_size` | int | 否 | 50 | 每页条数(1-200)| +| `source` | string | 否 | - | 逗号分隔 source slug,如 `dw,nhk` | +| `q` | string | 否 | - | 标题 / 正文模糊搜索 | +| `lang` | enum | 否 | "both" | `src` / `zh` / `both` | +| `since` | datetime | 否 | 24h 前 | 起始时间(UTC)| +| `until` | datetime | 否 | - | 结束时间(UTC)| +| `category` | string | 否 | - | LLM 分类 | +| `starred_only` | bool | 否 | false | 只看收藏 | + +**Response 200**: +```json +{ + "items": [ + { + "id": 510006, + "source": { + "id": 4, + "name": "BBC 中文", + "slug": "bbc-zh", + "region": "UK" + }, + "title": "Iran attacks Bahrain and Jordan...", + "title_zh": "伊朗袭击巴林和约旦以报复美国对霍尔木兹的袭击", + "body_zh_text": "...", + "summary_zh": "...", + "lang_src": "en", + "translation_status": "ok", + "category": "国际,军事", + "published_at": "2026-06-10T03:30:00Z", + "fetched_at": "2026-06-10T03:35:21Z", + "image_url": "https://...", + "image_ai_url": "https://...", + "commentary": "中东局势进一步升级...", + "commentary_status": "ok", + "is_starred": false + } + ], + "page": 1, + "page_size": 50, + "total": 228, + "total_pages": 5 +} +``` + +**DTO**: +```kotlin +@Serializable +data class ArticleListResponseDto( + val items: List, + val page: Int, + @SerialName("page_size") val pageSize: Int, + val total: Int, + @SerialName("total_pages") val totalPages: Int, +) + +@Serializable +data class ArticleListItemDto( + val id: Long, + val source: SourceBriefDto, + val title: String, + @SerialName("title_zh") val titleZh: String? = null, + @SerialName("body_zh_text") val bodyZhText: String? = null, + @SerialName("summary_zh") val summaryZh: String? = null, + @SerialName("lang_src") val langSrc: String? = null, + @SerialName("translation_status") val translationStatus: String, // "pending" | "ok" | "failed" + val category: String? = null, + @SerialName("published_at") val publishedAt: String? = null, + @SerialName("fetched_at") val fetchedAt: String, + @SerialName("image_url") val imageUrl: String? = null, + @SerialName("image_ai_url") val imageAiUrl: String? = null, + val commentary: String? = null, + @SerialName("commentary_status") val commentaryStatus: String? = null, + @SerialName("is_starred") val isStarred: Boolean = false, +) + +@Serializable +data class SourceBriefDto( + val id: Long, + val name: String, + val slug: String, + val region: String? = null, +) +``` + +### 3.2 `GET /articles/{id}` — 详情 + +**Response 200**(字段是 ListItem 的超集,再加正文): +```json +{ + "id": 510006, + "source": { ... }, + "url": "https://www.bbc.com/...", + "title": "Iran attacks...", + "body_html": "

...

", + "body_text": "Iran attacked Bahrain...", + "title_zh": "伊朗袭击巴林...", + "body_zh_html": "

...

", + "body_zh_text": "伊朗袭击巴林和约旦...", + "body_zh_formatted": "
...
", + "summary_zh": "...", + "lang_src": "en", + "author": "Jane Doe", + "image_url": "...", + "image_ai_url": "...", + "translation_status": "ok", + "translation_engine": "tencent", + "translated_at": "2026-06-10T03:36:02Z", + "category": "国际,军事", + "format_status": "ok", + "classify_status": "ok", + "image_ai_status": "ok", + "commentary_status": "ok", + "commentary": "中东局势进一步升级...", + "entities": { + "PERSON": ["Trump", "Khamenei"], + "ORG": ["UN", "EU"] + }, + "sentiment": -0.42, + "duplicate_of": null, + "published_at": "2026-06-10T03:30:00Z", + "fetched_at": "2026-06-10T03:35:21Z", + "is_starred": true +} +``` + +**DTO**: +```kotlin +@Serializable +data class ArticleDetailDto( + // 继承列表项所有字段 + val id: Long, + val source: SourceBriefDto, + val title: String, + @SerialName("title_zh") val titleZh: String? = null, + @SerialName("body_zh_text") val bodyZhText: String? = null, + @SerialName("summary_zh") val summaryZh: String? = null, + @SerialName("lang_src") val langSrc: String? = null, + @SerialName("translation_status") val translationStatus: String, + val category: String? = null, + @SerialName("published_at") val publishedAt: String? = null, + @SerialName("fetched_at") val fetchedAt: String, + @SerialName("image_url") val imageUrl: String? = null, + @SerialName("image_ai_url") val imageAiUrl: String? = null, + val commentary: String? = null, + @SerialName("commentary_status") val commentaryStatus: String? = null, + @SerialName("is_starred") val isStarred: Boolean = false, + // 详情独有 + val url: String, + @SerialName("body_html") val bodyHtml: String? = null, + @SerialName("body_text") val bodyText: String, + @SerialName("body_zh_html") val bodyZhHtml: String? = null, + @SerialName("body_zh_formatted") val bodyZhFormatted: String? = null, + val author: String? = null, + @SerialName("translation_engine") val translationEngine: String? = null, + @SerialName("translated_at") val translatedAt: String? = null, + @SerialName("format_status") val formatStatus: String? = null, + @SerialName("classify_status") val classifyStatus: String? = null, + @SerialName("image_ai_status") val imageAiStatus: String? = null, + val entities: JsonObject? = null, + val sentiment: Double? = null, + @SerialName("duplicate_of") val duplicateOf: Long? = null, +) +``` + +--- + +## 4. 源(1 个) + +### 4.1 `GET /sources` + +**Response 200**: +```json +[ + { + "id": 1, + "name": "DW 中文", + "slug": "dw", + "kind": "rss", + "url": "https://rss.dw.com/...", + "enabled": true, + "region": "DE", + "language_src": "de", + "priority": 10, + "fetch_interval_min": 30, + "translate_to": "zh", + "last_fetched_at": "2026-06-10T03:35:21Z", + "last_status": "ok", + "consecutive_failures": 0, + "blocklist_tags": ["体育", "娱乐"] + } +] +``` + +**DTO**: +```kotlin +@Serializable +data class SourceDto( + val id: Long, + val name: String, + val slug: String, + val kind: String, + val url: String, + val enabled: Boolean, + val region: String? = null, + @SerialName("language_src") val languageSrc: String? = null, + val priority: Int, + @SerialName("fetch_interval_min") val fetchIntervalMin: Int, + @SerialName("translate_to") val translateTo: String, + @SerialName("last_fetched_at") val lastFetchedAt: String? = null, + @SerialName("last_status") val lastStatus: String? = null, + @SerialName("consecutive_failures") val consecutiveFailures: Int = 0, + @SerialName("blocklist_tags") val blocklistTags: List? = null, +) +``` + +--- + +## 5. 收藏(3 个) + +### 5.1 `GET /bookmarks` — 我的收藏 + +**Response 200**: +```json +[ + { + "id": 88, + "article_id": 510006, + "note": "中东局势长期观察", + "created_at": "2026-06-10T05:12:30Z" + } +] +``` + +**DTO**: +```kotlin +@Serializable +data class BookmarkDto( + val id: Long, + @SerialName("article_id") val articleId: Long, + val note: String? = null, + @SerialName("created_at") val createdAt: String, +) +``` + +### 5.2 `POST /bookmarks` — 收藏 + +**Request**: +```json +{ + "article_id": 510006, + "note": "可选备注" +} +``` + +**Response 201**:同 5.1。 + +**DTO**: +```kotlin +@Serializable +data class BookmarkCreateRequest( + @SerialName("article_id") val articleId: Long, + val note: String? = null, +) +``` + +### 5.3 `DELETE /bookmarks/{article_id}` — 取消收藏 + +**Response 204**(无 body)。 + +--- + +## 6. 订阅(3 个) + +### 6.1 `POST /subscriptions` — 订阅源 + +**Request**: +```json +{ + "source_id": 1, + "category_filter": ["国际", "科技"] +} +``` + +**Response 201**: +```json +{ + "id": 12, + "source_id": 1, + "category_filter": ["国际", "科技"], + "created_at": "2026-06-10T05:00:00Z" +} +``` + +**DTO**: +```kotlin +@Serializable +data class SubscriptionCreateRequest( + @SerialName("source_id") val sourceId: Long, + @SerialName("category_filter") val categoryFilter: List? = null, +) + +@Serializable +data class SubscriptionDto( + val id: Long, + @SerialName("source_id") val sourceId: Long, + @SerialName("category_filter") val categoryFilter: List? = null, + @SerialName("created_at") val createdAt: String, +) +``` + +### 6.2 `GET /subscriptions` — 我的订阅列表 + +**Response 200**:`SubscriptionDto[]` + +### 6.3 `DELETE /subscriptions/{id}` — 取消订阅 + +**Response 204**。 + +--- + +## 7. 错误响应 + +### 7.1 4xx / 5xx 统一格式(RFC 7807) + +```json +{ + "type": "about:blank", + "title": "Article not found", + "status": 404, + "instance": "http://207.57.129.228:3000/api/v1/articles/999999" +} +``` + +### 7.2 422 Validation Error + +```json +{ + "type": "about:blank", + "title": "Validation Error", + "status": 422, + "errors": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing" + } + ], + "instance": "..." +} +``` + +### 7.3 客户端处理策略 + +```kotlin +sealed class ApiError(message: String) : Exception(message) { + object Unauthorized : ApiError("请重新登录") + object NotFound : ApiError("资源不存在") + data class Validation(val errors: List) : ApiError("请求参数错误: $errors") + data class Server(val status: Int, val title: String) : ApiError("服务器错误: $title") + data class Network(cause: Throwable) : ApiError("网络错误: ${cause.message}") +} + +// 在 Repository 里捕获 HttpException: +val response = try { + api.listArticles(...) +} catch (e: HttpException) { + when (e.code()) { + 401 -> throw ApiError.Unauthorized + 404 -> throw ApiError.NotFound + 422 -> { + val body = e.response()?.errorBody()?.string() + throw ApiError.Validation(parseValidationErrors(body)) + } + else -> throw ApiError.Server(e.code(), e.message()) + } +} catch (e: IOException) { + throw ApiError.Network(e) +} +``` + +--- + +## 8. DTO ↔ Domain 映射表 + +DTO 是网络层的事,domain model 是 UI 层的事。**永远不要让 DTO 漏到 UI**。 + +| DTO | Domain | 转换点 | +|---|---|---| +| `ArticleListItemDto` | `domain.model.Article` | `ArticlePagingSource.map()` | +| `ArticleDetailDto` | `domain.model.ArticleDetail` | `ArticleRepository.getArticle()` | +| `SourceDto` | `domain.model.Source` | `SourceRepository.list()` | +| `BookmarkDto` | `domain.model.Bookmark` | `BookmarkRepository.list()` | +| `MeDto` | `domain.model.User` | `AuthRepository.me()` | + +**Domain model 示例**(UI 用): + +```kotlin +data class Article( + val id: Long, + val sourceName: String, + val sourceSlug: String, + val title: String, + val titleZh: String?, + val bodyZhText: String?, + val summaryZh: String?, + val langSrc: String?, + val translationStatus: String, + val categories: List, // 已拆分逗号分隔 + val publishedAt: Instant?, + val imageUrl: String?, + val imageAiUrl: String?, + val commentary: String?, + val commentaryStatus: String?, + val isStarred: Boolean, +) + +data class ArticleDetail( + val article: Article, + val url: String, + val bodyText: String, + val bodyHtml: String?, + val bodyZhHtml: String?, + val bodyZhFormatted: String?, // 优先用这个做正文渲染 + val author: String?, + val translationEngine: String?, + val formatStatus: String?, + val classifyStatus: String?, + val imageAiStatus: String?, + val entities: Map>?, + val sentiment: Double?, +) +``` + +--- + +## 9. 不在 MVP 的接口(列出来,以后再用) + +下面这些是 `admin` 系列,客户端不需要: + +- `GET /admin/sources` `POST /admin/sources` `PATCH /admin/sources/{id}` `DELETE /admin/sources/{id}` +- `POST /admin/refresh/{source_id}` +- `POST /admin/translation/rerun/{article_id}` +- `POST /admin/translation/quota/reset` +- `GET /admin/health` +- `GET /admin/llm/settings` `PUT /admin/llm/settings` `POST /admin/llm/settings/reset` `POST /admin/llm/settings/test` +- `POST /admin/llm/enrich/{article_id}` + +这些都是 **owner 自己后台用**,你会在 web 后台管,app 不碰。 \ No newline at end of file diff --git a/docs/android/03-build-run.md b/docs/android/03-build-run.md new file mode 100644 index 0000000..fdba285 --- /dev/null +++ b/docs/android/03-build-run.md @@ -0,0 +1,602 @@ +# 03 · 构建与运行 + +> 从 0 到能装到真机跑的 APK,每一步都给具体命令。 +> +> 假设你本地已经有:**Android Studio Hedgehog(2023.1.1)+** + **JDK 17** + **Android SDK 35** + **Kotlin 2.0.21**。 + +--- + +## 0. 环境清单 + +| 工具 | 版本 | 验证命令 | +|---|---|---| +| Android Studio | Hedgehog (2023.1.1) 或更新 | Help → About | +| JDK | 17 | `java -version` | +| Android SDK | 35 | SDK Manager 看 "Android 15.0" | +| Gradle | 8.10+(Studio 自带) | `gradle --version` | +| Kotlin | 2.0.21(项目自带)| 无所谓 | + +**JDK 注意**:**JDK 17**,不是 11、不是 21。AGP 8.7 要求 JDK 17。 + +--- + +## 1. 新建工程 + +1. **File → New → New Project** +2. 选 **Empty Activity (Compose)** +3. 配置: + +| 项 | 值 | +|---|---| +| Name | `Diary News` | +| Package name | `com.diary.news` | +| Save location | `~/projects/diary-news-android/` | +| Language | **Kotlin** | +| Minimum SDK | **API 24 ("Nougat")** | +| Build configuration language | **Kotlin DSL** | +| ☑️ Use Version Catalog | **勾上**(用 `libs.versions.toml`)| + +4. **Finish** + +--- + +## 2. 改版本目录 + +打开 `gradle/libs.versions.toml`,**整体替换**为: + +```toml +[versions] +agp = "8.7.2" +kotlin = "2.0.21" +ksp = "2.0.21-1.0.27" + +# AndroidX & Compose +compose-bom = "2024.10.01" +activity-compose = "1.9.3" +lifecycle = "2.8.7" +navigation-compose = "2.8.4" + +# Network +retrofit = "2.11.0" +retrofit-kotlinx-converter = "1.0.0" +okhttp = "4.12.0" +kotlinx-serialization = "1.7.3" +kotlinx-coroutines = "1.9.0" + +# DI +hilt = "2.52" +hilt-navigation-compose = "1.2.0" + +# Persistence +room = "2.6.1" +security-crypto = "1.1.0-alpha06" + +# Paging +paging = "3.3.4" +paging-compose = "3.3.4" + +# Image +coil = "2.7.0" + +# Test +junit = "4.13.2" +mockk = "1.13.13" +turbine = "1.2.0" + +[libraries] +# Core +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +# Compose +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } + +# Network +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-kotlinx-serialization = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit-kotlinx-converter" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } + +# DI +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" } + +# Persistence +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +room-paging = { module = "androidx.room:room-paging", version.ref = "room" } +security-crypto = { module = "androidx.security:security-crypto-ktx", version.ref = "security-crypto" } + +# Paging +paging-runtime = { module = "androidx.paging:paging-runtime-ktx", version.ref = "paging" } +paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging-compose" } + +# Image +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } + +# Test +junit = { module = "junit:junit", version.ref = "junit" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +``` + +--- + +## 3. 改 `app/build.gradle.kts` + +整体替换为: + +```kotlin +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.diary.news" + compileSdk = 35 + + defaultConfig { + applicationId = "com.diary.news" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + isMinifyEnabled = false + } + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + // debug 阶段先不上签名,直接装 debug APK + // 真要 release,见 §6 + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Compose BOM 统一管 + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + debugImplementation(libs.androidx.compose.ui.tooling) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // Network + implementation(libs.retrofit) + implementation(libs.retrofit.kotlinx.serialization) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + // DI + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + + // Persistence + implementation(libs.room.runtime) + implementation(libs.room.ktx) + implementation(libs.room.paging) + ksp(libs.room.compiler) + implementation(libs.security.crypto) + + // Paging + implementation(libs.paging.runtime) + implementation(libs.paging.compose) + + // Image + implementation(libs.coil.compose) + + // Test + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.turbine) + testImplementation(libs.kotlinx.coroutines.test) +} +``` + +--- + +## 4. 网络安全白名单(关键!) + +### 4.1 `app/src/main/AndroidManifest.xml` + +整体替换: + +```xml + + + + + + + + + + + + + + + + +``` + +### 4.2 `app/src/main/res/xml/network_security_config.xml` + +新建文件: + +```xml + + + + + + + + + + + + 207.57.129.228 + + +``` + +### 4.3 `app/src/main/res/xml/backup_rules.xml`(禁止备份 token) + +```xml + + + + +``` + +### 4.4 `app/src/main/res/xml/data_extraction_rules.xml`(Android 12+) + +```xml + + + + + + + + + +``` + +--- + +## 5. ProGuard / R8 规则(发布包必加) + +`app/proguard-rules.pro`: + +```proguard +# Retrofit +-keepattributes Signature, InnerClasses, EnclosingMethod +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations +-keepattributes AnnotationDefault +-keep,allowobfuscation,allowshrinking interface retrofit2.Call +-keep,allowobfuscation,allowshrinking class retrofit2.Response +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# kotlinx.serialization +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt + +-keep,includedescriptorclasses class com.diary.news.**$$serializer { *; } +-keepclassmembers class com.diary.news.** { + *** Companion; + } +-keepclasseswithmembers class com.diary.news.** { + kotlinx.serialization.KSerializer serializer(...); + } + +# OkHttp +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** + +# Hilt +-keep class dagger.hilt.** { *; } +-keep class * extends dagger.hilt.android.HiltAndroidApp +``` + +--- + +## 6. 第一次 Sync & Build + +### 6.1 Sync Gradle + +- Android Studio 顶部会弹"Gradle files have changed..." → **Sync Now** +- 或菜单 **File → Sync Project with Gradle Files** +- 等 5-15 分钟(下载依赖) + +### 6.2 第一次 build + +```bash +# 命令行 +./gradlew assembleDebug + +# 或 Studio 右上角 ▶️ 直接 Run +``` + +预期: +- `BUILD SUCCESSFUL` +- 产物在 `app/build/outputs/apk/debug/app-debug.apk` + +### 6.3 跑在模拟器 + +1. 工具栏 AVD Manager → Create Virtual Device → Pixel 7 + API 35 +2. 启动模拟器(等 30s-1min) +3. Run ▶️ + +**模拟器坑**: +- 模拟器自己的 IP 是 `10.0.2.2`,不是 `127.0.0.1` +- 但我们的目标是 `207.57.129.228`(真实服务器),**模拟器直连外网就行,无需特殊配置** +- 如果你只是想测本地后端,临时把 `network_security_config.xml` 改成 `10.0.2.2` 白名单 + +### 6.4 跑在真机 + +1. **设置 → 关于手机 → 连续点 7 次"版本号"** → 开启开发者模式 +2. **设置 → 系统 → 开发者选项 → 打开 USB 调试** +3. USB 连电脑 → 手机弹"允许 USB 调试" → 确定 +4. Studio 右上角设备列表选你的真机 → Run ▶️ + +--- + +## 7. 配置服务器地址(可切换 debug / release) + +**MVP 写死在 `BuildConfig`**。 + +在 `app/build.gradle.kts` 的 `defaultConfig` 加: + +```kotlin +defaultConfig { + // ... + buildConfigField("String", "API_BASE_URL", "\"http://207.57.129.228:3000/api/v1/\"") +} + +buildTypes { + debug { + buildConfigField("String", "API_BASE_URL", "\"http://207.57.129.228:3000/api/v1/\"") + } + release { + buildConfigField("String", "API_BASE_URL", "\"http://207.57.129.228:3000/api/v1/\"") + // 后续要换 https,改这里 + 加证书 + } +} +``` + +代码里用: + +```kotlin +@Provides @Singleton +fun provideRetrofit(okHttp: OkHttpClient): Retrofit = + Retrofit.Builder() + .baseUrl(BuildConfig.API_BASE_URL) + .client(okHttp) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() +``` + +--- + +## 8. 调试技巧 + +### 8.1 OkHttp 日志 + +```kotlin +@Provides @Singleton +fun provideOkHttp( + tokenStore: TokenStore, +): OkHttpClient = + OkHttpClient.Builder() + .addInterceptor(AuthInterceptor(tokenStore)) + .addInterceptor(HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE + }) + .authenticator(TokenAuthenticator(tokenStore, ...)) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() +``` + +`Level.BODY` 会在 Logcat 打印所有请求和响应体(包含 token!小心)。 + +**生产包务必改回 `Level.NONE`**。 + +### 8.2 Compose Preview + +```kotlin +@Preview(showBackground = true) +@Composable +fun ArticleCardPreview() { + DiaryNewsTheme { + ArticleCard( + a = Article( + id = 1, + sourceName = "DW", + sourceSlug = "dw", + title = "Sample title", + titleZh = "示例标题", + bodyZhText = "示例正文...", + ... + ), + onClick = {}, + ) + } +} +``` + +### 8.3 Layout Inspector + +Studio → **Tools → Layout Inspector** → 选进程 → 实时看 Compose 树。 + +### 8.4 抓包 + +推荐 **Charles** 或 **mitmproxy**: +- 模拟器:WiFi → 长按 → 修改网络 → 手动代理 → 127.0.0.1:8888 +- 真机:Charles Proxy + 安装证书 +- 注意:**mitmproxy 会让 HTTPS 失效**(因为加了第三方 CA),我们的 HTTP 不受影响 + +--- + +## 9. CI(可选,MVP 不强求) + +如果你想每次 push 自动出 debug APK: + +`.github/workflows/build.yml`: + +```yaml +name: Build APK +on: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build + run: ./gradlew assembleDebug + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/app-debug.apk +``` + +--- + +## 10. 常见报错速查 + +| 报错 | 原因 | 解决 | +|---|---|---| +| `Cleartext HTTP traffic to 207.57.129.228 not permitted` | 没配 network_security_config | 装 §4 | +| `Cannot resolve symbol 'hiltViewModel'` | 没加 `hilt-navigation-compose` | 装 §2 依赖 | +| `Hilt: @AndroidEntryPoint ... missing binding` | Application 没 `@HiltAndroidApp` | 检查 `DiaryNewsApp.kt` | +| `KSP not found` | KSP 版本与 Kotlin 不匹配 | 检查 `ksp` 版本号 | +| `Plugin [id: 'com.google.devtools.ksp'] was not found` | settings.gradle.kts 没声明 | 在 `pluginManagement` 块加 | +| `Composable invocations can only happen from the context of a @Composable function` | 在 `LaunchedEffect` 里调非 suspend 阻塞 | 加 `withContext(Dispatchers.IO)` | +| `JSON decode error: Polymorphic serializer was not found` | 后端返回了 polymorphic 类型,DTO 没声明 | DTO 加 `@JsonClassDiscriminator` 或 sealed class | +| `401 Unauthorized` 一片 | Token 过期 / 错 | 启动 app 看 logcat 的 OkHttp 日志,确认 refresh 接口通不通 | + +--- + +## 11. 发布签名(真要上架时再弄) + +```bash +# 生成 keystore +keytool -genkey -v -keystore diary-news.jks -keyalg RSA -keysize 2048 \ + -validity 10000 -alias diary-news + +# 在 ~/.gradle/gradle.properties 加 +DIARY_NEWS_STORE_FILE=diary-news.jks +DIARY_NEWS_STORE_PASSWORD=... +DIARY_NEWS_KEY_ALIAS=diary-news +DIARY_NEWS_KEY_PASSWORD=... + +# app/build.gradle.kts +signingConfigs { + create("release") { + storeFile = file(providers.gradleProperty("DIARY_NEWS_STORE_FILE").get()) + storePassword = providers.gradleProperty("DIARY_NEWS_STORE_PASSWORD").get() + keyAlias = providers.gradleProperty("DIARY_NEWS_KEY_ALIAS").get() + keyPassword = providers.gradleProperty("DIARY_NEWS_KEY_PASSWORD").get() + } +} +buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } +} +``` + +**MVP 阶段不弄**,直接 debug APK 装手机上跑就够了。 \ No newline at end of file diff --git a/docs/android/04-milestones.md b/docs/android/04-milestones.md new file mode 100644 index 0000000..25e63ed --- /dev/null +++ b/docs/android/04-milestones.md @@ -0,0 +1,273 @@ +# 04 · 7 天里程碑 + +> 目标:第 7 天能装一个 APK 到真机,跑通 **登录 → 列表 → 详情 → 收藏 → 离线缓存** 完整链路。 +> +> 工作量估计基于:**全栈老哥**(你已经会 Kotlin/Compose 基础),按一天 3-4 小时有效开发时间算。 +> +> **每个里程碑有明确 DoD**(Definition of Done)。不达 DoD 不算完成。 + +--- + +## 全局 DoD(贯穿 7 天) + +- [ ] `./gradlew assembleDebug` 每次都 BUILD SUCCESSFUL +- [ ] 真机/模拟器能装能跑,不闪退 +- [ ] `git commit` 节奏:每个里程碑一个 commit,信息写清楚 +- [ ] logcat 无 ERROR 级别的 crash +- [ ] 没有 hardcoded 颜色 / 字体大小(全部走 `MaterialTheme`) + +--- + +## Day 1 — 工程骨架 + 鉴权三件套 + +**目标**:能登录、能持久化 token、401 自动 refresh、空 UI 能跳转。 + +### 任务清单 + +| # | 任务 | 文件 | 估时 | +|---|---|---|---| +| 1 | 新建 Android Studio 工程,改 `libs.versions.toml` + `app/build.gradle.kts` | 见 03 | 30min | +| 2 | 配 `network_security_config.xml` + 4 个 res 文件 | 见 03 §4 | 15min | +| 3 | `DiaryNewsApp.kt`(`@HiltAndroidApp`)| `app/` | 5min | +| 4 | `MainActivity.kt`(`@AndroidEntryPoint`,装 `AppNav`)| `app/` | 15min | +| 5 | DTO 文件 12 个 | `data/api/dto/` | 1h | +| 6 | `ApiService.kt` | `data/api/` | 30min | +| 7 | `TokenStore.kt`(EncryptedSharedPreferences)| `data/auth/` | 30min | +| 8 | `AuthInterceptor.kt` + `TokenAuthenticator.kt` | `data/api/` | 1h | +| 9 | `NetworkModule.kt`(Hilt)| `di/` | 30min | +| 10 | `LoginScreen.kt` + `LoginViewModel.kt` | `ui/login/` | 1h | +| 11 | `AppNav.kt` 骨架(Login ↔ Feed)| `ui/nav/` | 15min | +| 12 | `Theme.kt` + `Color.kt` + `Type.kt`(复用 web 的蓝 #2080f0)| `ui/theme/` | 30min | + +### Day 1 DoD + +- [ ] `./gradlew assembleDebug` BUILD SUCCESSFUL +- [ ] 装到真机,启动能看到登录页 +- [ ] 输入 `owner / test1234`,点登录 → 跳到一个空白 Feed 页 +- [ ] 杀掉 app 再开 → 直接进 Feed 页(token 持久化生效) +- [ ] 等 60min 后,任意接口请求 → 自动 refresh → 用户无感 +- [ ] logcat 里能看到 `OkHttp` 打印的请求日志(debug build) +- [ ] 进 `设置 → 应用 → Diary News → 存储`,看不到明文 token(只在加密 SP 里) + +### 验证方法 + +```bash +# Day 1 验证清单(贴在 issue / PR 描述里): +- [x] 登录成功,TokenStore 保存到 EncryptedSP +- [x] 重启 app 自动进 Feed(读 SP 成功) +- [x] 改密码 → 旧 token 在 server 失效 → app 收到 401 → 跳登录 +- [x] 等 access 过期(60min)→ 任意请求 → 自动 refresh → 200 +``` + +--- + +## Day 2 — Feed 列表(分页 + 卡片) + +**目标**:能滚能动,看到真实的新闻列表,卡片视觉对齐 web。 + +### 任务清单 + +| # | 任务 | 文件 | 估时 | +|---|---|---|---| +| 1 | `ArticlePagingSource.kt` | `ui/feed/` | 30min | +| 2 | `FeedViewModel.kt`(Paging 流)| `ui/feed/` | 30min | +| 3 | `ArticleCard.kt`(对齐 web 视觉)| `ui/common/` | 1h | +| 4 | `FeedScreen.kt`(LazyColumn + 加载状态)| `ui/feed/` | 1h | +| 5 | 源筛选 + 关键词搜索(下拉 + 输入)| `ui/feed/` | 1h | +| 6 | 空状态 / 错误状态 / 加载状态组件 | `ui/common/` | 30min | + +### Day 2 DoD + +- [ ] 列表能加载第一页(50 条) +- [ ] 滑到底自动加载下一页(Paging 3 自动) +- [ ] 卡片展示:源 / 语言 / 分类 tag / 时间 / 原标题(灰)/ 中标题 / 插图 / 译文正文摘要 / 评论钩子 +- [ ] 源筛选:选一个源,列表只剩该源 +- [ ] 关键词搜索:输入 "AI" → 列表过滤 +- [ ] 无网络时显示空状态 + 重试按钮 +- [ ] 滚得快时图片不卡(Coil 默认就 OK) + +### 视觉对齐清单 + +对照 web `frontend/src/views/Feed.vue`: +- 卡片圆角、间距、字号 1:1 +- 中文标题字号 18sp,原标题 13sp 灰 +- 评论钩子块背景 `#f6f8ff`,左边框 `#2080f0` 3dp + +--- + +## Day 3 — 详情页(三段 Tab) + +**目标**:点卡片进详情,看到完整译文 + 评论 + 原文。 + +### 任务清单 + +| # | 任务 | 文件 | 估时 | +|---|---|---|---| +| 1 | `ArticleDetailDto` → `domain.ArticleDetail` mapper | `data/` | 30min | +| 2 | `ArticleViewModel.kt`(`StateFlow`)| `ui/article/` | 30min | +| 3 | `ArticleScreen.kt` 骨架 + TabRow | `ui/article/` | 30min | +| 4 | `ArticleTabs.kt`:`CommentaryTab` / `TranslationTab` / `OriginalTab` | `ui/article/` | 1h | +| 5 | `TranslationTab`:WebView 渲染 `body_zh_formatted`(无 JS)| `ui/article/` | 1h | +| 6 | `OriginalTab`:WebView 渲染 `body_html`(无 JS)| `ui/article/` | 15min | +| 7 | 详情页顶部卡片(标题 / 分类 / 时间 / 插图)| `ui/article/` | 30min | +| 8 | 收藏按钮 ☆(Day 4 接 API,Day 3 先显示静态)| `ui/article/` | 15min | + +### Day 3 DoD + +- [ ] 点列表卡片 → 进详情页 +- [ ] 顶部展示:原标题(灰)/ 中标题(主)/ 插图 / 分类 tag / 发布时间 / 作者 +- [ ] 三个 Tab 可切换:评论 / 译文 / 原文 +- [ ] 译文 Tab 用 `body_zh_formatted`,如果没有就 fallback 到 `body_zh_text` +- [ ] WebView 不开 JS(验证方式:`runJavaScript` 调用失败) +- [ ] 详情页旋转屏幕 / 切换深色模式不崩 + +--- + +## Day 4 — 收藏(乐观更新 + Room 缓存) + +**目标**:能收藏 / 取消,UI 秒响应,失败可回滚。 + +### 任务清单 + +| # | 任务 | 文件 | 估时 | +|---|---|---|---| +| 1 | Room:`AppDatabase.kt` + `BookmarkDao.kt` + `BookmarkCacheEntity` | `data/db/` | 1h | +| 2 | `BookmarkRepository.kt`(乐观更新)| `data/repository/` | 1h | +| 3 | `DatabaseModule.kt`(Hilt)| `di/` | 15min | +| 4 | 详情页收藏按钮接 `ArticleViewModel.toggleBookmark()` | `ui/article/` | 30min | +| 5 | `BookmarksScreen.kt` + `BookmarksViewModel.kt` | `ui/bookmarks/` | 1h | +| 6 | 离线显示:无网络时用 Room 缓存渲染 | `ui/bookmarks/` | 30min | + +### Day 4 DoD + +- [ ] 详情页点 ☆ → 立刻变实心(乐观更新) +- [ ] 后台调 `POST /bookmarks`,失败时 → 回滚 ☆ + Snackbar "收藏失败,已撤销" +- [ ] 收藏列表页能看到所有收藏 +- [ ] 离线时收藏列表仍可看(读 Room 缓存) +- [ ] 滑动列表时无卡顿(用 `LazyColumn` + `key`) + +--- + +## Day 5 — 源 + 订阅 + +**目标**:能看所有源、订阅 / 取消订阅。 + +### 任务清单 + +| # | 任务 | 文件 | 估时 | +|---|---|---|---| +| 1 | `SourceRepository.kt` | `data/repository/` | 30min | +| 2 | `SourcesScreen.kt` + `SourcesViewModel.kt` | `ui/sources/` | 1h | +| 3 | 订阅按钮 + `POST/DELETE /subscriptions` | `ui/sources/` | 1h | +| 4 | 底部导航栏:Feed / Sources / Bookmarks | `ui/nav/` | 1h | + +### Day 5 DoD + +- [ ] 底部三 Tab:Feed / Sources / Bookmarks +- [ ] Sources 页能看到所有 enabled 源,带订阅状态 +- [ ] 点订阅按钮 → 立刻变化 + 后台请求 +- [ ] 失败 Snackbar 提示 + 回滚 +- [ ] Feed 页源筛选多选能跨 Tab 状态保留(navigation 状态管理) + +--- + +## Day 6 — 主题 + 通用打磨 + +**目标**:视觉统一、空状态友好、加载体验好。 + +### 任务清单 + +| # | 任务 | 文件 | 估时 | +|---|---|---|---| +| 1 | Material3 主题:浅色 + 深色(Dynamic Color,Android 12+)| `ui/theme/` | 1h | +| 2 | 自定义启动屏 + 应用图标 | `res/` | 30min | +| 3 | 通用加载 / 空 / 错误组件统一风格 | `ui/common/` | 30min | +| 4 | 下拉刷新(`SwipeRefreshLayout` / `PullToRefreshContainer`)| `ui/feed/` | 30min | +| 5 | 网络状态监听(ConnectivityManager)| `data/` | 30min | +| 6 | 离线条 / Snackbar 提示 | `ui/common/` | 30min | +| 7 | 长按卡片 → 分享 / 复制 URL 菜单 | `ui/common/` | 30min | + +### Day 6 DoD + +- [ ] 切深色模式 → 全 app 颜色自动切(阅读体验好) +- [ ] 启动屏不闪白(用 SplashScreen API) +- [ ] 列表支持下拉刷新 +- [ ] 飞行模式下进入 app → 各页有合理提示 +- [ ] 长按文章卡片弹出菜单:复制链接 / 在浏览器打开 / 分享 + +--- + +## Day 7 — 收尾 + 真机端到端测试 + APK 打包 + +**目标**:出第一个 release-ready APK,完整跑一遍所有功能。 + +### 任务清单 + +| # | 任务 | 文件 | 估时 | +|---|---|---|---| +| 1 | `README.md`(装 app 说明 + 调试指南)| 根目录 | 30min | +| 2 | ProGuard / R8 配置 + release build 验证 | `proguard-rules.pro` | 1h | +| 3 | 真机端到端测试(用 [e2e-checklist.md](#) 列表)| — | 2h | +| 4 | crash 报告接入(可选:Sentry / Firebase Crashlytics)| — | 1h | +| 5 | 修最后发现的 bug | — | 1h | + +### Day 7 DoD + +- [ ] `./gradlew assembleRelease` 出 `app-release-unsigned.apk` +- [ ] debug APK 跑完下面所有场景,全过 + +### E2E 测试场景清单 + +- [ ] 第一次启动 → 登录页 +- [ ] 登录成功 → 进 Feed +- [ ] 列表能加载,滑到底加载更多 +- [ ] 源筛选 / 关键词搜索生效 +- [ ] 点卡片进详情,三段 Tab 都能切 +- [ ] 收藏 / 取消收藏 UI 秒响应,后台请求成功 +- [ ] 收藏页能看到刚收藏的 +- [ ] 杀进程,重启,直接进 Feed(token 持久化) +- [ ] 飞行模式启动,各页空状态 + 离线缓存(收藏)仍可用 +- [ ] access 过期后任意请求,自动 refresh,UI 无感 +- [ ] 改密码,旧 token → app 跳登录 + +--- + +## 风险 & 应对 + +| 风险 | 概率 | 影响 | 应对 | +|---|---|---|---| +| Compose / Kotlin 版本不兼容,build 失败 | 中 | 高 | 严格用本文档指定版本;失败先查 [Compose-Kotlin Compatibility Map](https://developer.android.com/jetpack/androidx/releases/compose-kotlin) | +| 后端 schema 改了,app 跑不起来 | 中 | 中 | 启动时 catch `SerializationException`,跳错误页 + 重启按钮 | +| 真机 7.0 / 7.1 系统太老,某些 API 没有 | 低 | 中 | 用 AndroidX 兼容层;遇到问题查 [API 24 compat matrix](https://developer.android.com/training/backward-compatible-support) | +| 网络安全白名单加错,全 app 报错 | 低 | 高 | 第 1 次 build 一定先跑清单 Day 1 DoD | +| 11 章(ProGuard)没做就 release,接口全找不到 | 中 | 高 | Day 7 必须先跑 `./gradlew assembleRelease` 再装机 | +| EncryptedSharedPreferences 在某些定制 ROM 上崩 | 极低 | 中 | catch `GeneralSecurityException`,fallback 引导用户重新登录 | + +--- + +## 后续迭代方向(7 天后看) + +| 优先级 | 功能 | 估时 | +|---|---|---| +| P1 | FCM 推送(新文章到时通知)| 2-3 天 | +| P1 | 阅读历史(本地)| 半天 | +| P2 | 平板 adaptive(WindowSizeClass + 双栏)| 1-2 天 | +| P2 | 离线下载包(整周报导出)| 2-3 天 | +| P3 | Wear OS 端 | 1 周 | +| P3 | 主屏 widget | 2-3 天 | +| P3 | 多账号切换 | 1 天 | + +--- + +## 任务量快速核算 + +| Day | 估时 | 主要内容 | +|---|---|---| +| 1 | 7h | 工程 + 鉴权 | +| 2 | 4.5h | Feed + 分页 + 卡片 | +| 3 | 4.5h | 详情 + WebView | +| 4 | 4h | 收藏 + Room | +| 5 | 3.5h | 源 + 订阅 + 底导 | +| 6 | 4h | 主题 + 通用 | +| 7 | 5.5h | 测试 + APK | +| **总计** | **~33h** | 7 个有效工作日 | \ No newline at end of file diff --git a/docs/android/README.md b/docs/android/README.md new file mode 100644 index 0000000..3534509 --- /dev/null +++ b/docs/android/README.md @@ -0,0 +1,179 @@ +# Diary News — Android App + +> 私人新闻聚合器的 Android 客户端,读 FastAPI 后端。 +> +> **状态**:方案阶段(未开工)。本文档是开工前的最终蓝图,任何代码改动必须先对齐这里的约定。 + +--- + +## 0. 一句话总览 + +- **后端**:`http://207.57.129.228:3000/api/v1`(私有 IP 直连,明文 HTTP) +- **客户端**:Kotlin + Jetpack Compose,单 module +- **认证**:Bearer JWT,access 60min,自动 refresh +- **离线**:列表分页缓存 + 收藏本地库 + Token 加密存储 +- **目标**:7 天出第一个能跑通登录 → 列表 → 详情 → 收藏的 APK + +--- + +## 1. 文档索引(按开工顺序读) + +| 序 | 文档 | 作用 | 何时读 | +|---|---|---|---| +| 1 | [01-architecture.md](01-architecture.md) | 模块划分 + 数据流 + 依赖选型理由 | 开工前 30min | +| 2 | [02-api-contract.md](02-api-contract.md) | 每个接口的请求/响应 + DTO 字段映射表 | 写 DTO 时对照 | +| 3 | [03-build-run.md](03-build-run.md) | Gradle / SDK / network security / 真机调试 | 第一次 build 前 | +| 4 | [04-milestones.md](04-milestones.md) | 7 天里程碑拆分 + DoD | 每天开工前看当天任务 | + +--- + +## 2. 5 分钟决策摘要(免读细节直接用) + +| 维度 | 选择 | 替代方案(及为啥不选)| +|---|---|---| +| 语言 | Kotlin 2.0.21 | — | +| UI | Jetpack Compose (Material3) | ❌ XML View(老)| ❌ RN/Flutter(项目太轻)| +| 网络 | Retrofit 2.11 + OkHttp 4.12 | ❌ Ktor Client(生态薄)| +| 序列化 | kotlinx.serialization 1.7.3 | ❌ Moshi(更重)| +| DI | Hilt 2.52 | ❌ Koin(运行时,启动慢)| +| 分页 | Paging 3.3.4 | ❌ 手写 LazyColumn + offset | +| 图片 | Coil 2.7 | ❌ Glide(Compose 集成弱)| +| 路由 | Navigation Compose 2.8.4 | — | +| 加密 | security-crypto 1.1.0-alpha06(EncryptedSharedPreferences)| — | +| 本地 DB | Room 2.6.1 | — | +| minSdk | 24 (Android 7.0) | ❌ 26(放弃 7.0/7.1 ~3% 用户)| +| targetSdk | 35 (Android 15) | — | +| compileSdk | 35 | — | +| AGP | 8.7.2 | — | +| API base | `http://207.57.129.228:3000/api/v1` | ❌ HTTPS(当前 server 无证书)| + +--- + +## 3. 顶层目录(最终落地的样子) + +``` +diary-news-android/ # ← 独立 Git 仓库(不要混进 diary-news) +├── settings.gradle.kts├── build.gradle.kts # root +├── gradle/ +│ ├── libs.versions.toml # 集中版本 +│ └── wrapper/ +├── app/ +│ ├── build.gradle.kts│ ├── proguard-rules.pro│ └── src/main/ +│ ├── AndroidManifest.xml +│ ├── res/ # xml + values + mipmap +│ └── java/com/diary/news/ +│ ├── DiaryNewsApp.kt # @HiltAndroidApp +│ ├── MainActivity.kt│ ├── data/ # api/auth/db/repository +│ ├── domain/ # 业务 model +│ ├── di/ # Hilt modules +│ └── ui/ # theme/nav/login/feed/article/bookmarks/sources/common +└── README.md +``` + +> **仓库策略**:Android app 单独建 Git 仓库 `diary-news-android`,不要和 `diary-news`(后端 + web)混。原因是 release 节奏 / CI / 依赖管理天然不同。 + +--- + +## 4. 端到端数据流(登录 → 刷列表 → 打开详情 → 收藏) + +``` +1. 启动 + DiaryNewsApp.onCreate + → Hilt 初始化 + → MainActivity 检查 TokenStore:有 access 且未过期 → 直接进 Feed + → 无 / 过期 → 进 LoginScreen + +2. 登录 + LoginScreen → LoginViewModel.login() + → POST /auth/login { username, password } + → 拿到 { access_token, refresh_token, expires_in } + → TokenStore.save() (EncryptedSharedPreferences) + → 跳 FeedScreen + +3. 列表 + FeedScreen → FeedViewModel + → Pager + ArticlePagingSource(api::listArticles) + → ApiService.listArticles(page, page_size, ...) + → AuthInterceptor 加 Authorization: Bearer + → 返回 ArticleListResponseDto + → Paging 流到 LazyColumn + → 每一项 ArticleCard 渲染 + +4. 详情 + 点 ArticleCard → ArticleScreen(articleId) + → ArticleViewModel.load(id) + → ApiService.getArticle(id) + → 三段 Tab:评论(commentary)/译文(body_zh_formatted)/原文(body_html) + +5. 收藏 + 详情页点 ☆ 按钮 → ArticleViewModel.toggleBookmark() + → POST /bookmarks { article_id } 或 DELETE /bookmarks/{id} + → 乐观更新 UI(立刻变实心★,失败再回滚 + Snackbar) + +6. 401 自动 refresh + 任意接口返回 401 + → OkHttp TokenAuthenticator 拦截 + → 拿 refresh_token 调 /auth/refresh + → 拿到新 access → 重发原请求 + → 用户完全无感 +``` + +--- + +## 5. 不要做的事(踩坑清单) + +| 坑 | 说明 | 正确做法 | +|---|---|---| +| ❌ 把 access_token 存普通 SharedPreferences | root 手机秒读 | `EncryptedSharedPreferences` + Keystore | +| ❌ 在主线程调 Retrofit | ANR | `suspend fun` + ViewModelScope(自动主线程安全)| +| ❌ 在每次请求前同步读 token | 阻塞 UI | TokenStore 缓存到内存,只在 save/load 时动 SP | +| ❌ refresh 接口并发触发 N 次 | 触发限流 / 死锁 | `synchronized(this)` 单飞锁 | +| ❌ 全量缓存所有文章 | DB 撑爆,启动慢 | 只缓存当前可见页 | +| ❌ WebView 开 JS | XSS 风险 | `settings.javaScriptEnabled = false` | +| ❌ 信任 HTTPS 证书所有 CA | 中间人 | 默认 `system` trust anchor,不动 | +| ❌ 让 HTTP 走全网 | 不安全 | `network_security_config.xml` 白名单单一 IP | +| ❌ 在 ViewModel 里持有 Context | 内存泄漏 | 用 `@HiltAndroidApp` / `AndroidEntryPoint`,Context 通过 Hilt 注入 | +| ❌ ProGuard 不留 keep 规则就 release | retrofit 接口全找不到 | 详见 03-build-run.md §5 | + +--- + +## 6. 启动指令(开工第一步) + +1. **Android Studio Hedgehog**(2023.1.1)+ → `File → New → New Project → Empty Activity (Compose)` +2. **包名**:`com.diary.news` +3. **Application name**:`Diary News` +4. **Min SDK**:24 / **Target SDK**:35 +5. **Kotlin DSL** + **Version Catalog**(`gradle/libs.versions.toml`) +6. 改完 `gradle/libs.versions.toml` 后第一次 Sync —— 视网络,5-15 分钟 +7. 跑 `gradlew assembleDebug` 出第一个 debug APK +8. 真机或模拟器装上,网络选 **宿主机的桥接**(模拟器用 `10.0.2.2:3000` 临时绕开,真机直接走 server IP) + +完整步骤见 [03-build-run.md](03-build-run.md)。 + +--- + +## 7. 与 web 端的关系 + +| 项 | web (Vue) | Android (Kotlin) | +|---|---|---| +| 主题色 | `#2080f0` | 同 | +| 字体 | 系统字体 + 14px/13px | Material3 Typography scale | +| 卡片布局 | 标题 → 译标 → 摘要 → 评论钩子 | 同(插图在中间)| +| 列表分页 | 12345 页码 (NPagination)| 滚动加载(Paging 3)| +| 详情页布局 | 评论 / 译文 / 原文 三段 | 同(改用 Tab) | +| 鉴权 | localStorage 存 token | EncryptedSharedPreferences | + +视觉与交互**保持一致**,不要做出两个产品的分裂感。 + +--- + +## 8. 后续可能加的东西(不在 MVP) + +- 推送通知(FCM / 极光) +- 离线下载包(整本周报导出 PDF) +- 阅读历史(本地,不上服务端) +- 暗色主题(Material3 Dynamic Color) +- 主屏幕 widget(显示今日头条) +- Wear OS 端(以后再说) + +> 原则:**MVP 先能跑,再加料**。每加一项功能,先回到 [01-architecture.md](01-architecture.md) 看会不会破坏现有分层。 \ No newline at end of file diff --git a/docs/android/assets/INTEGRATION.md b/docs/android/assets/INTEGRATION.md new file mode 100644 index 0000000..02f6af7 --- /dev/null +++ b/docs/android/assets/INTEGRATION.md @@ -0,0 +1,230 @@ +# Logo + 启动屏集成指南 + +> 把 `assets/` 下所有产物拷到你的 Android Studio 工程目录里。 +> +> 主要文件清单 + 拷贝目标位置。 + +--- + +## 1. 文件清单 + +### 1.1 应用图标 + +| 源文件 | 目标位置(Android Studio 项目)| +|---|---| +| `assets/logo/ic_launcher_foreground.png` | `app/src/main/res/mipmap-anydpi-v26/ic_launcher_foreground.png`(覆盖)| +| `assets/logo/ic_launcher_background.png` | `app/src/main/res/mipmap-anydpi-v26/ic_launcher_background.png`(覆盖)| +| `assets/logo/mipmap-mdpi/ic_launcher.png` | `app/src/main/res/mipmap-mdpi/ic_launcher.png`(覆盖)| +| `assets/logo/mipmap-mdpi/ic_launcher_round.png` | `app/src/main/res/mipmap-mdpi/ic_launcher_round.png`(覆盖)| +| `assets/logo/mipmap-hdpi/ic_launcher.png` | `app/src/main/res/mipmap-hdpi/ic_launcher.png`(覆盖)| +| `assets/logo/mipmap-hdpi/ic_launcher_round.png` | `app/src/main/res/mipmap-hdpi/ic_launcher_round.png`(覆盖)| +| `assets/logo/mipmap-xhdpi/ic_launcher.png` | `app/src/main/res/mipmap-xhdpi/ic_launcher.png`(覆盖)| +| `assets/logo/mipmap-xhdpi/ic_launcher_round.png` | `app/src/main/res/mipmap-xhdpi/ic_launcher_round.png`(覆盖)| +| `assets/logo/mipmap-xxhdpi/ic_launcher.png` | `app/src/main/res/mipmap-xxhdpi/ic_launcher.png`(覆盖)| +| `assets/logo/mipmap-xxhdpi/ic_launcher_round.png` | `app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png`(覆盖)| +| `assets/logo/mipmap-xxxhdpi/ic_launcher.png` | `app/src/main/res/mipmap-xxxhdpi/ic_launcher.png`(覆盖)| +| `assets/logo/mipmap-xxxhdpi/ic_launcher_round.png` | `app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png`(覆盖)| + +### 1.2 Adaptive icon XML + +把 `assets/android_resources/mipmap-anydpi-v26/` 下的两个 XML 也拷到对应目录: + +| 源文件 | 目标 | +|---|---| +| `ic_launcher.xml` | `app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml` | +| `ic_launcher_round.xml` | `app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml` | + +> Android 8.0+ 会优先用 `` XML。如果项目里已经有同名 XML,**整体覆盖**即可。 + +### 1.3 启动屏资源 + +| 源文件 | 目标 | +|---|---| +| `splash/drawable-mdpi/ic_splash_logo.png` | `app/src/main/res/drawable-mdpi/ic_splash_logo.png` | +| `splash/drawable-hdpi/ic_splash_logo.png` | `app/src/main/res/drawable-hdpi/ic_splash_logo.png` | +| `splash/drawable-xhdpi/ic_splash_logo.png` | `app/src/main/res/drawable-xhdpi/ic_splash_logo.png` | +| `splash/drawable-xxhdpi/ic_splash_logo.png` | `app/src/main/res/drawable-xxhdpi/ic_splash_logo.png` | +| `splash/drawable-xxxhdpi/ic_splash_logo.png` | `app/src/main/res/drawable-xxxhdpi/ic_splash_logo.png` | + +### 1.4 XML 主题 / 颜色 / 字符串 + +| 源文件 | 目标 | +|---|---| +| `android_resources/values/colors_splash.xml` | `app/src/main/res/values/colors_splash.xml` | +| `android_resources/values/strings_splash.xml` | `app/src/main/res/values/strings_splash.xml` | +| `android_resources/values/themes_splash.xml` | `app/src/main/res/values/themes_splash.xml` | +| `android_resources/values/dimens_splash.xml` | `app/src/main/res/values/dimens_splash.xml` | +| `android_resources/drawable/splash_screen.xml` | `app/src/main/res/drawable/splash_screen.xml` | +| `android_resources/layout/activity_splash.xml` | `app/src/main/res/layout/activity_splash.xml` | + +--- + +## 2. 集成步骤 + +### 2.1 加 SplashScreen 依赖 + +`gradle/libs.versions.toml`: + +```toml +[versions] +core-splashscreen = "1.0.1" + +[libraries] +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "core-splashscreen" } +``` + +`app/build.gradle.kts`: + +```kotlin +dependencies { + // ... + implementation(libs.androidx.core.splashscreen) +} +``` + +### 2.2 `AndroidManifest.xml` 改启动主题 + +```xml + + + + android:exported="true"> + ... + + +``` + +或者用 v31 区分: + +```xml + + + + + + + + +``` + +**最简单做法**:在 `MainActivity` 里 **不** 用 `android:theme`,改在 `onCreate` 里用 SplashScreen API 处理。 + +### 2.3 `MainActivity.kt`(SplashScreen API 用法) + +```kotlin +package com.diary.news + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.diary.news.ui.theme.DiaryNewsTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + // 1. 安装 SplashScreen(必须在 super.onCreate 之前) + val splashScreen = installSplashScreen() + // 可选:保持启动屏直到数据加载完成 + // splashScreen.setKeepOnScreenCondition { !viewModel.isReady.value } + + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + DiaryNewsTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AppNav() + } + } + } + } +} +``` + +### 2.4 应用名(`strings.xml`) + +确保 `app/src/main/res/values/strings.xml` 有: + +```xml + + Diary News + +``` + +--- + +## 3. 验证清单 + +装到真机后,逐项过: + +- [ ] **桌面图标**:长按图标 → 看 launcher 上的视觉,是不是米色木质方块 D +- [ ] **不同 launcher 风格**:长按图标 → 改 "Edit" / "Themes",确认 adaptive icon 正常(Pixel Launcher / Nova / 系统自带都试一下) +- [ ] **启动屏**:点图标,启动时先看到木色启动屏 + Diary News 文字,然后进登录页 / Feed +- [ ] **启动屏过渡**:启动屏消失时是否平滑(无白闪 / 黑闪) +- [ ] **暗色模式**:系统切深色 → 启动屏颜色是否仍然温暖(用了固定木色,深色模式不变) +- [ ] **横竖屏切换**:旋转屏幕时启动屏不崩(虽然启动时不会切,但要看 activity 不因配置变更崩) +- [ ] **低版本兼容**:Android 7.0 / 8.0 真机测试启动屏是否正常显示 +- [ ] **多 DPI**:在 Pixel 2 (xhdpi) / Pixel 7 Pro (xxxhdpi) 上看图标清晰度 + +--- + +## 4. 关于"以后用域名访问" + +Android 这边改 IP 为域名,只需要两处: + +1. `app/build.gradle.kts` 里 `buildConfigField` 的 `API_BASE_URL` 改成 `https://你的域名/api/v1/` +2. `app/src/main/res/xml/network_security_config.xml` 删掉 IP 白名单,改成默认 HTTPS 即可(或者保留 +加域名白名单) + +代码不动,业务逻辑零变更。 + +--- + +## 5. 设计说明 + +### 颜色 + +| 角色 | HEX | 来源 | +|---|---|---| +| 木色底 | `#F5E9D0` | 参考图取色 | +| 中木色 | `#E8D4A8` | 渐变中间色 | +| 暗木色 | `#C9A876` | 阴影 / 渐变底部 | +| 字母深棕 | `#3E2A1E` | 字母主体 | +| 木纹线 | `#A8825A` | 半透明纹理 | + +整套配色都是低饱和度暖色,**跟"私人日报 / 日记"的氛围匹配**,和 web 端 `#2080f0` 的蓝色主调形成"冷暖对比",反而有"早晚看新闻"的感觉。 + +### 字体 + +- 当前使用 Arial(Pillow 默认 fallback) +- 真机运行时,SplashScreen 的文字是 SVG/drawable 渲染的,字体以系统为准 +- Compose 里的 `Text()` 可以用 `FontFamily.Serif`(衬线感更接近参考图) + +### 安全区(Adaptive Icon) + +- Android adaptive icon 要求 foreground 主体放中心 **66%**(108x108 中的 72x72 中心区) +- 我们的 `safe_zone=True` 参数自动留了 22% padding,符合规范 +- **不要**给 foreground PNG 加背景色 —— 必须是透明 PNG +- **不要**让主体元素贴边 + +### 启动屏时长 + +- SplashScreen API 的默认时长 ≈ 200ms(很短,基本看不到) +- 我们做了 `setKeepOnScreenCondition` 注释,如果以后想"等数据加载完再消失",取消注释就行 +- **不要**手动 sleep 假装启动屏,Google Play 政策明文禁止 \ No newline at end of file diff --git a/docs/android/assets/_make_logo.py b/docs/android/assets/_make_logo.py new file mode 100644 index 0000000..eb5f2b9 --- /dev/null +++ b/docs/android/assets/_make_logo.py @@ -0,0 +1,289 @@ +"""重做字母 D — 用更干净的 polygon path。""" +from PIL import Image, ImageDraw, ImageFont, ImageChops, ImageFilter +from pathlib import Path + +OUT = Path(r'D:\selftools\diary-news\docs\android\assets') +LOGO = OUT / 'logo' +SPLASH = OUT / 'splash' + +WOOD_LIGHT = (245, 233, 208) +WOOD_MID = (232, 212, 168) +WOOD_DARK = (201, 168, 118) +GRAIN_LINE = (168, 130, 90) +LETTER_DARK = (62, 42, 30) +LETTER_DARKER = (42, 27, 16) + + +def lerp(a, b, t): + return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) + + +def make_wood_gradient(w, h, top, mid, bot): + img = Image.new('RGB', (w, h), top) + px = img.load() + for y in range(h): + t = y / max(1, h - 1) + c = lerp(top, mid, min(1.0, t * 2)) if t < 0.5 else lerp(mid, bot, (t - 0.5) * 2) + for x in range(w): + px[x, y] = c + return img + + +def add_wood_grain(img, spacing=10, opacity=40): + w, h = img.size + overlay = Image.new('RGBA', (w, h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + for y in range(0, h, spacing): + op = max(15, opacity - (y % spacing) * 2) + draw.line([(0, y), (w, y)], fill=(*GRAIN_LINE, op), width=1) + return Image.alpha_composite(img.convert('RGBA'), overlay) + + +def draw_letter_D_simple(canvas_size, box): + """画一个干净的 D — 用 polygon 直接画出 D 的外形。 + D 的几何:左竖条 + 上下凸出半圆(右半)+ 中间挖空。 + """ + img = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + x0, y0, x1, y1 = box + bw, bh = x1 - x0, y1 - y0 + cx = (x0 + x1) // 2 + cy = (y0 + y1) // 2 + + # D 的外轮廓尺寸 + dw = bw * 0.52 + dh = bh * 0.68 + L = int(cx - dw / 2) # 左 + R = int(cx + dw / 2) # 右 + T = int(cy - dh / 2) # 顶 + B = int(cy + dh / 2) # 底 + # 笔画粗细 + bar_w = dw * 0.32 + # 凸出(让 D 顶/底圆润) + bulge = bar_w * 0.35 + + # 1) 画 D 的整体外形:左竖条 + 上下半圆 + 右半椭圆 + # 用 polygon 拼一个 D + D_path = [ + # 左竖条顶 + (L, T + bulge), + # 左竖条底 + (L, B - bulge), + # 底弧起点 + (L + bar_w * 0.4, B), + # 右半圆底部 + (R - bar_w * 0.3, B), + # 右半圆弧最高点(顶部) + (R, cy), + # 右半圆底部 → 已经在 (R - bar_w*0.3, B) + # 顶弧起点 + (L + bar_w * 0.4, T), + ] + # 实际上我们直接用 pieslice 拼 + # 顶半圆:从 (L, T+bulge) 到 (R-bar_w*0.3, T),画 pieslice 180°~360° + # 实际更简单:画三个形状叠起来 + + # a) 左竖条(矩形) + draw.rectangle( + [L, T + bulge * 0.5, L + bar_w, B - bulge * 0.5], + fill=LETTER_DARK, + ) + + # b) 顶半圆(右半,从 180° 到 360°,中心点) + # pieslice 接受 bbox + start/end angle(角度,3 点钟方向=0,逆时针为正) + # Pillow 中 pieslice 是顺时针 0=3 点,90=6 点,180=9 点,270=12 点 + # 我们要画右上 1/4 圆:从 270° 到 360°(即 12 点 → 3 点)不对 + # 重新想:画 D 的右半外轮廓,是一个完整的椭圆右半 + # 顶弧:从 (L+bar_w, T) 弧形向右下到 (R, cy) + # 用 arc 描边粗一些,然后用 chord 实心填充 + + # 直接用 pieslice 实心填充 + ellipse 配合 + # 简化方案: + # - 画一个完整 ellipse fill DARK + # - 再画一个稍小的 ellipse fill 木色(挖空内部) + # - 用矩形覆盖椭圆左半,挖出左边的"竖条" + # 这样视觉上就是 D + + # 整个 D 占的区域 + full_ell = [L, T, R + int(bulge * 0.5), B] + # 让椭圆稍微超出矩形一点,确保右半圆足够圆 + inner_ell = [L + bar_w, T + bar_w * 1.05, R + int(bulge * 0.5) - bar_w * 0.55, B - bar_w * 1.05] + + # 1) 整个外轮廓 fill DARK + draw.ellipse(full_ell, fill=LETTER_DARK) + # 2) 内部挖空(fill 木色,让字母透出底) + draw.ellipse(inner_ell, fill=WOOD_LIGHT) + # 3) 用矩形盖住椭圆左半,形成 D 的竖条 + # 矩形左边到 L+bar_w*0.9,右边到 inner_ell 的左侧+一点 + rect_left = L + rect_right = L + bar_w + (inner_ell[0] - (L + bar_w)) // 2 + 2 + # 让矩形比椭圆略矮,保持椭圆上下凸出 + rect_top = T + bar_w * 0.85 + rect_bot = B - bar_w * 0.85 + # 矩形 fill DARK(竖条) + draw.rectangle([rect_left, rect_top, rect_right, rect_bot], fill=LETTER_DARK) + # 4) 在矩形右侧挖一个米色矩形,让 D 中间真的空出来 + # 计算 D 中间的"肚子"位置 + mid_left = L + bar_w + 4 + mid_right = R - bar_w * 0.4 + mid_top = T + bar_w * 1.1 + mid_bot = B - bar_w * 1.1 + # 用椭圆 fill 米色 覆盖中间的"空腔" + # 但这样会把竖条也覆盖,改成用 polygon + # 实际上 inner_ell 已经挖空了椭圆内部,现在要把"竖条"也挖掉中间一部分 + # 方法:用 ellipse 在竖条右侧挖一个椭圆洞 + draw.ellipse( + [mid_left, mid_top, mid_right, mid_bot], + fill=WOOD_LIGHT, + ) + # 5) 用矩形 cover 椭圆左半(只留右边空腔) + cover_left = mid_left - 5 + cover_right = mid_left + (mid_right - mid_left) * 0.30 + draw.rectangle( + [cover_left, mid_top + (mid_bot - mid_top) * 0.05, + cover_right, mid_bot - (mid_bot - mid_top) * 0.05], + fill=WOOD_LIGHT, + ) + + # 6) 描深色边 + # 左竖条外缘 + draw.line([(L, T + bulge * 0.5), (L, B - bulge * 0.5)], fill=LETTER_DARKER, width=3) + # 顶弧右端 + 右半圆 + 底弧右端 + draw.arc(full_ell, start=270, end=90, fill=LETTER_DARKER, width=3) + + return img + + +def make_block_icon(size, safe_zone=False): + canvas_size = (size, size) + if safe_zone: + pad = int(size * 0.22) + else: + pad = int(size * 0.08) + box = (pad, pad, size - pad, size - pad) + + bw, bh = box[2] - box[0], box[3] - box[1] + wood = make_wood_gradient(bw, bh, WOOD_LIGHT, WOOD_MID, WOOD_DARK) + wood = add_wood_grain(wood, spacing=int(size * 0.025), opacity=50) + + wood_rgba = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) + mask = Image.new('L', (bw, bh), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, bw - 1, bh - 1], radius=int(size * 0.18), fill=255) + wood_rgba.paste(wood, (box[0], box[1]), mask) + + edge = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) + ed = ImageDraw.Draw(edge) + ed.rounded_rectangle([box[0], box[1], box[2] - 1, box[3] - 1], + radius=int(size * 0.18), outline=(107, 79, 48, 200), width=2) + wood_rgba = Image.alpha_composite(wood_rgba, edge) + + shadow = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) + sd = ImageDraw.Draw(shadow) + sd.ellipse([int(size * 0.28), int(size * 0.93), int(size * 0.72), int(size * 1.02)], fill=(0, 0, 0, 50)) + shadow = shadow.filter(ImageFilter.GaussianBlur(4)) + + hl = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) + hd = ImageDraw.Draw(hl) + hd.rounded_rectangle( + [box[0], box[1], box[2] - 1, box[1] + bh // 2], + radius=int(size * 0.18), fill=(255, 255, 255, 35), + ) + + letter = draw_letter_D_simple(canvas_size, box) + + final = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) + final = Image.alpha_composite(final, shadow) + final = Image.alpha_composite(final, wood_rgba) + final = Image.alpha_composite(final, hl) + final = Image.alpha_composite(final, letter) + return final + + +def make_round_icon(size): + canvas = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + wood = make_wood_gradient(size, size, WOOD_LIGHT, WOOD_MID, WOOD_DARK) + wood = add_wood_grain(wood, spacing=int(size * 0.04), opacity=50) + + mask = Image.new('L', (size, size), 0) + ImageDraw.Draw(mask).ellipse([0, 0, size - 1, size - 1], fill=255) + canvas.paste(wood, (0, 0), mask) + + letter = draw_letter_D_simple( + (size, size), + (int(size * 0.20), int(size * 0.20), int(size * 0.80), int(size * 0.80)), + ) + canvas = Image.alpha_composite(canvas, letter) + + hl = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + hd = ImageDraw.Draw(hl) + hd.ellipse([int(size * 0.1), int(size * 0.05), int(size * 0.9), int(size * 0.55)], + fill=(255, 255, 255, 35)) + hl_mask = Image.new('L', (size, size), 0) + ImageDraw.Draw(hl_mask).ellipse([0, 0, size - 1, size - 1], fill=255) + hl.putalpha(ImageChops.multiply(hl.split()[3], hl_mask)) + canvas = Image.alpha_composite(canvas, hl) + return canvas + + +# === 重新生成 === +print('[1] master 1024') +make_block_icon(1024, safe_zone=False).save(LOGO / 'icon_master_1024.png') + +print('[2] adaptive icon 432') +make_block_icon(432, safe_zone=True).save(LOGO / 'ic_launcher_foreground.png') +bg = make_wood_gradient(432, 432, WOOD_LIGHT, WOOD_MID, WOOD_DARK) +bg = add_wood_grain(bg, spacing=12, opacity=30) +bg.save(LOGO / 'ic_launcher_background.png') + +print('[3] launcher icons 5 DPI') +SIZES = {'mdpi': 48, 'hdpi': 72, 'xhdpi': 96, 'xxhdpi': 144, 'xxxhdpi': 192} +icon_master = make_block_icon(1024, safe_zone=False) +for dpi, sz in SIZES.items(): + d = LOGO / f'mipmap-{dpi}' + d.mkdir(exist_ok=True) + icon_master.resize((sz, sz), Image.LANCZOS).save(d / 'ic_launcher.png') + make_round_icon(sz).save(d / 'ic_launcher_round.png') + print(f' {dpi}: {sz}x{sz}') + +print('[4] 启动屏 logo 512') +make_block_icon(512, safe_zone=False).save(SPLASH / 'splash_logo.png') + +print('[5] 启动屏背景 1080x1920') +bg_w, bg_h = 1080, 1920 +bg_full = make_wood_gradient(bg_w, bg_h, WOOD_LIGHT, WOOD_MID, WOOD_DARK) +bg_full = add_wood_grain(bg_full, spacing=20, opacity=35).convert('RGBA') +logo_big = make_block_icon(512, safe_zone=False) +logo_x = (bg_w - 512) // 2 +logo_y = (bg_h - 512) // 2 - 100 +bg_full.alpha_composite(logo_big, (logo_x, logo_y)) + +draw = ImageDraw.Draw(bg_full) +try: + font_big = ImageFont.truetype('arial.ttf', 110) + font_sm = ImageFont.truetype('arial.ttf', 38) +except Exception: + font_big = ImageFont.load_default() + font_sm = ImageFont.load_default() + +name = 'Diary News' +bb = draw.textbbox((0, 0), name, font=font_big) +tw = bb[2] - bb[0] +draw.text(((bg_w - tw) // 2, logo_y + 512 + 80), name, fill=LETTER_DARK, font=font_big) + +sub = 'Your Private News Diary' +bb2 = draw.textbbox((0, 0), sub, font=font_sm) +tw2 = bb2[2] - bb2[0] +draw.text(((bg_w - tw2) // 2, logo_y + 512 + 230), sub, fill=(90, 65, 40), font=font_sm) + +bg_full.save(SPLASH / 'splash_bg_full.png') +print(' OK') + +print('[6] 启动屏各 DPI logo') +SPLASH_SIZES = {'mdpi': 192, 'hdpi': 288, 'xhdpi': 384, 'xxhdpi': 576, 'xxxhdpi': 768} +for dpi, sz in SPLASH_SIZES.items(): + d = SPLASH / f'drawable-{dpi}' + d.mkdir(exist_ok=True, parents=True) + make_block_icon(sz, safe_zone=False).save(d / 'ic_splash_logo.png') + print(f' {dpi}: {sz}x{sz}') + +print('\n=== 完成 ===') \ No newline at end of file diff --git a/docs/android/assets/android_resources/drawable/splash_screen.xml b/docs/android/assets/android_resources/drawable/splash_screen.xml new file mode 100644 index 0000000..ff6e0ea --- /dev/null +++ b/docs/android/assets/android_resources/drawable/splash_screen.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/android/assets/android_resources/layout/activity_splash.xml b/docs/android/assets/android_resources/layout/activity_splash.xml new file mode 100644 index 0000000..666e792 --- /dev/null +++ b/docs/android/assets/android_resources/layout/activity_splash.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/docs/android/assets/android_resources/mipmap-anydpi-v26/ic_launcher.xml b/docs/android/assets/android_resources/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..4ae7d12 --- /dev/null +++ b/docs/android/assets/android_resources/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/docs/android/assets/android_resources/mipmap-anydpi-v26/ic_launcher_round.xml b/docs/android/assets/android_resources/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..4ae7d12 --- /dev/null +++ b/docs/android/assets/android_resources/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/docs/android/assets/android_resources/values/colors_splash.xml b/docs/android/assets/android_resources/values/colors_splash.xml new file mode 100644 index 0000000..a89a51f --- /dev/null +++ b/docs/android/assets/android_resources/values/colors_splash.xml @@ -0,0 +1,7 @@ + + + + #F5E9D0 + #3E2A1E + #5A4128 + \ No newline at end of file diff --git a/docs/android/assets/android_resources/values/dimens_splash.xml b/docs/android/assets/android_resources/values/dimens_splash.xml new file mode 100644 index 0000000..869109a --- /dev/null +++ b/docs/android/assets/android_resources/values/dimens_splash.xml @@ -0,0 +1,5 @@ + + + + 160dp + \ No newline at end of file diff --git a/docs/android/assets/android_resources/values/strings_splash.xml b/docs/android/assets/android_resources/values/strings_splash.xml new file mode 100644 index 0000000..0057caa --- /dev/null +++ b/docs/android/assets/android_resources/values/strings_splash.xml @@ -0,0 +1,6 @@ + + + + Your Private News Diary + 你的私人新闻日报 + \ No newline at end of file diff --git a/docs/android/assets/android_resources/values/themes_splash.xml b/docs/android/assets/android_resources/values/themes_splash.xml new file mode 100644 index 0000000..8eb82f1 --- /dev/null +++ b/docs/android/assets/android_resources/values/themes_splash.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/docs/android/assets/logo.svg b/docs/android/assets/logo.svg new file mode 100644 index 0000000..f579fd9 --- /dev/null +++ b/docs/android/assets/logo.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + D + + + D + + \ No newline at end of file diff --git a/docs/android/assets/logo/ic_launcher_background.png b/docs/android/assets/logo/ic_launcher_background.png new file mode 100644 index 0000000..376ea19 Binary files /dev/null and b/docs/android/assets/logo/ic_launcher_background.png differ diff --git a/docs/android/assets/logo/ic_launcher_foreground.png b/docs/android/assets/logo/ic_launcher_foreground.png new file mode 100644 index 0000000..2ab2eca Binary files /dev/null and b/docs/android/assets/logo/ic_launcher_foreground.png differ diff --git a/docs/android/assets/logo/icon_foreground_1024.png b/docs/android/assets/logo/icon_foreground_1024.png new file mode 100644 index 0000000..94fe1e1 Binary files /dev/null and b/docs/android/assets/logo/icon_foreground_1024.png differ diff --git a/docs/android/assets/logo/icon_master_1024.png b/docs/android/assets/logo/icon_master_1024.png new file mode 100644 index 0000000..0faa079 Binary files /dev/null and b/docs/android/assets/logo/icon_master_1024.png differ diff --git a/docs/android/assets/logo/mipmap-hdpi/ic_launcher.png b/docs/android/assets/logo/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..0de65c9 Binary files /dev/null and b/docs/android/assets/logo/mipmap-hdpi/ic_launcher.png differ diff --git a/docs/android/assets/logo/mipmap-hdpi/ic_launcher_round.png b/docs/android/assets/logo/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..f9e4cca Binary files /dev/null and b/docs/android/assets/logo/mipmap-hdpi/ic_launcher_round.png differ diff --git a/docs/android/assets/logo/mipmap-mdpi/ic_launcher.png b/docs/android/assets/logo/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..7d219aa Binary files /dev/null and b/docs/android/assets/logo/mipmap-mdpi/ic_launcher.png differ diff --git a/docs/android/assets/logo/mipmap-mdpi/ic_launcher_round.png b/docs/android/assets/logo/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..efa38cd Binary files /dev/null and b/docs/android/assets/logo/mipmap-mdpi/ic_launcher_round.png differ diff --git a/docs/android/assets/logo/mipmap-xhdpi/ic_launcher.png b/docs/android/assets/logo/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1df6619 Binary files /dev/null and b/docs/android/assets/logo/mipmap-xhdpi/ic_launcher.png differ diff --git a/docs/android/assets/logo/mipmap-xhdpi/ic_launcher_round.png b/docs/android/assets/logo/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..486f43f Binary files /dev/null and b/docs/android/assets/logo/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/docs/android/assets/logo/mipmap-xxhdpi/ic_launcher.png b/docs/android/assets/logo/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ddd5ebf Binary files /dev/null and b/docs/android/assets/logo/mipmap-xxhdpi/ic_launcher.png differ diff --git a/docs/android/assets/logo/mipmap-xxhdpi/ic_launcher_round.png b/docs/android/assets/logo/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d7df271 Binary files /dev/null and b/docs/android/assets/logo/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/docs/android/assets/logo/mipmap-xxxhdpi/ic_launcher.png b/docs/android/assets/logo/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..27c735d Binary files /dev/null and b/docs/android/assets/logo/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/docs/android/assets/logo/mipmap-xxxhdpi/ic_launcher_round.png b/docs/android/assets/logo/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..628da44 Binary files /dev/null and b/docs/android/assets/logo/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/docs/android/assets/splash/drawable-hdpi/ic_splash_logo.png b/docs/android/assets/splash/drawable-hdpi/ic_splash_logo.png new file mode 100644 index 0000000..bd5b971 Binary files /dev/null and b/docs/android/assets/splash/drawable-hdpi/ic_splash_logo.png differ diff --git a/docs/android/assets/splash/drawable-mdpi/ic_splash_logo.png b/docs/android/assets/splash/drawable-mdpi/ic_splash_logo.png new file mode 100644 index 0000000..deab548 Binary files /dev/null and b/docs/android/assets/splash/drawable-mdpi/ic_splash_logo.png differ diff --git a/docs/android/assets/splash/drawable-xhdpi/ic_splash_logo.png b/docs/android/assets/splash/drawable-xhdpi/ic_splash_logo.png new file mode 100644 index 0000000..add29f7 Binary files /dev/null and b/docs/android/assets/splash/drawable-xhdpi/ic_splash_logo.png differ diff --git a/docs/android/assets/splash/drawable-xxhdpi/ic_splash_logo.png b/docs/android/assets/splash/drawable-xxhdpi/ic_splash_logo.png new file mode 100644 index 0000000..e5fdf9e Binary files /dev/null and b/docs/android/assets/splash/drawable-xxhdpi/ic_splash_logo.png differ diff --git a/docs/android/assets/splash/drawable-xxxhdpi/ic_splash_logo.png b/docs/android/assets/splash/drawable-xxxhdpi/ic_splash_logo.png new file mode 100644 index 0000000..b4a961d Binary files /dev/null and b/docs/android/assets/splash/drawable-xxxhdpi/ic_splash_logo.png differ diff --git a/docs/android/assets/splash/splash_bg_full.png b/docs/android/assets/splash/splash_bg_full.png new file mode 100644 index 0000000..9047976 Binary files /dev/null and b/docs/android/assets/splash/splash_bg_full.png differ diff --git a/docs/android/assets/splash/splash_logo.png b/docs/android/assets/splash/splash_logo.png new file mode 100644 index 0000000..8a70728 Binary files /dev/null and b/docs/android/assets/splash/splash_logo.png differ