build+fix: debug 包独立为 .dev,并修复元数据缓存永远查不到
包名: - debug 使用 com.lxh.qingting_player.dev(应用名"清听 Dev"),可与 release 并存 - release 保持 com.lxh.qingting_player - Android 包名每一段必须以字母开头,因此用 .dev 而非 _dev - AGP 9 需显式开启 buildFeatures.resValues;移除 manifest 中已废弃的 package 属性 元数据(两个独立原因叠加): 1. song_key 之前用 String.hashCode / 本地临时文件 mtime,都不稳定; 2. songs.remote_path 存的是完整 URL,而所有查询都用相对路径(song.id), getSongByPath 永远 miss,UI 只能回退到文件名。 - 新增 utils/song_key.dart:songKeyFor(url,size)=md5(url|size),跨进程稳定 - metadata_service: remote_path 改存 fileId(相对路径),并改用稳定 song_key - audio_service / file_provider: 同步使用稳定键 - 数据库升级 v5:清空 songs / metadata_cache(可再生缓存;歌单与收藏按路径存储,不受影响) - device-check.ps1: 支持 -Package,默认校验 dev 包,启动组件改为向 PackageManager 解析 验证:两次独立 dart 进程 song_key 输出一致;真机 debug/release 并存安装,单实例自检通过。
This commit is contained in:
@@ -42,10 +42,22 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// AGP 9 默认关闭 resValue 生成,这里显式打开(debug 需要 "清听 Dev" 的应用名)
|
||||
buildFeatures {
|
||||
resValues = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// 测试包与正式包并存安装:com.lxh.qingting_player.dev
|
||||
// 说明:Android 包名每一段必须以字母开头,所以没法直接写 "_dev",
|
||||
// 这里用 ".dev" 后缀,等价于给 debug 打上 dev 标记。
|
||||
applicationIdSuffix = ".dev"
|
||||
resValue("string", "app_name", "清听 Dev")
|
||||
}
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
// TODO: 上线前换成正式签名(当前沿用 debug keystore)
|
||||
resValue("string", "app_name", "清听")
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.lxh.qingting_player"> <!-- ⭐ 改这里 -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- package 由 build.gradle.kts 的 namespace / applicationId 决定;AGP 9 已忽略这里的 package 属性 -->
|
||||
<!-- 权限 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
@@ -12,7 +12,7 @@
|
||||
<application
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="清听"
|
||||
android:label="@string/app_name"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<activity
|
||||
|
||||
@@ -27,7 +27,7 @@ class SongDatabase {
|
||||
_log('📂 数据库路径: $path');
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 4, // 升级版本号
|
||||
version: 5, // v5:song_key 改为稳定的 md5
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
@@ -226,6 +226,14 @@ class SongDatabase {
|
||||
await _createPlaybackStateTable(db);
|
||||
}
|
||||
}
|
||||
// ⭐ 升级到版本 5:song_key 由 String.hashCode 改为 md5(url|size|mtime)。
|
||||
// 旧键带进程级随机种子、无法换算;songs / metadata_cache 都是可再生的缓存,
|
||||
// 直接清空,下次播放时按需重建。歌单与收藏按路径(song_id)存储,不受影响。
|
||||
if (oldVersion < 5) {
|
||||
await db.delete('metadata_cache');
|
||||
await db.delete('songs');
|
||||
_log('🧹 已清空 songs / metadata_cache(song_key 算法升级)');
|
||||
}
|
||||
_log('✅ 数据库升级完成');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; // ⭐ 添加
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../services/webdav_service.dart';
|
||||
import '../utils/song_key.dart';
|
||||
|
||||
abstract class FileProvider {
|
||||
Future<File?> getFile(String url);
|
||||
@@ -17,7 +18,7 @@ class WebDAVFileProvider implements FileProvider {
|
||||
try {
|
||||
// 1. 获取缓存目录
|
||||
final cacheDir = await getTemporaryDirectory();
|
||||
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp';
|
||||
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp';
|
||||
final cacheFile = File(cachePath);
|
||||
|
||||
// 2. 如果缓存存在且未过期,直接返回
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'metadata_normalizer.dart';
|
||||
import 'metadata_cache.dart';
|
||||
import 'file_provider.dart';
|
||||
import '../utils/artwork_helper.dart';
|
||||
import '../utils/song_key.dart';
|
||||
|
||||
class MetadataService {
|
||||
static final MetadataService _instance = MetadataService._internal();
|
||||
@@ -20,9 +21,8 @@ class MetadataService {
|
||||
final MetadataCache _memoryCache = MetadataCache();
|
||||
final SongDatabase _db = SongDatabase();
|
||||
|
||||
String _generateSongKey(String url, int fileSize, int modifiedTime) {
|
||||
final raw = '$url|$fileSize|$modifiedTime';
|
||||
return raw.hashCode.toString();
|
||||
String _generateSongKey(String url, int fileSize) {
|
||||
return songKeyFor(url, fileSize);
|
||||
}
|
||||
|
||||
FileProvider _getProvider(String url) {
|
||||
@@ -68,8 +68,7 @@ class MetadataService {
|
||||
}
|
||||
|
||||
final stat = await file.stat();
|
||||
final songKey =
|
||||
_generateSongKey(url, stat.size, stat.modified.millisecondsSinceEpoch);
|
||||
final songKey = _generateSongKey(url, stat.size);
|
||||
|
||||
// 3. SQLite 查询(高置信度直接使用)
|
||||
if (!forceRefresh) {
|
||||
@@ -133,9 +132,12 @@ class MetadataService {
|
||||
}
|
||||
|
||||
// 8. 保存到 SQLite
|
||||
// 注意:remote_path 必须存「应用内的歌曲路径」(即 fileId / song.id),
|
||||
// 不能存完整 URL。playback_controller 与 AudioService 都是用相对路径
|
||||
// 调 getSongByPath() 的,存 URL 会导致永远查不到、元数据看起来“没吃上”。
|
||||
await _db.insertSong({
|
||||
'song_key': songKey,
|
||||
'remote_path': url,
|
||||
'remote_path': fileId,
|
||||
'file_size': stat.size,
|
||||
'modified_time': stat.modified.millisecondsSinceEpoch,
|
||||
'title': finalMetadata.title,
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'playback_service.dart';
|
||||
import '../metadata/metadata_service.dart';
|
||||
import '../database/song_database.dart';
|
||||
import '../utils/artwork_helper.dart';
|
||||
import '../utils/song_key.dart';
|
||||
import '../repositories/playlist_repository.dart';
|
||||
import '../models/playlist.dart';
|
||||
import 'webdav_service.dart';
|
||||
@@ -351,9 +352,8 @@ class AudioService extends ChangeNotifier {
|
||||
return {};
|
||||
}
|
||||
|
||||
String _generateSongKey(String url, int fileSize, int modifiedTime) {
|
||||
final raw = '$url|$fileSize|$modifiedTime';
|
||||
return raw.hashCode.toString();
|
||||
String _generateSongKey(String url, int fileSize) {
|
||||
return songKeyFor(url, fileSize);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
@@ -442,13 +442,12 @@ class AudioService extends ChangeNotifier {
|
||||
final file = await provider.getFile(url);
|
||||
if (file != null && await file.exists()) {
|
||||
final stat = await file.stat();
|
||||
songKey = _generateSongKey(
|
||||
url, stat.size, stat.modified.millisecondsSinceEpoch);
|
||||
songKey = _generateSongKey(url, stat.size);
|
||||
} else {
|
||||
songKey = url.hashCode.toString();
|
||||
songKey = songKeyForUrl(url);
|
||||
}
|
||||
} catch (e) {
|
||||
songKey = url.hashCode.toString();
|
||||
songKey = songKeyForUrl(url);
|
||||
}
|
||||
|
||||
final db = SongDatabase();
|
||||
@@ -461,7 +460,7 @@ class AudioService extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
final cacheDir = await getTemporaryDirectory();
|
||||
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp';
|
||||
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp';
|
||||
final cacheFile = File(cachePath);
|
||||
if (await cacheFile.exists()) {
|
||||
await cacheFile.delete();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// lib/utils/song_key.dart
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// 歌曲缓存键(song_key)。
|
||||
///
|
||||
/// 只使用「远程路径 + 文件大小」这类稳定信息:
|
||||
/// - 不能用 String.hashCode:语义上不保证跨运行稳定(虽然当前 VM 实现是稳定的),
|
||||
/// 用它做持久化键属于依赖未定义行为;
|
||||
/// - 不能用本地临时文件的 mtime:WebDAV 的元数据预览文件会被系统清缓存、
|
||||
/// 或超过 24h 后重新下载,mtime 每次都变,键跟着变 → 缓存永远 miss。
|
||||
///
|
||||
/// 因此这里用 md5(url|size)。文件被替换但大小不变时不会自动失效,
|
||||
/// 由“清除单曲缓存 / forceRefresh”兜底。
|
||||
String songKeyFor(String url, int fileSize) {
|
||||
return md5.convert(utf8.encode('$url|$fileSize')).toString();
|
||||
}
|
||||
|
||||
/// 只依赖 URL 的稳定键,用于临时文件命名、拿不到 stat 的场景。
|
||||
String songKeyForUrl(String url) {
|
||||
return md5.convert(utf8.encode(url)).toString();
|
||||
}
|
||||
+12
-4
@@ -17,13 +17,15 @@
|
||||
param(
|
||||
[string]$DeviceId = '',
|
||||
[string]$Apk = '',
|
||||
[int]$WaitSeconds = 12
|
||||
[int]$WaitSeconds = 12,
|
||||
# 默认校验 debug 包(_dev 后缀),与 release 包并存安装
|
||||
[string]$Package = 'com.lxh.qingting_player.dev'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue' # adb 会往 stderr 写正常提示,用 Stop 会误判
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
if (-not $Apk) { $Apk = Join-Path $root 'build\app\outputs\flutter-apk\app-debug.apk' }
|
||||
$pkg = 'com.lxh.qingting_player'
|
||||
$pkg = $Package
|
||||
|
||||
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
|
||||
function Write-Ok($msg) { Write-Host " OK $msg" -ForegroundColor Green }
|
||||
@@ -82,7 +84,13 @@ for ($attempt = 1; $attempt -le 2; $attempt++) {
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
& adb -s $DeviceId logcat -c
|
||||
& adb -s $DeviceId shell am start -W -n "$pkg/.MainActivity" 2>&1 | Out-Null
|
||||
# 用 MAIN/LAUNCHER 意图启动:applicationId 带 .dev 后缀时,
|
||||
# "am start -n $pkg/.MainActivity" 会把 .MainActivity 拼到 .dev 上,导致找不到类。
|
||||
# 解析真正的启动组件再启动:applicationId 带 .dev 后缀时,
|
||||
# "$pkg/.MainActivity" 会把 .MainActivity 拼到 .dev 上导致找不到类;
|
||||
# 而 "am start -p $pkg" 在部分 ROM 上又解析不到,所以直接问 PackageManager。
|
||||
$component = (& adb -s $DeviceId shell cmd package resolve-activity --brief $pkg 2>&1 | Select-Object -Last 1).Trim()
|
||||
& adb -s $DeviceId shell am start -W -n $component 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds $WaitSeconds
|
||||
& adb -s $DeviceId logcat -d -v time > $logPath
|
||||
# 注意:PS 5.1 的 > 重定向写出的是 UTF-16LE(带 BOM),读取时不要强制 utf8
|
||||
@@ -106,7 +114,7 @@ if ($players.Count -eq 1) { Write-Ok "Player 唯一:$($players[0])" } else { W
|
||||
# ---------- 系统侧 ----------
|
||||
Write-Step '系统 MediaSession'
|
||||
$sessionIds = @(& adb -s $DeviceId shell dumpsys media_session 2>&1 |
|
||||
Select-String 'media-session com\.lxh\.qingting_player/media-session/(\d+)' |
|
||||
Select-String ("media-session " + [regex]::Escape($pkg) + "/media-session/(\d+)") |
|
||||
ForEach-Object { $_.Matches[0].Groups[1].Value } | Sort-Object -Unique)
|
||||
if ($sessionIds.Count -eq 1) { Write-Ok "MediaSession 唯一:$($sessionIds[0])" }
|
||||
elseif ($sessionIds.Count -eq 0) { Write-Host ' -- 还没有 MediaSession(未开始播放,属正常)' -ForegroundColor DarkGray }
|
||||
|
||||
Reference in New Issue
Block a user