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:
zarzet
2026-05-06 01:18:49 +07:00
parent bb06ab7e12
commit d24435dbc2
5 changed files with 174 additions and 9 deletions
@@ -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
View File
@@ -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}"
+15 -1
View File
@@ -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))
}
}
+61 -7
View File
@@ -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;