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" />
+
+
+ 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 5881dee7b..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
@@ -1,11 +1,13 @@
package com.lastwave.app.data.download
+import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.ContentValues
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
import android.media.MediaScannerConnection
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
@@ -14,6 +16,7 @@ import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
import com.lastwave.app.MainActivity
import com.lastwave.app.R
import com.lastwave.app.data.local.db.DownloadedTrackDao
@@ -62,7 +65,9 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
@@ -181,6 +186,50 @@ class TrackDownloadManager @Inject constructor(
fun makeDownloadKey(title: String, artist: String): String =
"${artist.trim().lowercase()}_${title.trim().lowercase()}"
+
+ const val SCAN_PREFS_NAME = "downloads_restore_prefs"
+ const val KEY_DOWNLOADS_SCANNED = "downloads_scanned"
+ private const val SCAN_MARKER_FILE = "downloads_scanned.marker"
+ private val AUDIO_EXTENSIONS = setOf("flac", "m4a", "mp3", "opus", "ogg", "webm", "wav", "aac")
+ private val BRACKET_SONG_ID_REGEX = Regex("""\[([a-zA-Z0-9_-]{11})\]""")
+ private val PAREN_SONG_ID_REGEX = Regex("""\(([a-zA-Z0-9_-]{11})\)""")
+ private val TRAILING_SONG_ID_REGEX = Regex("""(?:^|[\s\-_])([a-zA-Z0-9_-]{11})$""")
+ private val EXACT_SONG_ID_REGEX = Regex("""^[a-zA-Z0-9_-]{11}$""")
+
+ fun extractSongId(nameWithoutExt: String): String? {
+ BRACKET_SONG_ID_REGEX.find(nameWithoutExt)?.let { return it.groupValues[1] }
+ PAREN_SONG_ID_REGEX.find(nameWithoutExt)?.let { return it.groupValues[1] }
+ if (EXACT_SONG_ID_REGEX.matches(nameWithoutExt)) return nameWithoutExt
+ TRAILING_SONG_ID_REGEX.find(nameWithoutExt)?.let { return it.groupValues[1] }
+ return null
+ }
+
+ fun cleanSongTitle(rawTitle: String, songId: String?): String {
+ var title = rawTitle.trim()
+ if (!songId.isNullOrBlank()) {
+ title = title
+ .replace("[$songId]", "")
+ .replace("($songId)", "")
+ .replace(" - $songId", "")
+ .replace("_$songId", "")
+ .trim()
+ }
+ title = title.replace(BRACKET_SONG_ID_REGEX, "")
+ .replace(PAREN_SONG_ID_REGEX, "")
+ .trim()
+ return title.ifBlank { rawTitle.trim() }
+ }
+
+ fun parseArtistAndTitleFromFilename(nameWithoutExt: String, songId: String?): Pair {
+ 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