diff --git a/README.md b/README.md
index fb285631..93468dda 100644
--- a/README.md
+++ b/README.md
@@ -25,9 +25,7 @@ A Spotify songs downloader powered by [spotDL](https://github.com/spotDL/spotify
## ⚠️ Warning
-The Spotify mods downloader has been deleted by request of the xManager team. This is because having the mod downloader in Spowlo meant an avoid of their ads/earning methods. xManager has to pay servers and they pay those just for making the users have free Spotify, I hope that you all understand.
-
-please, instead use the [xManager app](https://github.com/xManager-App/xManager). Maybe somme day I create an app for them who knows haha
+Spowlo uses YT Music and YouTube to download the songs. This is because Spotify DRM bypassing can lead to an account ban and legal issues. If YT Music isn't available in your country, don't worry, you can still use YouTube as audio provider or use a VPN. We are working on making a regional bypass so don't matter your region. Thank you for understanding.
## 🔮 Features
@@ -51,6 +49,11 @@ For most devices, it is recommended to install the **ARM64-v8a** version of the
- Download the latest stable version from [GitHub releases](https://github.com/BobbyESP/Spowlo/releases/latest)
+## Translation
+
+We are using Hosted Weblate for the translations of the app. if you want to contribute follow [this link](https://hosted.weblate.org/engage/spowlo/) 🖇️
+
+
## 📖Credits
Thanks to [xnetcat](https://github.com/xnetcat) for it's help with some spotDL related things!
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 95fe5fb9..f92df50f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -43,8 +43,8 @@ sealed class Version(
val currentVersion: Version = Version.Stable(
versionMajor = 1,
- versionMinor = 2,
- versionPatch = 1,
+ versionMinor = 3,
+ versionPatch = 0,
)
val keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -71,7 +71,7 @@ android {
applicationId = "com.bobbyesp.spowlo"
minSdk = 26
targetSdk = 33
- versionCode = 10201
+ versionCode = 10300
versionName = currentVersion.toVersionName().run {
if (!splitApks) "$this-(F-Droid)"
@@ -110,18 +110,42 @@ android {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
)
+ packagingOptions {
+ resources.excludes.add("META-INF/*.kotlin_module")
+ }
if (keystorePropertiesFile.exists())
signingConfig = signingConfigs.getByName("debug")
+ //add client id and secret to build config
+ buildConfigField("String", "CLIENT_ID", "\"${project.properties["CLIENT_ID"]}\"")
+ buildConfigField(
+ "String",
+ "CLIENT_SECRET",
+ "\"${project.properties["CLIENT_SECRET"]}\""
+ )
+ matchingFallbacks.add(0, "debug")
+ matchingFallbacks.add(1, "release")
}
debug {
if (keystorePropertiesFile.exists())
signingConfig = signingConfigs.getByName("debug")
+ packagingOptions {
+ resources.excludes.add("META-INF/*.kotlin_module")
+ }
+ buildConfigField("String", "CLIENT_ID", "\"${project.properties["CLIENT_ID"]}\"")
+ buildConfigField(
+ "String",
+ "CLIENT_SECRET",
+ "\"${project.properties["CLIENT_SECRET"]}\""
+ )
+ matchingFallbacks.add(0, "debug")
+ matchingFallbacks.add(1, "release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
+
kotlinOptions {
jvmTarget = "1.8"
}
@@ -183,10 +207,11 @@ dependencies {
implementation(libs.accompanist.permissions)
implementation(libs.accompanist.navigation.animation)
implementation(libs.accompanist.webview)
- implementation(libs.accompanist.pager.layouts)
- implementation(libs.accompanist.pager.indicators)
implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.material)
+ implementation(libs.accompanist.pager.indicators)
+ implementation(libs.paging.compose)
+ implementation(libs.paging.runtime)
implementation(libs.coil.kt.compose)
@@ -214,13 +239,14 @@ dependencies {
implementation(libs.markdown)
//Exoplayer
- implementation(libs.exoplayer.core)
- implementation(libs.exoplayer.ui)
- implementation(libs.exoplayer.dash)
- implementation(libs.exoplayer.smoothstreaming)
- implementation(libs.exoplayer.extension.mediasession)
+// implementation(libs.exoplayer.core)
+// implementation(libs.exoplayer.ui)
+// implementation(libs.exoplayer.dash)
+// implementation(libs.exoplayer.smoothstreaming)
+// implementation(libs.exoplayer.extension.mediasession)
implementation(libs.customtabs)
+ // implementation(libs.shimmer)
debugImplementation(libs.crash.handler)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3737b598..95ba6492 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -92,6 +92,10 @@
android:value="true" />
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/bobbyesp/spowlo/App.kt b/app/src/main/java/com/bobbyesp/spowlo/App.kt
index 5ad52d2a..f35eec68 100644
--- a/app/src/main/java/com/bobbyesp/spowlo/App.kt
+++ b/app/src/main/java/com/bobbyesp/spowlo/App.kt
@@ -4,21 +4,27 @@ import android.annotation.SuppressLint
import android.app.Application
import android.content.ClipData
import android.content.ClipboardManager
+import android.content.ComponentName
import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Build
import android.os.Environment
+import android.os.IBinder
import android.os.Looper
import androidx.core.content.getSystemService
import com.bobbyesp.ffmpeg.FFmpeg
import com.bobbyesp.library.SpotDL
import com.bobbyesp.spowlo.utils.AUDIO_DIRECTORY
import com.bobbyesp.spowlo.utils.DownloaderUtil
+import com.bobbyesp.spowlo.utils.EXTRA_DIRECTORY
import com.bobbyesp.spowlo.utils.FilesUtil
import com.bobbyesp.spowlo.utils.FilesUtil.createEmptyFile
import com.bobbyesp.spowlo.utils.FilesUtil.getCookiesFile
+import com.bobbyesp.spowlo.utils.NotificationsUtil
import com.bobbyesp.spowlo.utils.PreferencesUtil
import com.bobbyesp.spowlo.utils.PreferencesUtil.getString
import com.bobbyesp.spowlo.utils.ToastUtil
@@ -69,12 +75,17 @@ class App : Application() {
getString(R.string.app_name)
).absolutePath
)
+ extraDownloadDir = EXTRA_DIRECTORY.getString(
+ ""
+ )
+ if (Build.VERSION.SDK_INT >= 26) NotificationsUtil.createNotificationChannel()
}
companion object {
private const val PRIVATE_DIRECTORY_SUFFIX = ".Spowlo"
lateinit var clipboard: ClipboardManager
lateinit var audioDownloadDir: String
+ lateinit var extraDownloadDir: String
lateinit var applicationScope: CoroutineScope
lateinit var connectivityManager: ConnectivityManager
lateinit var packageInfo: PackageInfo
@@ -83,36 +94,37 @@ class App : Application() {
const val userAgentHeader =
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Mobile Safari/537.36 Edg/105.0.1343.53"
- /* var isServiceRunning = false
-
- private val connection = object : ServiceConnection {
- override fun onServiceConnected(className: ComponentName, service: IBinder) {
- val binder = service as DownloadService.DownloadServiceBinder
- isServiceRunning = true
- }
-
- override fun onServiceDisconnected(arg0: ComponentName) {
- }
- }
-
- fun startService() {
- if (isServiceRunning) return
- Intent(context.applicationContext, DownloadService::class.java).also { intent ->
- context.applicationContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
- }
- }
-
- fun stopService() {
- if (!isServiceRunning) return
- try {
- isServiceRunning = false
- context.applicationContext.run {
- unbindService(connection)
- }
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }*/
+ var isServiceRunning = false
+
+ private val connection = object : ServiceConnection {
+ override fun onServiceConnected(className: ComponentName, service: IBinder) {
+ val binder = service as DownloaderKeepUpService.DownloadServiceBinder
+ isServiceRunning = true
+ }
+
+ override fun onServiceDisconnected(arg0: ComponentName) {
+
+ }
+ }
+
+ fun startService() {
+ if (isServiceRunning) return
+ Intent(context.applicationContext, DownloaderKeepUpService::class.java).also { intent ->
+ context.applicationContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
+ }
+ }
+
+ fun stopService() {
+ if (!isServiceRunning) return
+ try {
+ isServiceRunning = false
+ context.applicationContext.run {
+ unbindService(connection)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
fun getPrivateDownloadDirectory(): String =
diff --git a/app/src/main/java/com/bobbyesp/spowlo/Downloader.kt b/app/src/main/java/com/bobbyesp/spowlo/Downloader.kt
index 0f825145..f4ba6592 100644
--- a/app/src/main/java/com/bobbyesp/spowlo/Downloader.kt
+++ b/app/src/main/java/com/bobbyesp/spowlo/Downloader.kt
@@ -1,21 +1,30 @@
package com.bobbyesp.spowlo
+import android.app.PendingIntent
import android.util.Log
import androidx.annotation.CheckResult
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.ui.text.AnnotatedString
import com.bobbyesp.library.SpotDL
import com.bobbyesp.library.dto.Song
import com.bobbyesp.spowlo.App.Companion.applicationScope
import com.bobbyesp.spowlo.App.Companion.context
+import com.bobbyesp.spowlo.App.Companion.startService
+import com.bobbyesp.spowlo.App.Companion.stopService
+import com.bobbyesp.spowlo.ui.common.containsEllipsis
import com.bobbyesp.spowlo.utils.DownloaderUtil
import com.bobbyesp.spowlo.utils.FilesUtil
+import com.bobbyesp.spowlo.utils.NotificationsUtil
import com.bobbyesp.spowlo.utils.ToastUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import java.util.UUID
object Downloader {
@@ -30,6 +39,9 @@ object Downloader {
object Idle : State()
}
+ fun makeKey(url: String, additionalString: String = UUID.randomUUID().toString()): String =
+ "${additionalString}_$url"
+
data class ErrorState(
val errorReport: String = "",
val errorMessageResId: Int = R.string.unknown_error,
@@ -38,6 +50,73 @@ object Downloader {
errorMessageResId != R.string.unknown_error || errorReport.isNotEmpty()
}
+ data class DownloadTask(
+ val url: String,
+ val consoleOutput: String,
+ val state: State,
+ val currentLine: String,
+ val taskName: String,
+ ) {
+ fun toKey() = makeKey(url, url.reversed())
+ sealed class State {
+ data class Error(val errorReport: String) : State()
+ object Completed : State()
+ object Canceled : State()
+ data class Running(val progress: Float) : State()
+ }
+
+ override fun hashCode(): Int {
+ return (this.url + this.url.reversed()).hashCode()
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as DownloadTask
+
+ if (url != other.url) return false
+ if (consoleOutput != other.consoleOutput) return false
+ if (state != other.state) return false
+ if (currentLine != other.currentLine) return false
+
+ return true
+ }
+
+ fun onCopyLog(clipboardManager: androidx.compose.ui.platform.ClipboardManager) {
+ clipboardManager.setText(AnnotatedString(consoleOutput))
+ ToastUtil.makeToastSuspend(context.getString(R.string.log_copied))
+ }
+
+ fun onCopyUrl(clipboardManager: androidx.compose.ui.platform.ClipboardManager) {
+ clipboardManager.setText(AnnotatedString(url))
+ ToastUtil.makeToastSuspend(context.getString(R.string.link_copied))
+ }
+
+
+ fun onRestart() {
+ applicationScope.launch(Dispatchers.IO) {
+ executeParallelDownloadWithUrl(url, name = taskName)
+ }
+ }
+
+
+ fun onCopyError(clipboardManager: androidx.compose.ui.platform.ClipboardManager) {
+ clipboardManager.setText(AnnotatedString(currentLine))
+ ToastUtil.makeToast(R.string.error_copied)
+ }
+
+ fun onCancel() {
+ toKey().run {
+ SpotDL.getInstance().destroyProcessById(this, true)
+ onProcessCanceled(this)
+ }
+ }
+
+ }
+
+ //----------------------------
+
data class DownloadTaskItem(
val info: Song = Song(),
val spotifyUrl: String = "",
@@ -46,16 +125,14 @@ object Downloader {
val duration: Double = 0.0,
val isExplicit: Boolean = false,
val hasLyrics: Boolean = false,
- // val fileSizeApprox: Long = 0,
val progress: Float = 0f,
val progressText: String = "",
val thumbnailUrl: String = "",
val taskId: String = "",
val output: String = "",
- // val playlistIndex: Int = 0,
)
- private fun Song.toTask(playlistIndex: Int = 0, preferencesHash: Int): DownloadTaskItem =
+ private fun Song.toTask(preferencesHash: Int): DownloadTaskItem =
DownloadTaskItem(
info = this,
spotifyUrl = this.url,
@@ -64,12 +141,10 @@ object Downloader {
duration = this.duration,
isExplicit = this.explicit,
hasLyrics = this.lyrics.isNullOrEmpty(),
- // fileSizeApprox = this.fileSizeApprox,
progress = 0f,
progressText = "",
thumbnailUrl = this.cover_url,
- taskId = this.song_id + preferencesHash + playlistIndex,
- // playlistIndex = playlistIndex,
+ taskId = this.song_id + preferencesHash,
)
private var currentJob: Job? = null
@@ -91,10 +166,146 @@ object Downloader {
private val mutableProcessCount = MutableStateFlow(0)
private val processCount = mutableProcessCount.asStateFlow()
+ private val mutableQuickDownloadCount = MutableStateFlow(0)
+
+
+ //-------------------------------------
+
+ val mutableTaskList = mutableStateMapOf()
+
+ init {
+ applicationScope.launch {
+ downloaderState.combine(processCount) { state, cnt ->
+ if (cnt > 0) true
+ else when (state) {
+ is State.Idle -> false
+ else -> true
+ }
+ }.combine(mutableQuickDownloadCount) { isRunning, cnt ->
+ if (!isRunning) cnt > 0 else true
+ }.collect {
+ if (it) startService()
+ else stopService()
+ }
+
+ }
+ }
+
+ fun onTaskStarted(url: String, name: String) =
+ DownloadTask(
+ url = url,
+ consoleOutput = "",
+ state = DownloadTask.State.Running(0f),
+ currentLine = "",
+ taskName = name
+ ).run {
+ mutableTaskList.put(this.toKey(), this)
+
+ val key = makeKey(url, url.reversed())
+ NotificationsUtil.notifyProgress(
+ name + " - " + context.getString(R.string.parallel_download),
+ notificationId = key.toNotificationId(),
+ progress = (state as DownloadTask.State.Running).progress.toInt(),
+ text = currentLine
+ )
+ }
+
+ fun updateTaskOutput(url: String, line: String, progress: Float, isPlaylist: Boolean = false) {
+ val key = makeKey(url, url.reversed())
+ val oldValue = mutableTaskList[key] ?: return
+ val newValue = oldValue.run {
+ if (currentLine == line || line.containsEllipsis() || consoleOutput.contains(line)) return
+ when(isPlaylist) {
+ true -> {
+ copy(
+ consoleOutput = consoleOutput + line + "\n",
+ currentLine = line,
+ state = DownloadTask.State.Running(
+ if (line.contains("Total")) {
+ getProgress(line)
+ } else {
+ (state as DownloadTask.State.Running).progress
+ }
+ )
+ )
+
+ }
+ false -> {
+ copy(
+ consoleOutput = consoleOutput + line + "\n",
+ currentLine = line,
+ state = DownloadTask.State.Running(progress)
+ )
+ }
+ }
+ }
+ mutableTaskList[key] = newValue
+ }
+
+ private fun getProgress(line: String): Float{
+ val PERCENT: Float
+ //Get the two numbers before an % in the line
+ val regex = Regex("(\\d+)%")
+ val matchResult = regex.find(line)
+ //Log the result
+ ///if (BuildConfig.DEBUG) Log.d(TAG, "Progress: ${matchResult?.groupValues?.get(1)?.toFloat() ?: 0f}")
+ PERCENT = matchResult?.groupValues?.get(1)?.toFloat() ?: 0f
+ //divide percent by 100 to get a value between 0 and 1
+ return PERCENT / 100f
+ }
+
+ fun onTaskEnded(
+ url: String,
+ response: String? = null,
+ notificationTitle : String? = null
+ ) {
+ val key = makeKey(url, url.reversed())
+ NotificationsUtil.finishNotification(
+ notificationId = key.toNotificationId(),
+ title = notificationTitle,
+ text = context.getString(R.string.status_completed),
+ )
+ mutableTaskList.run {
+ val oldValue = get(key) ?: return
+ val newValue = oldValue.copy(state = DownloadTask.State.Completed).run {
+ response?.let { copy(consoleOutput = response) } ?: this
+ }
+ this[key] = newValue
+ }
+ FilesUtil.scanDownloadDirectoryToMediaLibrary(App.audioDownloadDir)
+ }
+
+ fun onTaskError(errorReport: String, url: String) =
+ mutableTaskList.run {
+ val key = makeKey(url, url.reversed())
+ NotificationsUtil.makeErrorReportNotification(
+ notificationId = key.toNotificationId(),
+ error = errorReport
+ )
+ val oldValue = mutableTaskList[key] ?: return
+ mutableTaskList[key] = oldValue.copy(
+ state = DownloadTask.State.Error(
+ errorReport
+ ),
+ currentLine = errorReport,
+ consoleOutput = oldValue.consoleOutput + "\n" + errorReport
+ )
+ }
+
fun onProcessEnded() =
mutableProcessCount.update { it - 1 }
+ fun onProcessCanceled(taskId: String) =
+ mutableTaskList.run {
+ get(taskId)?.let {
+ this.put(
+ taskId,
+ it.copy(state = DownloadTask.State.Canceled)
+ )
+ }
+ }
+
fun isDownloaderAvailable(): Boolean {
if (downloaderState.value !is State.Idle) {
ToastUtil.makeToastSuspend(context.getString(R.string.task_running))
@@ -104,7 +315,7 @@ object Downloader {
}
@CheckResult
- private suspend fun downloadSong(
+ private fun downloadSong(
songInfo: Song,
preferences: DownloaderUtil.DownloadPreferences = DownloaderUtil.DownloadPreferences()
): Result> {
@@ -112,12 +323,10 @@ object Downloader {
val isDownloadingPlaylist = downloaderState.value is State.DownloadingPlaylist
mutableTaskState.update { songInfo.toTask(preferencesHash = preferences.hashCode()) }
-
+ val notificationId = preferences.hashCode() + songInfo.song_id.getNumbers()
if (!isDownloadingPlaylist) updateState(State.DownloadingSong)
return DownloaderUtil.downloadSong(
songInfo = songInfo,
- playlistUrl = "",
- playlistItem = 0,
downloadPreferences = preferences,
taskId = songInfo.song_id + preferences.hashCode()
) { progress, _, line ->
@@ -125,19 +334,21 @@ object Downloader {
mutableTaskState.update {
it.copy(progress = progress, progressText = line)
}
- /*NotificationUtil.notifyProgress(
+
+ NotificationsUtil.notifyProgress(
notificationId = notificationId,
progress = progress.toInt(),
text = line,
- title = videoInfo.title
- )*/
+ title = songInfo.name
+ )
}.onFailure {
+ Log.d("Downloader", "$it")
if (it is SpotDL.CanceledException) return@onFailure
Log.d("Downloader", "The download has been canceled (app thread)")
manageDownloadError(
it,
false,
- //notificationId = notificationId,
+ notificationId = notificationId,
isTaskAborted = !isDownloadingPlaylist
)
}.onSuccess {
@@ -145,9 +356,9 @@ object Downloader {
val text =
context.getString(if (it.isEmpty()) R.string.status_completed else R.string.download_finish_notification)
FilesUtil.createIntentForOpeningFile(it.firstOrNull()).run {
- /* NotificationUtil.finishNotification(
+ NotificationsUtil.finishNotification(
notificationId,
- title = videoInfo.title,
+ title = songInfo.name,
text = text,
intent = if (this != null) PendingIntent.getActivity(
context,
@@ -155,36 +366,51 @@ object Downloader {
this,
PendingIntent.FLAG_IMMUTABLE
) else null
- )*/
+ )
}
}
}
fun getInfoAndDownload(
url: String,
- downloadPreferences: DownloaderUtil.DownloadPreferences = DownloaderUtil.DownloadPreferences()
+ downloadPreferences: DownloaderUtil.DownloadPreferences = DownloaderUtil.DownloadPreferences(),
+ skipInfoFetch: Boolean = false
) {
currentJob = applicationScope.launch(Dispatchers.IO) {
updateState(State.FetchingInfo)
- DownloaderUtil.fetchSongInfoFromUrl(
- url = url,
- preferences = downloadPreferences
- )
- .onFailure {
+ if (skipInfoFetch) {
+ downloadResultTemp = downloadSong(
+ songInfo = Song(url = url),
+ preferences = downloadPreferences
+ ).onFailure {
manageDownloadError(
it,
isFetchingInfo = true,
isTaskAborted = true
)
}
- .onSuccess { info ->
- for (song in info) {
- downloadResultTemp = downloadSong(
- songInfo = song,
- preferences = downloadPreferences
+ return@launch
+ } else {
+ DownloaderUtil.fetchSongInfoFromUrl(
+ url = url
+ )
+ .onFailure {
+ manageDownloadError(
+ it,
+ isFetchingInfo = true,
+ isTaskAborted = true
)
+ return@launch
}
- }
+ .onSuccess { info ->
+ for (song in info) {
+ downloadResultTemp = downloadSong(
+ songInfo = song,
+ preferences = downloadPreferences
+ )
+ }
+ }
+ }
}
}
@@ -195,10 +421,8 @@ object Downloader {
currentJob = applicationScope.launch(Dispatchers.IO) {
updateState(State.FetchingInfo)
DownloaderUtil.fetchSongInfoFromUrl(
- url = url,
- preferences = downloadPreferences
- )
- .onFailure {
+ url = url
+ ).onFailure {
manageDownloadError(
it,
isFetchingInfo = true,
@@ -207,13 +431,17 @@ object Downloader {
}
.onSuccess { info ->
DownloaderUtil.updateSongsState(info)
- mutableTaskState.update { DownloaderUtil.songsState.value[0].toTask(preferencesHash = downloadPreferences.hashCode()) }
+ mutableTaskState.update {
+ DownloaderUtil.songsState.value[0].toTask(
+ preferencesHash = downloadPreferences.hashCode()
+ )
+ }
finishProcessing()
}
}
}
- fun updateState(state: State) = mutableDownloaderState.update { state }
+ private fun updateState(state: State) = mutableDownloaderState.update { state }
fun clearErrorState() {
mutableErrorState.update { ErrorState() }
@@ -248,7 +476,7 @@ object Downloader {
/**
* @param isTaskAborted Determines if the download task is aborted due to the given `Exception`
*/
- fun manageDownloadError(
+ private fun manageDownloadError(
th: Throwable,
isFetchingInfo: Boolean,
isTaskAborted: Boolean = true,
@@ -265,11 +493,11 @@ object Downloader {
errorReport = th.message.toString()
)
}
- notificationId?.let {/*
- NotificationUtil.finishNotification(
+ notificationId?.let {
+ NotificationsUtil.finishNotification(
notificationId = notificationId,
text = context.getString(R.string.download_error_msg),
- )*/
+ )
}
if (isTaskAborted) {
updateState(State.Idle)
@@ -284,9 +512,29 @@ object Downloader {
updateState(State.Idle)
clearProgressState(isFinished = false)
taskState.value.taskId.run {
- SpotDL.getInstance().destroyProcessById(this)
- //NotificationUtil.cancelNotification(this.toNotificationId())
+ SpotDL.getInstance().destroyProcessById(this, true)
+ NotificationsUtil.cancelNotification(this.toNotificationId())
}
}
+ fun executeParallelDownloadWithUrl(url: String, name: String) =
+ applicationScope.launch(Dispatchers.IO) {
+ DownloaderUtil.executeParallelDownload(
+ url, name
+ )
+ }
+
+ fun onProcessStarted() = mutableProcessCount.update { it + 1 }
+ fun String.toNotificationId(): Int = this.hashCode()
+
+ //get just the numbers from a string and return an int
+ fun String.getNumbers(): Int {
+ val sb = StringBuilder()
+ for (c in this) {
+ if (c.isDigit()) {
+ sb.append(c)
+ }
+ }
+ return sb.toString().toInt()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/bobbyesp/spowlo/DownloaderKeepUpService.kt b/app/src/main/java/com/bobbyesp/spowlo/DownloaderKeepUpService.kt
new file mode 100644
index 00000000..8d33e98b
--- /dev/null
+++ b/app/src/main/java/com/bobbyesp/spowlo/DownloaderKeepUpService.kt
@@ -0,0 +1,43 @@
+package com.bobbyesp.spowlo
+
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.os.Binder
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import com.bobbyesp.spowlo.utils.NotificationsUtil
+import com.bobbyesp.spowlo.utils.NotificationsUtil.SERVICE_NOTIFICATION_ID
+
+private val TAG = DownloaderKeepUpService::class.java.simpleName
+class DownloaderKeepUpService: Service() {
+ override fun onBind(intent: Intent): IBinder {
+ val pendingIntent: PendingIntent =
+ Intent(this, MainActivity::class.java).let { notificationIntent ->
+ PendingIntent.getActivity(
+ this, 0, notificationIntent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+ val notification = NotificationsUtil.makeServiceNotification(pendingIntent)
+ startForeground(SERVICE_NOTIFICATION_ID, notification)
+ return DownloadServiceBinder()
+ }
+
+
+ override fun onUnbind(intent: Intent?): Boolean {
+ Log.d(TAG, "onUnbind: ")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } else {
+ stopForeground(true)
+ }
+ stopSelf()
+ return super.onUnbind(intent)
+ }
+
+ inner class DownloadServiceBinder : Binder() {
+ fun getService(): DownloaderKeepUpService = this@DownloaderKeepUpService
+ }
+}
diff --git a/app/src/main/java/com/bobbyesp/spowlo/MainActivity.kt b/app/src/main/java/com/bobbyesp/spowlo/MainActivity.kt
index f0b5f37a..65c02aa1 100644
--- a/app/src/main/java/com/bobbyesp/spowlo/MainActivity.kt
+++ b/app/src/main/java/com/bobbyesp/spowlo/MainActivity.kt
@@ -8,33 +8,13 @@ import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.offset
-import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Download
-import androidx.compose.material.icons.rounded.Home
-import androidx.compose.material.icons.rounded.LibraryMusic
-import androidx.compose.material.icons.rounded.MusicNote
+import androidx.compose.material.icons.rounded.FileDownloadDone
import androidx.compose.material.icons.rounded.Search
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.collectAsState
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
@@ -45,6 +25,7 @@ import com.bobbyesp.spowlo.ui.common.Route
import com.bobbyesp.spowlo.ui.common.SettingsProvider
import com.bobbyesp.spowlo.ui.pages.InitialEntry
import com.bobbyesp.spowlo.ui.pages.downloader.DownloaderViewModel
+import com.bobbyesp.spowlo.ui.pages.metadata_viewer.playlists.PlaylistPageViewModel
import com.bobbyesp.spowlo.ui.pages.mod_downloader.ModsDownloaderViewModel
import com.bobbyesp.spowlo.ui.theme.SpowloTheme
import com.bobbyesp.spowlo.utils.PreferencesUtil
@@ -58,6 +39,8 @@ import kotlinx.coroutines.runBlocking
class MainActivity : AppCompatActivity() {
private val downloaderViewModel: DownloaderViewModel by viewModels()
private val modsDownloaderViewModel: ModsDownloaderViewModel by viewModels()
+ private val playlistPageViewModel: PlaylistPageViewModel by viewModels()
+
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -67,10 +50,9 @@ class MainActivity : AppCompatActivity() {
insets
}
runBlocking {
- if (Build.VERSION.SDK_INT < 33)
- AppCompatDelegate.setApplicationLocales(
- LocaleListCompat.forLanguageTags(PreferencesUtil.getLanguageConfiguration())
- )
+ if (Build.VERSION.SDK_INT < 33) AppCompatDelegate.setApplicationLocales(
+ LocaleListCompat.forLanguageTags(PreferencesUtil.getLanguageConfiguration())
+ )
}
context = this.baseContext
setContent {
@@ -86,6 +68,7 @@ class MainActivity : AppCompatActivity() {
InitialEntry(
downloaderViewModel = downloaderViewModel,
modsDownloaderViewModel = modsDownloaderViewModel,
+ playlistPageViewModel = playlistPageViewModel,
isUrlShared = isUrlSharingTriggered
)
}
@@ -94,7 +77,8 @@ class MainActivity : AppCompatActivity() {
handleShareIntent(intent)
}
- //This function is very important. It handles the intent of opening the app from a shared link and put it in the url field
+ //This function is very important.
+ //It handles the intent of opening the app from a shared link and put it in the url field
override fun onNewIntent(intent: Intent) {
handleShareIntent(intent)
super.onNewIntent(intent)
@@ -112,11 +96,9 @@ class MainActivity : AppCompatActivity() {
}
Intent.ACTION_SEND -> {
- intent.getStringExtra(Intent.EXTRA_TEXT)
- ?.let { sharedContent ->
+ intent.getStringExtra(Intent.EXTRA_TEXT)?.let { sharedContent ->
intent.removeExtra(Intent.EXTRA_TEXT)
- matchUrlFromSharedText(sharedContent)
- .let { matchedUrl ->
+ matchUrlFromSharedText(sharedContent).let { matchedUrl ->
if (sharedUrl != matchedUrl) {
sharedUrl = matchedUrl
downloaderViewModel.updateUrl(sharedUrl, true)
@@ -133,18 +115,17 @@ class MainActivity : AppCompatActivity() {
fun setLanguage(locale: String) {
Log.d(TAG, "setLanguage: $locale")
- val localeListCompat =
- if (locale.isEmpty()) LocaleListCompat.getEmptyLocaleList()
- else LocaleListCompat.forLanguageTags(locale)
+ val localeListCompat = if (locale.isEmpty()) LocaleListCompat.getEmptyLocaleList()
+ else LocaleListCompat.forLanguageTags(locale)
App.applicationScope.launch(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(localeListCompat)
}
}
val showInBottomNavigation = mapOf(
- Route.HOME to Icons.Rounded.Download,
- Route.SEARCHER to Icons.Rounded.Search,
- Route.MEDIA_PLAYER to Icons.Rounded.MusicNote
+ Route.DownloaderNavi to Icons.Rounded.Download,
+ Route.SearcherNavi to Icons.Rounded.Search,
+ Route.DownloadTasksNavi to Icons.Rounded.FileDownloadDone,
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/bobbyesp/spowlo/NotificationActionReceiver.kt b/app/src/main/java/com/bobbyesp/spowlo/NotificationActionReceiver.kt
new file mode 100644
index 00000000..4caa29fb
--- /dev/null
+++ b/app/src/main/java/com/bobbyesp/spowlo/NotificationActionReceiver.kt
@@ -0,0 +1,67 @@
+package com.bobbyesp.spowlo
+
+import android.content.BroadcastReceiver
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.bobbyesp.library.SpotDL
+import com.bobbyesp.spowlo.App.Companion.context
+import com.bobbyesp.spowlo.utils.NotificationsUtil
+import com.bobbyesp.spowlo.utils.ToastUtil
+
+class NotificationActionReceiver : BroadcastReceiver() {
+ companion object {
+ private const val TAG = "CancelReceiver"
+ private const val PACKAGE_NAME_PREFIX = "com.bobbyesp.spowlo."
+
+ const val ACTION_CANCEL_TASK = 0
+ const val ACTION_ERROR_REPORT = 1
+
+ const val ACTION_KEY = PACKAGE_NAME_PREFIX + "action"
+ const val TASK_ID_KEY = PACKAGE_NAME_PREFIX + "taskId"
+
+ const val NOTIFICATION_ID_KEY = PACKAGE_NAME_PREFIX + "notificationId"
+ const val ERROR_REPORT_KEY = PACKAGE_NAME_PREFIX + "error_report"
+ }
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent == null) return
+ val notificationId = intent.getIntExtra(NOTIFICATION_ID_KEY, 0)
+ val action = intent.getIntExtra(ACTION_KEY, ACTION_CANCEL_TASK)
+ Log.d(TAG, "onReceive: $action")
+ when (action) {
+ ACTION_CANCEL_TASK -> {
+ val taskId = intent.getStringExtra(TASK_ID_KEY)
+ cancelTask(taskId, notificationId)
+ }
+
+ ACTION_ERROR_REPORT -> {
+ val errorReport = intent.getStringExtra(ERROR_REPORT_KEY)
+ if (!errorReport.isNullOrEmpty())
+ copyErrorReport(errorReport, notificationId)
+ }
+ }
+ }
+
+ private fun cancelTask(taskId: String?, notificationId: Int) {
+ if (taskId.isNullOrEmpty()) return
+ NotificationsUtil.cancelNotification(notificationId)
+ val result = SpotDL.getInstance().destroyProcessById(taskId, true)
+ NotificationsUtil.cancelNotification(notificationId)
+ if (result) {
+ Log.d(TAG, "Task (id:$taskId) was killed.")
+ Downloader.onProcessCanceled(taskId)
+
+ }
+ }
+
+ private fun copyErrorReport(error: String, notificationId: Int) {
+ App.clipboard.setPrimaryClip(
+ ClipData.newPlainText(null, error)
+ )
+ context.let { ToastUtil.makeToastSuspend(it.getString(R.string.error_copied)) }
+ NotificationsUtil.cancelNotification(notificationId)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/bobbyesp/spowlo/features/mod_downloader/data/remote/ModsDownloaderAPI.kt b/app/src/main/java/com/bobbyesp/spowlo/features/mod_downloader/data/remote/ModsDownloaderAPI.kt
index 629b732c..7e810b3e 100644
--- a/app/src/main/java/com/bobbyesp/spowlo/features/mod_downloader/data/remote/ModsDownloaderAPI.kt
+++ b/app/src/main/java/com/bobbyesp/spowlo/features/mod_downloader/data/remote/ModsDownloaderAPI.kt
@@ -20,6 +20,7 @@ import okhttp3.Response
import okio.IOException
import java.io.File
import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
object ModsDownloaderAPI {
@@ -32,48 +33,38 @@ object ModsDownloaderAPI {
private val client = OkHttpClient()
const val TAG = "APKsDownloaderAPI"
- private val requestAPIResponse =
- Request.Builder()
- .url(BASE_URL + ENDPOINT)
- .build()
+ private val requestAPIResponse = Request.Builder().url(BASE_URL + ENDPOINT).build()
@CheckResult
- private suspend fun getAPIResponse(): Result{
- return suspendCoroutine {
+ suspend fun getAPIResponse(): Result {
+ return suspendCoroutine { continuation ->
client.newCall(requestAPIResponse).enqueue(object : Callback {
- override fun onFailure(call: Call, e: IOException) {
- it.resumeWith(Result.failure(e))
- }
-
- override fun onResponse(call: Call, response: Response) {
- val responseData = response.body.string()
- val apiResponse = jsonFormat.decodeFromString(APIResponseDto.serializer(), responseData)
- response.body.close()
- it.resume(Result.success(apiResponse))
- }
- })
+ override fun onResponse(call: Call, response: Response) {
+ val responseData = response.body.string()
+ val apiResponse =
+ jsonFormat.decodeFromString(APIResponseDto.serializer(), responseData)
+ response.body.close()
+ continuation.resume(Result.success(apiResponse))
+ }
+
+ override fun onFailure(call: Call, e: IOException) {
+ continuation.resumeWithException(e)
+ }
+ })
}
}
- suspend fun callModsAPI(): Result {
- return getAPIResponse()
- }
-
- private fun Context.getSpotifyAPK() =
- File(getExternalFilesDir("apk"), "Spotify_Spowlo_Mod.apk")
+ private fun Context.getSpotifyAPK() = File(getExternalFilesDir("apk"), "Spotify_Spowlo_Mod.apk")
suspend fun downloadPackage(
- context: Context = App.context,
- apiResponseDto: APIResponseDto,
- listName: String,
- index: Int
- ): Flow {
- withContext(Dispatchers.IO){
+ context: Context = App.context, apiResponseDto: APIResponseDto, listName: String, index: Int
+ ): Flow {
+ withContext(Dispatchers.IO) {
var selectedList = emptyList()
- when(listName){
+ when (listName) {
"Regular" -> selectedList = apiResponseDto.apps.Regular
"Amoled" -> selectedList = apiResponseDto.apps.AMOLED
"Regular_Cloned" -> selectedList = apiResponseDto.apps.Regular_Cloned
@@ -83,9 +74,7 @@ object ModsDownloaderAPI {
val file = context.getSpotifyAPK()
- val request = Request.Builder()
- .url(selectedList[index].link)
- .build()
+ val request = Request.Builder().url(selectedList[index].link).build()
try {
val response = client.newCall(request).execute()
diff --git a/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/paging/SpotifyApiMediator.kt b/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/paging/SpotifyApiMediator.kt
new file mode 100644
index 00000000..0ad8766a
--- /dev/null
+++ b/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/paging/SpotifyApiMediator.kt
@@ -0,0 +1 @@
+package com.bobbyesp.spowlo.features.spotify_api.data.paging
diff --git a/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/remote/SpotifyApiRequests.kt b/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/remote/SpotifyApiRequests.kt
new file mode 100644
index 00000000..ecdd346b
--- /dev/null
+++ b/app/src/main/java/com/bobbyesp/spowlo/features/spotify_api/data/remote/SpotifyApiRequests.kt
@@ -0,0 +1,195 @@
+package com.bobbyesp.spowlo.features.spotify_api.data.remote
+
+import android.util.Log
+import com.adamratzman.spotify.SpotifyAppApi
+import com.adamratzman.spotify.models.Album
+import com.adamratzman.spotify.models.Artist
+import com.adamratzman.spotify.models.AudioFeatures
+import com.adamratzman.spotify.models.Playlist
+import com.adamratzman.spotify.models.SpotifyPublicUser
+import com.adamratzman.spotify.models.SpotifySearchResult
+import com.adamratzman.spotify.models.Token
+import com.adamratzman.spotify.models.Track
+import com.adamratzman.spotify.spotifyAppApi
+import com.adamratzman.spotify.utils.Market
+import com.bobbyesp.spowlo.BuildConfig
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.Job
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object SpotifyApiRequests {
+
+ private const val clientId = BuildConfig.CLIENT_ID
+ private const val clientSecret = BuildConfig.CLIENT_SECRET
+ private var api: SpotifyAppApi? = null
+ private var token: Token? = null
+
+ private var currentJob: Job? = null
+
+
+ //Pulls the clientId and clientSecret tokens and builds them into an object
+
+ @Provides
+ @Singleton
+ suspend fun provideSpotifyApi(): SpotifyAppApi {
+ if (api == null) {
+ buildApi()
+ }
+ return api!!
+ }
+
+ suspend fun buildApi() {
+ Log.d(
+ "SpotifyApiRequests",
+ "Building API with client ID: $clientId and client secret: $clientSecret"
+ )
+ token = spotifyAppApi(clientId, clientSecret).build().token
+ api = spotifyAppApi(clientId, clientSecret, token!!) {
+ automaticRefresh = true
+ }.build()
+ }
+
+ //Performs Spotify database query for queries related to user information.
+ private suspend fun userSearch(userQuery: String): SpotifyPublicUser? {
+ return provideSpotifyApi().users.getProfile(userQuery)
+ }
+
+ @Provides
+ @Singleton
+ suspend fun provideUserSearch(query: String): SpotifyPublicUser? {
+ return userSearch("bobbyesp")
+ }
+
+ // Performs Spotify database query for queries related to track information.
+ suspend fun searchAllTypes(searchQuery: String): SpotifySearchResult {
+ kotlin.runCatching {
+ provideSpotifyApi().search.searchAllTypes(searchQuery, limit = 50, offset = 1, market = Market.ES)
+ }.onFailure {
+ Log.d("SpotifyApiRequests", "Error: ${it.message}")
+ return SpotifySearchResult()
+ }.onSuccess {
+ return it
+ }
+ return SpotifySearchResult()
+ }
+
+ @Provides
+ @Singleton
+ suspend fun provideSearchAllTypes(query: String): SpotifySearchResult {
+ return searchAllTypes(query)
+ }
+
+ private suspend fun searchTracks(searchQuery: String): List