docs(android): 完整方案 + logo 资源 + 启动屏

新增 docs/android/ 目录:
- README.md 总入口(快速上手 + 决策摘要 + 数据流)
- 01-architecture.md 模块划分 + 数据流 + 选型理由
- 02-api-contract.md 每个接口的请求/响应 + DTO 字段映射
- 03-build-run.md Gradle/SDK/网络安全白名单/真机调试
- 04-milestones.md 7 天里程碑 + DoD + E2E 测试场景

新增 assets/:
- logo/: 主图标 master + adaptive icon + 5 DPI launcher (方/圆)
- splash/: 启动屏 logo + 完整背景预览 + 5 DPI 资源
- android_resources/: 集成所需的 XML(adaptive icon/主题/颜色/字符串/drawable/layout)
- INTEGRATION.md 集成指南
- logo.svg + _make_logo.py 设计源

设计风格:参考用户提供的木质方块字母积木图,米色木纹底 +
深棕色字母 D,代表 'Diary',温暖私人日记感。

服务器体检:所有容器/API/DB/翻译主链路正常,TMT 本月已用 0.37%。
MaaS 备用通道上次已验证可用。
This commit is contained in:
Mavis
2026-06-10 14:11:43 +08:00
parent 81c83ced8d
commit 02f0260dfc
37 changed files with 2750 additions and 0 deletions

View File

@@ -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<PagingData<Article>>
└─ 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<ArticleUiState>
└─ 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<AuthEvent.LoggedIn>
└─ MainActivity 监听 → 导航到 Feed
```
### 2.2 异常 / 401 处理
```
ApiService 任意调用 → OkHttp chain
├─ AuthInterceptor: 加 Authorization: Bearer <access>
└─ 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<List<BookmarkCacheEntity>>
@Query("SELECT articleId FROM bookmarks_cache WHERE userId = :userId")
suspend fun ids(userId: Long): List<Long>
@Insert(onConflict = REPLACE)
suspend fun upsertAll(items: List<BookmarkCacheEntity>)
@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
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">207.57.129.228</domain>
</domain-config>
</network-security-config>
```
**为什么是白名单而不是全开 `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<userId, tokenPair>` | 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 天 |

View File

@@ -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<ArticleListItemDto>,
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": "<p>...</p>",
"body_text": "Iran attacked Bahrain...",
"title_zh": "伊朗袭击巴林...",
"body_zh_html": "<p>...</p>",
"body_zh_text": "伊朗袭击巴林和约旦...",
"body_zh_formatted": "<div class='diary-para'>...</div>",
"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<String>? = 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<String>? = null,
)
@Serializable
data class SubscriptionDto(
val id: Long,
@SerialName("source_id") val sourceId: Long,
@SerialName("category_filter") val categoryFilter: List<String>? = 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<String>) : 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<String>, // 已拆分逗号分隔
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<String, List<String>>?,
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 不碰。

View File

@@ -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
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".DiaryNewsApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DiaryNews"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="35"
xmlns:tools="http://schemas.android.com/tools">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.DiaryNews">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
```
### 4.2 `app/src/main/res/xml/network_security_config.xml`
新建文件:
```xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- 默认所有域走系统 HTTPS(拒绝明文)-->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- 例外:只对后端 server 放行明文 HTTP -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">207.57.129.228</domain>
</domain-config>
</network-security-config>
```
### 4.3 `app/src/main/res/xml/backup_rules.xml`(禁止备份 token)
```xml
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="auth_prefs.xml" />
</full-backup-content>
```
### 4.4 `app/src/main/res/xml/data_extraction_rules.xml`(Android 12+)
```xml
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="auth_prefs.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="auth_prefs.xml" />
</device-transfer>
</data-extraction-rules>
```
---
## 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 装手机上跑就够了。

View File

@@ -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<UiState>`)| `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 个有效工作日 |

179
docs/android/README.md Normal file
View File

