diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 2d9b6f9..1e9dc8b 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -61,6 +61,13 @@ jobs:
set -o pipefail
./gradlew lintDebug --console=plain 2>&1 | tee ../gradle-lint.log
+ - name: Kotlin 格式检查(ktlint)
+ run: |
+ set -o pipefail
+ curl -sSLo /tmp/ktlint https://github.com/pinterest/ktlint/releases/download/1.5.0/ktlint
+ chmod +x /tmp/ktlint
+ /tmp/ktlint --relative "app/src/**/*.kt" 2>&1 | tee ../gradle-ktlint.log
+
- name: 构建 Debug APK
run: |
set -o pipefail
diff --git a/.gitignore b/.gitignore
index ace0093..36bd150 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,7 @@ pnpm-debug.log*
*.pem
*.key
*private-key*
+*.p12
+*.jks
+*.keystore
+/android/keystore
diff --git a/android/.editorconfig b/android/.editorconfig
new file mode 100644
index 0000000..dcd07cf
--- /dev/null
+++ b/android/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*.{kt,kts}]
+# 与 Kotlin 官方风格一致;行宽交给 IDE 排版,不在 CI 卡 140 列
+ktlint_code_style = intellij_idea
+max_line_length = off
+# Compose 组件按约定使用 PascalCase;单表达式函数强制写回一行也与现有风格冲突
+ktlint_standard_function-naming = disabled
+ktlint_standard_function-signature = disabled
diff --git a/android/README.md b/android/README.md
index 2ff5cc4..08defe6 100644
--- a/android/README.md
+++ b/android/README.md
@@ -78,20 +78,60 @@ PR 与 push 到 `main` / `master` 时触发,仅在 `android/**` 有变更时
```
app/src/main/java/dev/amll/hub/android/
-├── MainActivity.kt # 入口,处理 amllhub:// deep link
+├── MainActivity.kt # 入口,处理 amllhub:// deep link
├── data/
-│ ├── local/AuthStore.kt # DataStore 存 token
-│ └── remote/ # Retrofit API、DTO、错误映射
-├── di/AppModule.kt # Hilt 模块
-├── nav/AmllNavGraph.kt # 登录 / 首页 / 扫码
+│ ├── local/AuthStore.kt # DataStore 存 token
+│ ├── lyric/TtmlParser.kt # TTML → 逐字行(翻译 / 音译 / 背景行)
+│ ├── remote/ # Retrofit API、DTO、错误映射
+│ └── repo/ContentRepository.kt# 内容仓库,接口失败降级为空
+├── playback/
+│ ├── PlayerController.kt # Media3 ExoPlayer + 播放状态 StateFlow
+│ └── PlaybackService.kt # MediaSessionService,后台播放与通知栏
+├── di/AppModule.kt # Hilt 模块
+├── nav/AmllNavGraph.kt # 登录态 → 5 Tab + 二级页 + 启动页
└── ui/
- ├── component/ # QrScannerView、ErrorBanner
- ├── screen/ # Login / Home / Scan + ViewModel
- └── theme/ # 颜色与字体,对齐网页端 token
+ ├── AppNav.kt # 页面跳转集合
+ ├── AppViewModel.kt # Activity 级共享状态(首页/榜单/歌词/播放)
+ ├── component/Common.kt # TopBar / PillTabBar / SongRow / EmptyState…
+ ├── screen/
+ │ ├── MainScreens.kt # 首页 / 发现 / 排行榜 / 每日推荐 / 消息 / 我的
+ │ └── FlowScreens.kt # 歌词详情 / 沉浸播放 / 创作 / 投稿 / 稿件 / 审核
+ └── theme/ # Palette(6 组语义色)+ Dimens(1:1 token)
```
+## 沉浸播放的歌词页(react-full)
+
+全屏沉浸播放用 WebView 承载网页端同款歌词渲染(`@applemusic-like-lyrics/react-full` 的
+`PrebuiltLyricPlayer` + jotai atoms),源码在 `android/lyrics-web/`,构建产物直接落到
+`app/src/main/assets/lyrics/`,APK 内离线可用。
+
+```bash
+cd android/lyrics-web
+
+# 复用 frontend 已装好的依赖(避免重复下载)
+# Windows: New-Item -ItemType Junction -Path node_modules -Target ../../frontend/node_modules
+# macOS/Linux: ln -s ../../frontend/node_modules node_modules
+
+node ../../frontend/node_modules/vite/bin/vite.js build
+```
+
+原生侧通过 `WebViewAssetLoader` 以 `https://appassets.androidplatform.net/assets/lyrics/index.html`
+加载,避免 `file://` 下 ES Module 的跨域限制;进度与播放态由 `PlayerController` 通过
+`window.amllNative` 写入。
+
## 待办
-- 个人中心(资料编辑、头像上传、密码修改)
-- 投稿与歌词浏览
+- 游客态浏览:当前沿用登录门禁,未登录只显示登录页
+- 投稿提交:表单已还原,但 TTML 解析与文件上传未接,提交按钮暂未落库
+- 头像上传、审核操作(通过 / 驳回)、每日推荐「下载图片」
+- 稿件详情的「原文件 / 文件效果」对比视图
- 正式签名配置(当前 release 走 debug key)
+
+## 已接通的交互
+
+- 首页 / 发现的搜索框 → 半屏搜索弹层(词库 `/api/v1/search` + 网易云 `/api/v1/ncm/search`)
+- 榜单 / 最新收录 / 推荐卡 → 歌词详情;封面点按 → WebView 沉浸播放
+- 每日推荐点赞(乐观更新 + 失败回滚)、消息全部已读 / 单条已读
+- 我的页读 `/api/v1/auth/profile`;编辑资料保存昵称走 `PUT /api/v1/auth/profile`
+- 创作中心稿件列表 → 稿件详情(`GET /api/v1/submissions/{id}`)
+
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index cb4ddc4..0c5d084 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -9,24 +9,36 @@ plugins {
alias(libs.plugins.ksp)
}
-// 从 local.properties 或环境变量读 API Base URL,缺失时回落到 beta 线上后端
-val apiBaseUrl: String = run {
- val fromLocal = rootProject.file("local.properties")
- .takeIf { it.exists() }
- ?.let { file -> Properties().apply { file.inputStream().use(::load) }.getProperty("amll.api.baseUrl") }
- val fromEnv = System.getenv("AMLL_API_BASE_URL")
- fromLocal?.takeIf { it.isNotBlank() }
- ?: fromEnv?.takeIf { it.isNotBlank() }
- ?: "https://api.beta.amllhub.bikonoo.com"
-}
+// local.properties 不进版本库(CI 用环境变量),两项都缺时才回落到 beta 后端
+val localProperties: Properties = rootProject.file("local.properties")
+ .takeIf { it.exists() }
+ ?.let { file -> Properties().apply { file.inputStream().use(::load) } }
+ ?: Properties()
+
+fun localOrEnv(key: String, env: String): String? =
+ localProperties.getProperty(key)?.takeIf { it.isNotBlank() }
+ ?: System.getenv(env)?.takeIf { it.isNotBlank() }
+
+val apiBaseUrl: String = localOrEnv("amll.api.baseUrl", "AMLL_API_BASE_URL")
+ ?: "https://api.beta.amllhub.bikonoo.com"
+
+// 发布签名:四项齐全才启用正式 keystore,否则 release 继续用 debug key 兜底
+val releaseStoreFile = localOrEnv("amll.release.storeFile", "AMLL_RELEASE_STORE_FILE")
+val releaseStorePassword = localOrEnv("amll.release.storePassword", "AMLL_RELEASE_STORE_PASSWORD")
+val releaseKeyAlias = localOrEnv("amll.release.keyAlias", "AMLL_RELEASE_KEY_ALIAS")
+val releaseKeyPassword = localOrEnv("amll.release.keyPassword", "AMLL_RELEASE_KEY_PASSWORD")
+val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword)
+ .all { !it.isNullOrBlank() }
android {
+ // namespace 是代码包名(R / BuildConfig 的包路径),与安装包标识无关,
+ // 这里保持不动;对外显示的包名是下面的 applicationId
namespace = "dev.amll.hub.android"
// 依赖(Coil 3.6 / Compose 1.12 / Navigation 2.10 等)要求 compileSdk 37
compileSdk = 37
defaultConfig {
- applicationId = "dev.amll.hub.android"
+ applicationId = "com.amlldev.amllhub"
minSdk = 26
// targetSdk 与 compileSdk 独立:这里不跟随升级,
// 避免在验证编译阶段同时引入 Android 17 的运行时行为变更
@@ -38,18 +50,35 @@ android {
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
}
+ signingConfigs {
+ if (hasReleaseSigning) {
+ create("release") {
+ storeFile = rootProject.file(releaseStoreFile!!)
+ storePassword = releaseStorePassword
+ keyAlias = releaseKeyAlias
+ keyPassword = releaseKeyPassword
+ storeType = "PKCS12"
+ }
+ }
+ }
+
buildTypes {
debug {
isMinifyEnabled = false
- applicationIdSuffix = ".debug"
+ // 不加 applicationIdSuffix:debug 包也用 com.amlldev.amllhub,
+ // 免得装出来的包名和 release 不一致
versionNameSuffix = "-debug"
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
- // 未配置签名时用debug key,保证 assembleRelease 也能跑通(CI 只出 debug 包)
- signingConfig = signingConfigs.getByName("debug")
+ // 配置齐全时用正式 keystore;否则用 debug key 兜底,保证 assembleRelease 也能跑通
+ signingConfig = if (hasReleaseSigning) {
+ signingConfigs.getByName("release")
+ } else {
+ signingConfigs.getByName("debug")
+ }
}
}
@@ -85,6 +114,9 @@ kotlin {
dependencies {
implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.core.splashscreen)
+ // XML 主题(Theme.Material3.DayNight.NoActionBar)
+ implementation(libs.material)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
@@ -94,6 +126,7 @@ dependencies {
// Compose(版本由 BOM 统一管理)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
+ implementation(libs.androidx.animation)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
@@ -102,6 +135,16 @@ dependencies {
implementation(libs.androidx.navigation.compose)
+ // 播放(Media3)与内嵌歌词页
+ implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.androidx.media3.session)
+ implementation(libs.androidx.webkit)
+
+ // 下载标签:MP3(mp3agic)/ FLAC(jaudiotagger)
+ implementation(libs.mp3agic)
+ implementation(libs.jaudiotagger)
+ implementation(libs.slf4j.nop)
+
// DI
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
@@ -121,6 +164,10 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
+ // 底栏 / 播放条的实时背景模糊
+ implementation(libs.haze.core)
+ implementation(libs.haze.blur)
+
// 扫码
implementation(libs.mlkit.barcode.scanning)
implementation(libs.camera.camera2)
diff --git a/android/app/src/debug/res/xml/network_security_config.xml b/android/app/src/debug/res/xml/network_security_config.xml
new file mode 100644
index 0000000..ad043b9
--- /dev/null
+++ b/android/app/src/debug/res/xml/network_security_config.xml
@@ -0,0 +1,14 @@
+
+
Pt?(Xt=At,At=null):Xt=At.sibling;var ee=it(Z,At,et[Pt],ut);if(ee===null){At===null&&(At=Xt);break}s&&At&&ee.alternate===null&&r(Z,At),$=y(ee,$,Pt),te===null?Et=ee:te.sibling=ee,te=ee,At=Xt}if(Pt===et.length)return l(Z,At),Yt&&Pi(Z,Pt),Et;if(At===null){for(;Pt