From c6c0b6d9a5df1b449d209a0e73f79d3088a66663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=94=F0=9D=98=86=F0=9D=98=82=F0=9D=98=80?= =?UTF-8?q?=F0=9D=97=B5=20=F0=9D=97=9E?= <100coldice@gmail.com> Date: Tue, 20 Oct 2026 02:16:03 +0530 Subject: [PATCH 1/2] feat: restore previously downloaded songs automatically after reinstall --- app/src/main/AndroidManifest.xml | 3 + .../com/lastwave/app/LastWaveApplication.kt | 1 + .../app/data/download/TrackDownloadManager.kt | 406 ++++++++++++++++++ .../app/ui/settings/DownloadsViewModel.kt | 1 + app/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 9 + 6 files changed, 424 insertions(+) create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f050cce49..23fcd9d20 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ android:maxSdkVersion="28" /> + { + val cleanName = cleanSongTitle(nameWithoutExt, songId) + return if (cleanName.contains(" - ")) { + val artist = cleanName.substringBefore(" - ").trim().ifBlank { "Unknown Artist" } + val title = cleanName.substringAfter(" - ").trim().ifBlank { cleanName } + artist to title + } else { + "Unknown Artist" to cleanName.ifBlank { nameWithoutExt } + } + } } // Dedicated HTTP client with extended timeouts and bounded pooling. @@ -221,6 +270,7 @@ class TrackDownloadManager @Inject constructor( private val activeFiles = ConcurrentHashMap() private val reconnectGenerations = ConcurrentHashMap() private val downloadSlots = Semaphore(MAX_CONCURRENT_DOWNLOADS) + private val restoreMutex = Mutex() private val _downloads = MutableStateFlow>(emptyMap()) val downloads: StateFlow> = _downloads.asStateFlow() @@ -3226,7 +3276,363 @@ class TrackDownloadManager @Inject constructor( } } + fun isDownloadsScanned(): Boolean { + val prefs = context.getSharedPreferences(SCAN_PREFS_NAME, Context.MODE_PRIVATE) + if (prefs.getBoolean(KEY_DOWNLOADS_SCANNED, false)) return true + val marker = File(context.noBackupFilesDir, SCAN_MARKER_FILE) + return marker.exists() + } + + private fun markDownloadsScanned() { + runCatching { + context.getSharedPreferences(SCAN_PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_DOWNLOADS_SCANNED, true) + .apply() + } + runCatching { + val marker = File(context.noBackupFilesDir, SCAN_MARKER_FILE) + if (!marker.exists()) { + marker.parentFile?.mkdirs() + marker.createNewFile() + } + } + } + + private fun hasStoragePermission(): Boolean { + val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_AUDIO + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + + suspend fun restoreDownloadsAfterReinstall(): Boolean = withContext(Dispatchers.IO) { + if (isDownloadsScanned()) return@withContext true + + restoreMutex.withLock { + if (isDownloadsScanned()) return@withContext true + + val dirName = currentDownloadDirName() + val hasPermission = hasStoragePermission() + if (!hasPermission) { + // If storage permission isn't granted yet, don't prompt just for this. + // Leave the flag unset and retry on a later launch, unless the folder + // is already directly readable on this device (e.g. legacy/custom storage). + val canDirectlyRead = runCatching { + downloadSearchDirs(dirName).any { dir -> + dir.exists() && dir.canRead() && dir.listFiles()?.any { it.isFile && it.extension.lowercase() in AUDIO_EXTENSIONS } == true + } + }.getOrDefault(false) + if (!canDirectlyRead) { + return@withContext false + } + } + + val existingEntities = runCatching { downloadedTrackDao.getAllList() }.getOrDefault(emptyList()).toMutableList() + val existingPaths = existingEntities.map { it.filePath }.toMutableSet() + val existingUris = existingEntities.mapNotNull { it.mediaStoreUri }.toMutableSet() + val existingKeys = existingEntities.map { it.trackKey }.toMutableSet() + val existingSongIds = mutableSetOf() + + for (entity in existingEntities) { + extractSongId(File(entity.filePath).nameWithoutExtension)?.let { existingSongIds.add(it) } + extractSongId(entity.title)?.let { existingSongIds.add(it) } + entity.artworkUrl?.let { url -> + if (url.contains("i.ytimg.com/vi/")) { + val id = url.substringAfter("i.ytimg.com/vi/").substringBefore("/") + if (id.length == 11) existingSongIds.add(id) + } + } + } + + // 1. Scan public download directories on device (active folder + legacy default, incl. subfolders) + val musicDirs = downloadSearchDirs(dirName).filter { it.exists() && it.isDirectory } + val seenDirs = mutableSetOf() + for (musicDir in musicDirs) { + if (!seenDirs.add(musicDir.absolutePath)) continue + val audioFiles = runCatching { + musicDir.walkTopDown().maxDepth(6) + .filter { file -> + file.isFile && + file.extension.lowercase() in AUDIO_EXTENSIONS && + !file.name.lowercase().endsWith(".part") && + !file.name.lowercase().endsWith(".tmp") && + !file.name.lowercase().endsWith(".crdownload") && + !file.name.lowercase().endsWith(".download") && + !file.name.lowercase().contains(".part.") && + file.length() >= MIN_VALID_AUDIO_BYTES + } + .toList() + }.getOrNull().orEmpty() + + for (file in audioFiles) { + if (file.absolutePath in existingPaths) continue + if (activeFiles.values.any { it.absolutePath == file.absolutePath }) continue + + val songId = extractSongId(file.nameWithoutExtension) + if (songId != null && songId in existingSongIds) continue + + val retriever = android.media.MediaMetadataRetriever() + try { + retriever.setDataSource(file.absolutePath) + val metaTitle = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_TITLE) + ?.trim()?.takeIf { it.isNotBlank() } + val metaArtist = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_ARTIST) + ?.trim()?.takeIf { it.isNotBlank() } + val metaAlbum = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_ALBUM) + ?.trim().orEmpty() + + val (parsedArtist, parsedTitle) = parseArtistAndTitleFromFilename(file.nameWithoutExtension, songId) + + val rawTitle = metaTitle ?: parsedTitle + val cleanTitle = cleanSongTitle(rawTitle, songId) + val title = cleanTitle.ifBlank { file.nameWithoutExtension } + + var artist = metaArtist?.takeUnless { ArtistHelper.isPlayCountOrStat(it) } + if (artist.isNullOrBlank()) { + val parentName = file.parentFile?.name.orEmpty() + artist = parentName.takeUnless { + it.isBlank() || it.equals("Singles", ignoreCase = true) || + it.equals(dirName, ignoreCase = true) || + it.equals(LEGACY_PUBLIC_DIR_NAME, ignoreCase = true) || + it.equals("Music", ignoreCase = true) || + ArtistHelper.isPlayCountOrStat(it) + } ?: parsedArtist.takeUnless { ArtistHelper.isPlayCountOrStat(it) } + ?: "Unknown Artist" + } + artist = artist.trim().ifBlank { "Unknown Artist" } + + val trackKey = makeDownloadKey(title, artist) + if (trackKey in existingKeys) continue + if (activeKeys.contains(trackKey)) continue + + val album = metaAlbum.takeUnless { ArtistHelper.isPlayCountOrStat(it) }.orEmpty() + val durStr = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION) + val durMs = durStr?.toLongOrNull() ?: 0L + val bitRateStr = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_BITRATE) + val bitrateKbps = bitRateStr?.toIntOrNull()?.let { it / 1000 } + + val ext = file.extension.lowercase() + val isFlac = ext == "flac" + val badge = when (ext) { + "flac" -> "FLAC" + "m4a", "mp4", "aac" -> "M4A AAC" + "mp3" -> if (bitrateKbps != null && bitrateKbps >= 300) "320k MP3" else "MP3" + "opus", "ogg" -> "OPUS" + "wav" -> "WAV" + else -> "AUDIO" + } + + // Extract embedded cover art or fall back to songId / local image + var artworkUrl: String? = null + val pictureBytes = retriever.embeddedPicture + if (pictureBytes != null && pictureBytes.isNotEmpty()) { + try { + val coversDir = File(context.filesDir, "download_covers").apply { mkdirs() } + val coverFile = File(coversDir, "${trackKey.hashCode().toUInt().toString(16)}.jpg") + if (!coverFile.exists() || coverFile.length() == 0L) { + coverFile.writeBytes(pictureBytes) + } + artworkUrl = Uri.fromFile(coverFile).toString() + } catch (_: Exception) { } + } + if (artworkUrl == null && !songId.isNullOrBlank()) { + artworkUrl = "https://i.ytimg.com/vi/$songId/hqdefault.jpg" + } + if (artworkUrl == null) { + val companionCover = File(file.parentFile ?: musicDir, "${file.nameWithoutExtension}.jpg") + .takeIf { it.exists() && it.length() > 0 } + ?: File(file.parentFile ?: musicDir, "cover.jpg").takeIf { it.exists() && it.length() > 0 } + if (companionCover != null) { + artworkUrl = Uri.fromFile(companionCover).toString() + } + } + + // Check companion .lrc lyrics file + val lrcFile = File(file.parentFile ?: musicDir, file.nameWithoutExtension + ".lrc") + val altLrcFile = File(file.parentFile ?: musicDir, sanitizeFilename("$artist - $title") + ".lrc") + val effectiveLrc = when { + lrcFile.exists() && lrcFile.length() > 0 -> lrcFile + altLrcFile.exists() && altLrcFile.length() > 0 -> altLrcFile + else -> null + } + val hasLyrics = effectiveLrc != null + val lrcText = effectiveLrc?.let { runCatching { it.readText() }.getOrNull() } + + val entity = DownloadedTrackEntity( + trackKey = trackKey, + title = title, + artist = artist, + album = album, + artworkUrl = artworkUrl, + filePath = file.absolutePath, + fileSizeBytes = file.length(), + formatBadge = badge, + durationMs = durMs, + bitrateKbps = bitrateKbps, + isLossless = isFlac, + hasLyrics = hasLyrics, + syncedLyrics = if (lrcText?.contains("[") == true) lrcText else null, + plainLyrics = if (lrcText?.contains("[") != true) lrcText else null, + lrcFilePath = effectiveLrc?.absolutePath, + downloadedAtMillis = file.lastModified().takeIf { it > 0 } ?: System.currentTimeMillis(), + ) + downloadedTrackDao.insert(entity) + existingPaths.add(file.absolutePath) + existingKeys.add(trackKey) + if (songId != null) existingSongIds.add(songId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (oom: OutOfMemoryError) { + android.util.Log.e("TrackDownloadManager", "Skipping file after OOM: ${file.name}", oom) + } catch (e: Exception) { + android.util.Log.e("TrackDownloadManager", "Failed to restore file: ${file.name}", e) + } finally { + runCatching { retriever.release() } + } + } + } + + // 2. Query MediaStore for audio items in the download folders (active + legacy) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.SIZE, + MediaStore.Audio.Media.MIME_TYPE, + MediaStore.Audio.Media.RELATIVE_PATH, + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.DATE_ADDED, + ) + val selection: String + val selectionArgs: Array + if (dirName != LEGACY_PUBLIC_DIR_NAME) { + selection = "(${MediaStore.Audio.Media.RELATIVE_PATH} LIKE ? OR ${MediaStore.Audio.Media.RELATIVE_PATH} LIKE ?)" + selectionArgs = arrayOf("Music/$dirName%", "Music/$LEGACY_PUBLIC_DIR_NAME%") + } else { + selection = "${MediaStore.Audio.Media.RELATIVE_PATH} LIKE ?" + selectionArgs = arrayOf("Music/$dirName%") + } + + runCatching { + context.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + null, + )?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) + val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) + val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) + val durCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) + val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE) + val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val dateCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idCol) + val uri = Uri.withAppendedPath(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id.toString()) + if (uri.toString() in existingUris) continue + if (activeUris.values.any { it.toString() == uri.toString() }) continue + + val displayName = cursor.getString(nameCol).orEmpty() + val songId = extractSongId(displayName.substringBeforeLast('.')) + if (songId != null && songId in existingSongIds) continue + + val rawTitle = cursor.getString(titleCol) ?: displayName.substringBeforeLast('.').ifBlank { "Unknown Track" } + val rawArtist = cursor.getString(artistCol) ?: "Unknown Artist" + val rawAlbum = cursor.getString(albumCol).orEmpty() + + val cleanTitle = cleanSongTitle(rawTitle, songId) + val title = cleanTitle.ifBlank { "Unknown Track" } + val artist = rawArtist.trim() + .takeUnless { ArtistHelper.isPlayCountOrStat(it) } + ?.takeIf { it.isNotBlank() } ?: "Unknown Artist" + val trackKey = makeDownloadKey(title, artist) + if (trackKey in existingKeys) continue + if (activeKeys.contains(trackKey)) continue + + val album = rawAlbum.trim().takeUnless { ArtistHelper.isPlayCountOrStat(it) }.orEmpty() + val durMs = cursor.getLong(durCol) + val size = cursor.getLong(sizeCol) + if (size < MIN_VALID_AUDIO_BYTES) continue + + val mime = cursor.getString(mimeCol).orEmpty().lowercase() + val date = cursor.getLong(dateCol) * 1000L + + val isFlac = mime.contains("flac") + val isM4a = mime.contains("mp4") || mime.contains("m4a") || mime.contains("aac") + val isMp3 = mime.contains("mp3") || mime.contains("mpeg") + val isOpus = mime.contains("opus") || mime.contains("ogg") + + val badge = when { + isFlac -> "FLAC" + isM4a -> "M4A AAC" + isMp3 -> "320k MP3" + isOpus -> "OPUS" + else -> "AUDIO" + } + + var artworkUrl: String? = null + val retriever = android.media.MediaMetadataRetriever() + try { + retriever.setDataSource(context, uri) + val pictureBytes = retriever.embeddedPicture + if (pictureBytes != null && pictureBytes.isNotEmpty()) { + val coversDir = File(context.filesDir, "download_covers").apply { mkdirs() } + val coverFile = File(coversDir, "${trackKey.hashCode().toUInt().toString(16)}.jpg") + if (!coverFile.exists() || coverFile.length() == 0L) { + coverFile.writeBytes(pictureBytes) + } + artworkUrl = Uri.fromFile(coverFile).toString() + } + } catch (_: Exception) { + } finally { + runCatching { retriever.release() } + } + if (artworkUrl == null && !songId.isNullOrBlank()) { + artworkUrl = "https://i.ytimg.com/vi/$songId/hqdefault.jpg" + } + + val entity = DownloadedTrackEntity( + trackKey = trackKey, + title = title, + artist = artist, + album = album, + artworkUrl = artworkUrl, + filePath = uri.toString(), + mediaStoreUri = uri.toString(), + fileSizeBytes = size, + formatBadge = badge, + durationMs = durMs, + isLossless = isFlac, + downloadedAtMillis = if (date > 0) date else System.currentTimeMillis(), + ) + downloadedTrackDao.insert(entity) + existingUris.add(uri.toString()) + existingKeys.add(trackKey) + if (songId != null) existingSongIds.add(songId) + } + } + } + } + + // Only mark as successfully scanned when the complete routine finishes without uncaught error + markDownloadsScanned() + true + } + } + suspend fun syncDownloadsFromStorage() = withContext(Dispatchers.IO) { + runCatching { restoreDownloadsAfterReinstall() } runCatching { repairCorruptedDownloadMetadata() } val existingEntities = downloadedTrackDao.getAllList().toMutableList() val existingPaths = existingEntities.map { it.filePath }.toMutableSet() diff --git a/app/src/main/java/com/lastwave/app/ui/settings/DownloadsViewModel.kt b/app/src/main/java/com/lastwave/app/ui/settings/DownloadsViewModel.kt index 8dee5cd39..9d5a4c5b8 100644 --- a/app/src/main/java/com/lastwave/app/ui/settings/DownloadsViewModel.kt +++ b/app/src/main/java/com/lastwave/app/ui/settings/DownloadsViewModel.kt @@ -365,6 +365,7 @@ class DownloadsViewModel @Inject constructor( init { launchDownloadAction("sync downloads from storage") { + downloadManager.restoreDownloadsAfterReinstall() downloadManager.syncDownloadsFromStorage() } } diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 000000000..85df4a220 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..514ebc57e --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + From f355e98f86a654688dc66731c76fbe996874ebac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=94=F0=9D=98=86=F0=9D=98=82=F0=9D=98=80?= =?UTF-8?q?=F0=9D=97=B5=20=F0=9D=97=9E?= <100coldice@gmail.com> Date: Tue, 20 Oct 2026 08:00:33 +0530 Subject: [PATCH 2/2] feat(restore): add runtime audio permission prompt on first launch --- .../java/com/lastwave/app/MainActivity.kt | 58 ++++++- .../app/data/download/TrackDownloadManager.kt | 9 +- .../app/data/download/AudioRestoreScanTest.kt | 153 ++++++++++++++++++ 3 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/com/lastwave/app/data/download/AudioRestoreScanTest.kt diff --git a/app/src/main/java/com/lastwave/app/MainActivity.kt b/app/src/main/java/com/lastwave/app/MainActivity.kt index fb5340155..54105a13a 100644 --- a/app/src/main/java/com/lastwave/app/MainActivity.kt +++ b/app/src/main/java/com/lastwave/app/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.lastwave.app.util.AppLocaleManager +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map @@ -50,7 +51,20 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { @Inject lateinit var appLocaleManager: AppLocaleManager - private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + @Inject + lateinit var trackDownloadManager: dagger.Lazy + + private val audioPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + lifecycleScope.launch(Dispatchers.IO) { + runCatching { trackDownloadManager.get().restoreDownloadsAfterReinstall() } + } + } + } + + private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + checkAndRequestAudioPermission() + } override fun attachBaseContext(newBase: Context) { // FragmentActivity gets no AppCompat locale backport, so pin the @@ -124,11 +138,19 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { ), ) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED - ) { - runCatching { notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } - .onFailure { android.util.Log.w(STARTUP_TAG, "Notification permission request skipped", it) } + if (savedInstanceState == null) { + val needsNotificationPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + + if (needsNotificationPermission) { + runCatching { notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } + .onFailure { + android.util.Log.w(STARTUP_TAG, "Notification permission request skipped", it) + checkAndRequestAudioPermission() + } + } else { + checkAndRequestAudioPermission() + } } setContent { @@ -276,7 +298,31 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { handleNavigationIntent(intent) } + private fun checkAndRequestAudioPermission() { + if (hasPromptedAudioThisSession) return + hasPromptedAudioThisSession = true + + val downloadManager = trackDownloadManager.get() + if (downloadManager.isDownloadsScanned()) return + + val requiredPermission = downloadManager.getRequiredAudioPermission() + if (checkSelfPermission(requiredPermission) == PackageManager.PERMISSION_GRANTED) { + lifecycleScope.launch(Dispatchers.IO) { + runCatching { downloadManager.restoreDownloadsAfterReinstall() } + } + return + } + + runCatching { + audioPermissionLauncher.launch(requiredPermission) + }.onFailure { + android.util.Log.w(STARTUP_TAG, "Audio permission request skipped", it) + } + } + private companion object { const val STARTUP_TAG = "LastWaveStartup" + @Volatile + var hasPromptedAudioThisSession = false } } diff --git a/app/src/main/java/com/lastwave/app/data/download/TrackDownloadManager.kt b/app/src/main/java/com/lastwave/app/data/download/TrackDownloadManager.kt index 89f75a1b5..daad910de 100644 --- a/app/src/main/java/com/lastwave/app/data/download/TrackDownloadManager.kt +++ b/app/src/main/java/com/lastwave/app/data/download/TrackDownloadManager.kt @@ -3299,14 +3299,15 @@ class TrackDownloadManager @Inject constructor( } } - private fun hasStoragePermission(): Boolean { - val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + fun getRequiredAudioPermission(): String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_AUDIO } else { Manifest.permission.READ_EXTERNAL_STORAGE } - return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - } + + fun hasStoragePermission(): Boolean = + ContextCompat.checkSelfPermission(context, getRequiredAudioPermission()) == PackageManager.PERMISSION_GRANTED suspend fun restoreDownloadsAfterReinstall(): Boolean = withContext(Dispatchers.IO) { if (isDownloadsScanned()) return@withContext true diff --git a/app/src/test/java/com/lastwave/app/data/download/AudioRestoreScanTest.kt b/app/src/test/java/com/lastwave/app/data/download/AudioRestoreScanTest.kt new file mode 100644 index 000000000..2b6eecb81 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/data/download/AudioRestoreScanTest.kt @@ -0,0 +1,153 @@ +package com.lastwave.app.data.download + +import android.Manifest +import android.content.ContentResolver +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build +import com.google.common.truth.Truth.assertThat +import com.lastwave.app.data.local.MiscSettings +import com.lastwave.app.data.local.SettingsPreferences +import com.lastwave.app.data.local.db.DownloadedTrackDao +import com.lastwave.app.data.local.db.DownloadedTrackEntity +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.util.Collections + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], manifest = Config.NONE) +class AudioRestoreScanTest { + + @get:Rule + val temp = TemporaryFolder() + + private val stored = Collections.synchronizedList(mutableListOf()) + private val dao = mockk(relaxed = true) + private val resolver = mockk(relaxed = true) + private val context = mockk(relaxed = true) + private val settings = mockk(relaxed = true) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private lateinit var manager: TrackDownloadManager + private lateinit var prefsDir: File + private lateinit var noBackupDir: File + + @Before + fun setUp() { + prefsDir = temp.newFolder("shared_prefs") + noBackupDir = temp.newFolder("no_backup") + val filesDir = temp.newFolder("files") + val cacheDir = temp.newFolder("cache") + + every { context.applicationContext } returns context + every { context.contentResolver } returns resolver + every { context.cacheDir } returns cacheDir + every { context.filesDir } returns filesDir + every { context.noBackupFilesDir } returns noBackupDir + every { context.getSystemService(any()) } returns null + every { settings.settings } returns flowOf(MiscSettings()) + + val inMemoryPrefs = HashMap() + val prefs = mockk(relaxed = true) + val editor = mockk(relaxed = true) + every { context.getSharedPreferences(any(), any()) } returns prefs + every { prefs.getBoolean(any(), any()) } answers { + val key = firstArg() + val def = secondArg() + (inMemoryPrefs[key] as? Boolean) ?: def + } + every { prefs.edit() } returns editor + every { editor.putBoolean(any(), any()) } answers { + val key = firstArg() + val v = secondArg() + inMemoryPrefs[key] = v + editor + } + every { editor.apply() } answers { } + + coEvery { dao.getAllList() } coAnswers { stored.toList() } + coEvery { dao.insert(any()) } coAnswers { + val track = firstArg() + stored.add(track) + track.id + } + coEvery { dao.findByTrackKey(any()) } answers { + val key = firstArg() + stored.firstOrNull { it.trackKey == key } + } + + manager = TrackDownloadManager( + context = context, + innerTube = mockk(relaxed = true), + artworkRepository = mockk(relaxed = true), + lyricsRepository = mockk(relaxed = true), + audioTagWriter = mockk(relaxed = true), + okHttpClient = OkHttpClient(), + downloadedTrackDao = dao, + settingsPreferences = settings, + applicationScope = scope, + moduleResolver = mockk(relaxed = true), + segBridge = mockk(relaxed = true), + offlineLicense = mockk(relaxed = true), + moduleManager = mockk(relaxed = true), + flacTranscoder = mockk(relaxed = true), + losslessMusicApi = mockk(relaxed = true), + ) + } + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun requiredPermission_onSdk33_isReadMediaAudio() { + assertThat(manager.getRequiredAudioPermission()).isEqualTo(Manifest.permission.READ_MEDIA_AUDIO) + } + + @Test + fun initialScannedFlag_isFalse() { + assertThat(manager.isDownloadsScanned()).isFalse() + } + + @Test + fun restoreDownloads_withPermission_setsScannedFlagAndDoesNotDuplicate() = runBlocking { + every { context.checkPermission(Manifest.permission.READ_MEDIA_AUDIO, any(), any()) } returns PackageManager.PERMISSION_GRANTED + + val firstResult = manager.restoreDownloadsAfterReinstall() + assertThat(firstResult).isTrue() + assertThat(manager.isDownloadsScanned()).isTrue() + + // Calling a second time should immediately short-circuit without re-scanning or adding duplicates + val countBefore = stored.size + val secondResult = manager.restoreDownloadsAfterReinstall() + assertThat(secondResult).isTrue() + assertThat(stored.size).isEqualTo(countBefore) + } + + @Test + fun restoreDownloads_withoutPermission_leavesScannedFlagUnset() = runBlocking { + every { context.checkPermission(any(), any(), any()) } returns PackageManager.PERMISSION_DENIED + + val result = manager.restoreDownloadsAfterReinstall() + assertThat(result).isFalse() + assertThat(manager.isDownloadsScanned()).isFalse() + } +}