diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index b595b9e1..41fb726c 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/hash_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; part 'platform_bridge/bridge_codec.dart'; @@ -2326,11 +2327,7 @@ class PlatformBridge { 'method': method, 'arguments': arguments, }); - var hash = 0x811c9dc5; - for (final byte in utf8.encode(identity)) { - hash ^= byte; - hash = (hash * 0x01000193) & 0xffffffff; - } + final hash = fnv1a32(utf8.encode(identity)); final output = File( '${scanDir.path}${Platform.pathSeparator}' 'library_scan_${hash.toRadixString(16).padLeft(8, '0')}.ndjson', diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index 5c6a5717..ea104dd0 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -1,3 +1,4 @@ +import 'package:spotiflac_android/utils/hash_utils.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -290,11 +291,7 @@ int? _bitrateFromQuality(String? quality) { } String qualityVariantStagingLabel(String itemId) { - var hash = 0x811c9dc5; - for (final byte in itemId.codeUnits) { - hash ^= byte; - hash = (hash * 0x01000193) & 0xffffffff; - } + final hash = fnv1a32(itemId.codeUnits); return 'qv_${hash.toRadixString(16).padLeft(8, '0')}'; } diff --git a/lib/utils/hash_utils.dart b/lib/utils/hash_utils.dart new file mode 100644 index 00000000..a8b6d4cf --- /dev/null +++ b/lib/utils/hash_utils.dart @@ -0,0 +1,11 @@ +/// FNV-1a 32-bit hash over [units] (UTF-8 bytes or UTF-16 code units). +/// +/// The result feeds on-disk file names, so the algorithm must not change. +int fnv1a32(Iterable units) { + var hash = 0x811c9dc5; + for (final unit in units) { + hash ^= unit; + hash = (hash * 0x01000193) & 0xffffffff; + } + return hash; +} diff --git a/test/fnv1a32_test.dart b/test/fnv1a32_test.dart new file mode 100644 index 00000000..27a8776b --- /dev/null +++ b/test/fnv1a32_test.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; +import 'package:spotiflac_android/utils/hash_utils.dart'; + +String _hex(int hash) => hash.toRadixString(16).padLeft(8, '0'); + +void main() { + // Expected values were computed from the original inline implementations + // (scan output name over UTF-8 bytes, staging label over UTF-16 units). + test('fnv1a32 over UTF-8 bytes matches the scan output name hash', () { + expect(_hex(fnv1a32(utf8.encode(''))), '811c9dc5'); + expect(_hex(fnv1a32(utf8.encode('track-123'))), '8277aead'); + expect( + _hex( + fnv1a32( + utf8.encode('{"method":"scan","arguments":{"path":"content://x/y"}}'), + ), + ), + '94bc90df', + ); + expect(_hex(fnv1a32(utf8.encode('café 日本'))), 'c843754b'); + }); + + test('qualityVariantStagingLabel matches the original code units hash', () { + expect(qualityVariantStagingLabel(''), 'qv_811c9dc5'); + expect(qualityVariantStagingLabel('a'), 'qv_e40c292c'); + expect(qualityVariantStagingLabel('track-123'), 'qv_8277aead'); + expect(qualityVariantStagingLabel('café 日本'), 'qv_73a64e9d'); + }); +}