mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-13 20:42:10 +02:00
fix: truncate SAF filenames and directory segments safely at UTF-8 boundaries
Long track names (especially CJK/emoji) could exceed filesystem limits when used as SAF document display names, causing write failures. - Add truncateUtf8Bytes in Go, Kotlin (MainActivity + SafDownloadHandler), and Dart to truncate strings at valid UTF-8 codepoint boundaries - Limit SAF filenames to 180 UTF-8 bytes (preserving file extension) - Limit SAF directory segments to 120 UTF-8 bytes - Fix Go sanitizeFilename to use UTF-8 aware truncation instead of byte slice which could split multi-byte characters - Add Go test for multi-byte truncation correctness - Sanitize SAF relative directory in Dart native worker and regular download paths via _sanitizeSafRelativeDir
This commit is contained in:
@@ -43,6 +43,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"com.zarz.spotiflac/library_scan_progress_stream"
|
||||
private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L
|
||||
private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L
|
||||
private val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
||||
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
@@ -329,9 +330,47 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
.replace(Regex("_+"), "_")
|
||||
.trim('_', ' ')
|
||||
|
||||
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
|
||||
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
|
||||
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||
}
|
||||
|
||||
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
|
||||
|
||||
val dotIndex = name.lastIndexOf('.')
|
||||
val ext = if (
|
||||
dotIndex > 0 &&
|
||||
dotIndex < name.length - 1 &&
|
||||
name.length - dotIndex <= 10
|
||||
) {
|
||||
name.substring(dotIndex)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
|
||||
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
|
||||
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
|
||||
}
|
||||
|
||||
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
|
||||
|
||||
val builder = StringBuilder()
|
||||
var usedBytes = 0
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
val codePoint = value.codePointAt(index)
|
||||
val char = String(Character.toChars(codePoint))
|
||||
val charBytes = char.toByteArray(Charsets.UTF_8).size
|
||||
if (usedBytes + charBytes > maxBytes) break
|
||||
builder.append(char)
|
||||
usedBytes += charBytes
|
||||
index += Character.charCount(codePoint)
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.util.Locale
|
||||
*/
|
||||
object SafDownloadHandler {
|
||||
private val safDirLock = Any()
|
||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
|
||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
@@ -321,9 +322,47 @@ object SafDownloadHandler {
|
||||
.replace(Regex("_+"), "_")
|
||||
.trim('_', ' ')
|
||||
|
||||
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
|
||||
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
|
||||
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||
}
|
||||
|
||||
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
|
||||
|
||||
val dotIndex = name.lastIndexOf('.')
|
||||
val ext = if (
|
||||
dotIndex > 0 &&
|
||||
dotIndex < name.length - 1 &&
|
||||
name.length - dotIndex <= 10
|
||||
) {
|
||||
name.substring(dotIndex)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
|
||||
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
|
||||
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
|
||||
}
|
||||
|
||||
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
|
||||
|
||||
val builder = StringBuilder()
|
||||
var usedBytes = 0
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
val codePoint = value.codePointAt(index)
|
||||
val char = String(Character.toChars(codePoint))
|
||||
val charBytes = char.toByteArray(Charsets.UTF_8).size
|
||||
if (usedBytes + charBytes > maxBytes) break
|
||||
builder.append(char)
|
||||
usedBytes += charBytes
|
||||
index += Character.charCount(codePoint)
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
|
||||
+20
-1
@@ -48,7 +48,7 @@ func sanitizeFilename(filename string) string {
|
||||
}
|
||||
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = sanitized[:200]
|
||||
sanitized = truncateUTF8Bytes(sanitized, 200)
|
||||
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
}
|
||||
@@ -60,6 +60,25 @@ func sanitizeFilename(filename string) string {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func truncateUTF8Bytes(value string, maxBytes int) string {
|
||||
if maxBytes <= 0 || len(value) <= maxBytes {
|
||||
return value
|
||||
}
|
||||
|
||||
used := 0
|
||||
for i, r := range value {
|
||||
runeLen := utf8.RuneLen(r)
|
||||
if runeLen < 0 {
|
||||
runeLen = len(string(r))
|
||||
}
|
||||
if used+runeLen > maxBytes {
|
||||
return value[:i]
|
||||
}
|
||||
used += runeLen
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||
if template == "" {
|
||||
template = "{artist} - {title}"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
@@ -98,3 +102,13 @@ func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
|
||||
t.Fatalf("expected %q, got %q", "Unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilenameTruncatesWithoutSplittingUTF8(t *testing.T) {
|
||||
got := sanitizeFilename(strings.Repeat("あ", 80))
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("sanitizeFilename returned invalid UTF-8: %q", got)
|
||||
}
|
||||
if len(got) > 200 {
|
||||
t.Fatalf("sanitizeFilename length = %d, want <= 200", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ double _log10(num x) => log(x) / ln10;
|
||||
final _yearRegex = RegExp(r'^(\d{4})');
|
||||
const _defaultOutputFolderName = 'SpotiFLAC';
|
||||
const _defaultAndroidMusicSubpath = 'Music/$_defaultOutputFolderName';
|
||||
const _maxSafFilenameUtf8Bytes = 180;
|
||||
const _maxSafDirSegmentUtf8Bytes = 120;
|
||||
|
||||
class DownloadHistoryItem {
|
||||
final String id;
|
||||
@@ -2311,6 +2313,55 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
String _truncateUtf8Bytes(String value, int maxBytes) {
|
||||
if (maxBytes <= 0 || utf8.encode(value).length <= maxBytes) {
|
||||
return value;
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
var usedBytes = 0;
|
||||
for (final rune in value.runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
final charBytes = utf8.encode(char).length;
|
||||
if (usedBytes + charBytes > maxBytes) break;
|
||||
buffer.write(char);
|
||||
usedBytes += charBytes;
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _trimSafeName(String value) {
|
||||
var trimmed = value.trim();
|
||||
trimmed = trimmed.replaceAll(_trimDotsAndSpacesRegex, '');
|
||||
trimmed = trimmed.replaceAll(_trimUnderscoresAndSpacesRegex, '');
|
||||
return trimmed.isEmpty ? 'Unknown' : trimmed;
|
||||
}
|
||||
|
||||
String _sanitizeSafRelativeDir(String relativeDir) {
|
||||
if (relativeDir.trim().isEmpty) return '';
|
||||
final parts = relativeDir
|
||||
.split('/')
|
||||
.map(_sanitizeFolderName)
|
||||
.map((part) {
|
||||
final truncated = _truncateUtf8Bytes(
|
||||
part,
|
||||
_maxSafDirSegmentUtf8Bytes,
|
||||
);
|
||||
return _trimSafeName(truncated);
|
||||
})
|
||||
.where((part) => part.isNotEmpty && part != '.' && part != '..')
|
||||
.toList(growable: false);
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
Future<String> _buildSafFileName(String baseName, String outputExt) async {
|
||||
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
|
||||
final extBytes = utf8.encode(outputExt).length;
|
||||
final maxBaseBytes = max(1, _maxSafFilenameUtf8Bytes - extBytes);
|
||||
final truncated = _truncateUtf8Bytes(sanitized, maxBaseBytes);
|
||||
return '${_trimSafeName(truncated)}$outputExt';
|
||||
}
|
||||
|
||||
static final _featuredArtistPattern = RegExp(
|
||||
r'\s*[,;]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
|
||||
caseSensitive: false,
|
||||
@@ -4771,7 +4822,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (quality == 'DEFAULT') quality = state.audioQuality;
|
||||
|
||||
final isSafMode = _isSafMode(settings);
|
||||
final outputDir = isSafMode
|
||||
final rawOutputDir = isSafMode
|
||||
? await _buildRelativeOutputDir(
|
||||
item.track,
|
||||
settings.folderOrganization,
|
||||
@@ -4796,6 +4847,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
settings.filterContributingArtistsInAlbumArtist,
|
||||
playlistName: item.playlistName,
|
||||
);
|
||||
final outputDir = isSafMode
|
||||
? _sanitizeSafRelativeDir(rawOutputDir)
|
||||
: rawOutputDir;
|
||||
if (!isSafMode) {
|
||||
await _ensureDirExists(outputDir, label: 'Output folder');
|
||||
}
|
||||
@@ -4822,8 +4876,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'year': _extractYear(item.track.releaseDate) ?? '',
|
||||
'date': item.track.releaseDate ?? '',
|
||||
});
|
||||
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
|
||||
safFileName = '$sanitized$safOutputExt';
|
||||
safFileName = await _buildSafFileName(baseName, safOutputExt);
|
||||
}
|
||||
|
||||
var trackForPayload = item.track;
|
||||
@@ -6240,7 +6293,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
settings.filterContributingArtistsInAlbumArtist,
|
||||
playlistName: item.playlistName,
|
||||
);
|
||||
var effectiveOutputDir = initialOutputDir;
|
||||
var effectiveOutputDir = isSafMode
|
||||
? _sanitizeSafRelativeDir(initialOutputDir)
|
||||
: initialOutputDir;
|
||||
var effectiveSafMode = isSafMode;
|
||||
|
||||
String? safFileName;
|
||||
@@ -6259,9 +6314,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'year': _extractYear(trackToDownload.releaseDate) ?? '',
|
||||
'date': trackToDownload.releaseDate ?? '',
|
||||
});
|
||||
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
|
||||
safBaseName = sanitized;
|
||||
safFileName = '$sanitized$safOutputExt';
|
||||
safFileName = await _buildSafFileName(baseName, safOutputExt);
|
||||
safBaseName = safFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
|
||||
}
|
||||
String? finalSafFileName = safFileName;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user