@@ -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 <access>
→ 返回 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) 看会不会破坏现有分层。

View File

@@ -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+ 会优先用 `<adaptive-icon>` 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
<application
android:name=".DiaryNewsApp"
android:theme="@style/Theme.App.Starting"
...>
<activity
android:name=".MainActivity"
android:theme="@style/Theme.App.Starting.Legacy" <!-- Android 11 -->
android:exported="true">
...
</activity>
</application>
```
或者用 v31 区分:
```xml
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="false"
android:theme="@style/Theme.App.Starting.Legacy" /> <!-- 用一个 launcher alias 兼容低版本 -->
```
**最简单做法**:在 `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
<resources>
<string name="app_name">Diary News</string>
</resources>
```
---
## 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 政策明文禁止

View File

@@ -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=== 完成 ===')

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
启动屏背景 drawable(用于 Android 11 及以下的 windowBackground)
- 米色木纹底
- 中央 logo + app 名
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 底层:木色背景 -->
<item>
<shape android:shape="rectangle">
<gradient
android:type="linear"
android:angle="90"
android:startColor="#F5E9D0"
android:centerColor="#E8D4A8"
android:endColor="#C9A876" />
</shape>
</item>
<!-- 中央 logo 图标(用 bitmap 引用,需要把 splash_logo.png 放进来)-->
<!-- 实际项目中,把生成好的 splash_logo.png 放到 drawable-xxhdpi/ 等目录 -->
<item android:gravity="center" android:top="-40dp">
<bitmap
android:src="@drawable/ic_splash_logo"
android:gravity="center" />
</item>
</layer-list>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
启动屏 layout(SplashScreen API 12+ 用)
- 背景 = 木色
- 中心 = logo + app 名 + 副标题
- 不依赖任何外部依赖,纯原生
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:background="@color/splash_background">
<ImageView
android:id="@+id/splash_logo"
android:layout_width="@dimen/splash_logo_size"
android:layout_height="@dimen/splash_logo_size"
android:src="@drawable/ic_splash_logo"
android:contentDescription="@string/app_name"
android:layout_marginBottom="24dp" />
<TextView
android:id="@+id/splash_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="@color/splash_letter"
android:textSize="40sp"
android:textStyle="bold"
android:fontFamily="sans-serif" />
<TextView
android:id="@+id/splash_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/splash_subtitle"
android:textColor="@color/splash_subtitle"
android:textSize="16sp"
android:layout_marginTop="8dp"
android:fontFamily="sans-serif" />
</LinearLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 木质积木色调,跟 logo 一致 -->
<color name="splash_background">#F5E9D0</color>
<color name="splash_letter">#3E2A1E</color>
<color name="splash_subtitle">#5A4128</color>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 启动屏 logo 尺寸(根据屏宽自适应,这里用 dp)-->
<dimen name="splash_logo_size">160dp</dimen>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 启动屏要用的副标题(中英两版都备一份)-->
<string name="splash_subtitle">Your Private News Diary</string>
<string name="splash_subtitle_zh">你的私人新闻日报</string>
</resources>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SplashScreen API(Android 12+, API 31+)主题
用 Theme.SplashScreen 库,需要在 build.gradle 依赖 androidx.core:core-splashscreen
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Android 12+ 启动屏主题(API 31+)-->
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
<item name="windowSplashScreenAnimationDuration">200</item>
<item name="postSplashScreenTheme">@style/Theme.DiaryNews</item>
</style>
<!-- Android 11 及以下启动屏主题(API < 31)— 用自定义 drawable 作 background-->
<style name="Theme.App.Starting.Legacy" parent="Theme.DiaryNews">
<item name="android:windowBackground">@drawable/splash_screen</item>
<item name="android:statusBarColor">@color/splash_background</item>
<item name="android:navigationBarColor">@color/splash_background</item>
</style>
</resources>

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Diary News — App Logo
风格:木质方块字母积木(参考用户提供的图)
设计:单个大方块 + 字母 "D",代表 "Diary"
颜色:米色底 (#F5E9D0) + 深棕字母 (#3E2A1E) + 浅色高光
用途:Android adaptive icon foreground (108x108dp, 安全区 66dp)
-->
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 108 108"
width="108" height="108">
<defs>
<!-- 木纹渐变(米色基调,顶部亮、底部暗)-->
<linearGradient id="woodBase" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#F5E9D0"/>
<stop offset="50%" stop-color="#E8D4A8"/>
<stop offset="100%" stop-color="#C9A876"/>
</linearGradient>
<!-- 木纹纹理(横向纹理线)-->
<pattern id="grain" patternUnits="userSpaceOnUse" width="108" height="3">
<rect width="108" height="3" fill="url(#woodBase)"/>
<line x1="0" y1="1.5" x2="108" y2="1.5" stroke="#A8825A" stroke-width="0.3" opacity="0.3"/>
</pattern>
<!-- 字母 D 的木色(比底色更深的实木色)-->
<linearGradient id="letterWood" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#5C3D24"/>
<stop offset="100%" stop-color="#3E2A1E"/>
</linearGradient>
<!-- 顶面(轻微高光)-->
<linearGradient id="topHighlight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<!-- 侧面(更暗,营造立体感)-->
<linearGradient id="sideShadow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8B6B45"/>
<stop offset="100%" stop-color="#6B4F30"/>
</linearGradient>
<!-- 圆角遮罩 -->
<clipPath id="blockShape">
<rect x="14" y="14" width="80" height="80" rx="14" ry="14"/>
</clipPath>
</defs>
<!-- 整组外发光阴影(模拟方块落在桌面)-->
<ellipse cx="54" cy="92" rx="32" ry="3" fill="#000000" opacity="0.15"/>
<!-- 方块主体(底面/侧面/正面三层)-->
<!-- 底面(从左下到右下的厚度)-->
<path d="M 14 88 L 14 94 Q 14 98 18 98 L 90 98 Q 94 98 94 94 L 94 88 Z"
fill="url(#sideShadow)" opacity="0.6"/>
<!-- 正面 -->
<rect x="14" y="14" width="80" height="80" rx="14" ry="14"
fill="url(#grain)"/>
<!-- 木纹细节:几条横向纹路 -->
<g clip-path="url(#blockShape)" opacity="0.4">
<line x1="14" y1="28" x2="94" y2="28" stroke="#A8825A" stroke-width="0.4"/>
<line x1="14" y1="38" x2="94" y2="38" stroke="#A8825A" stroke-width="0.3"/>
<line x1="14" y1="54" x2="94" y2="54" stroke="#A8825A" stroke-width="0.5"/>
<line x1="14" y1="68" x2="94" y2="68" stroke="#A8825A" stroke-width="0.3"/>
<line x1="14" y1="82" x2="94" y2="82" stroke="#A8825A" stroke-width="0.4"/>
</g>
<!-- 圆角描边(加深轮廓)-->
<rect x="14" y="14" width="80" height="80" rx="14" ry="14"
fill="none" stroke="#6B4F30" stroke-width="1.5" opacity="0.5"/>
<!-- 顶面高光(轻微内阴影 + 顶部反光)-->
<rect x="14" y="14" width="80" height="40" rx="14" ry="14"
fill="url(#topHighlight)" opacity="0.6"/>
<!-- 字母 "D" — 用粗体衬线感字体 -->
<text x="54" y="74"
font-family="Georgia, 'Times New Roman', serif"
font-size="58"
font-weight="900"
text-anchor="middle"
fill="url(#letterWood)"
stroke="#2A1B10"
stroke-width="0.8">D</text>
<!-- 字母上的高光(让字母也有木纹感)-->
<text x="54" y="74"
font-family="Georgia, 'Times New Roman', serif"
font-size="58"
font-weight="900"
text-anchor="middle"
fill="#FFFFFF"
opacity="0.08">D</text>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB