First commit

This commit is contained in:
RemixDev 2022-08-05 21:28:22 +02:00
commit beff72b380
No known key found for this signature in database
GPG key ID: B33962B465BDB51C
74 changed files with 3935 additions and 0 deletions

35
.gitignore vendored Normal file
View file

@ -0,0 +1,35 @@
.DS_Store
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

56
app/build.gradle Normal file
View file

@ -0,0 +1,56 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
compileSdk 32
defaultConfig {
applicationId "app.deemix.downloader"
minSdk 21
targetSdk 32
versionCode 1
versionName "0.07"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.work:work-multiprocess:2.7.1'
implementation 'androidx.preference:preference:1.1.1'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.annotation:annotation:1.2.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
implementation 'com.squareup.picasso:picasso:2.71828'
implementation 'net.jthink:jaudiotagger:3.0.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

21
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,24 @@
package app.deemix.downloader
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("app.deemix.downloader", appContext.packageName)
}
}

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.deemix.downloader">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DeezerDownloader">
<activity
android:name=".SettingsActivity"
android:exported="false"
android:label="@string/title_activity_settings" />
<activity
android:name=".LoginActivity"
android:exported="false"
android:label="@string/title_activity_login" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,333 @@
package app.deemix.downloader
import android.os.Build
import app.deemix.downloader.types.DeezerUser
import org.json.JSONObject
import org.json.JSONTokener
import java.io.BufferedReader
import java.io.InputStreamReader
import java.lang.Exception
import java.net.*
import javax.net.ssl.HttpsURLConnection
class Deezer {
private var cookieJar: CookieManager = CookieManager()
private var httpHeaders: MutableMap<String, String> = HashMap()
var isLoggedIn = false
var currentUser: DeezerUser? = null
private var childs: ArrayList<DeezerUser> = ArrayList()
private var selectedAccount: Int = 0
private var apiToken: String? = null
init {
httpHeaders["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36"
}
fun apiCall(method: String): JSONObject {
var result: JSONObject
val request = URL("https://api.deezer.com/$method")
with(request.openConnection() as HttpsURLConnection) {
requestMethod = "GET"
doInput = true
// Set Headers
for (key in httpHeaders.keys){
setRequestProperty(key, httpHeaders[key])
}
// Set Cookies
if (cookieJar.cookieStore.cookies.size > 0) {
var cookieText = ""
for (cookie in cookieJar.cookieStore.cookies) {
cookieText += "${cookie.name}=${cookie.value}; "
}
setRequestProperty("Cookie", cookieText)
}
// Save set cookies
val thisHeaderFields: Map<String, List<String>> = headerFields
val cookiesHeader = thisHeaderFields["Set-Cookie"]
if (cookiesHeader != null) {
for (cookie in cookiesHeader) {
val thisCookie = HttpCookie.parse(cookie)
cookieJar.cookieStore.add(URI("https://www.deezer.com"), thisCookie[0])
}
}
BufferedReader(InputStreamReader(inputStream)).use {
result = JSONTokener(it.readText()).nextValue() as JSONObject
}
}
if (result.has("error")){
val error = result.getJSONObject("error")
if (error.has("code")){
val errorCode = error.getInt("code")
if (arrayOf(4, 700).contains(errorCode)){
return apiCall(method)
}
}
throw Exception(error.toString(0).replace("\n", ""))
}
return result
}
fun apiCallGW(method: String, args:String? = null): JSONObject{
if (apiToken == null && method != "deezer.getUserData") apiToken = getToken()
var result: JSONObject
val request = URL("https://www.deezer.com/ajax/gw-light.php?api_version=1.0&api_token=${ if (method == "deezer.getUserData") "null" else apiToken }&input=3&method=$method")
with(request.openConnection() as HttpsURLConnection) {
requestMethod = "POST"
doInput = true
doOutput = args != null
// Set Headers
for (key in httpHeaders.keys){
setRequestProperty(key, httpHeaders[key])
}
// Set Cookies
if (cookieJar.cookieStore.cookies.size > 0) {
var cookieText = ""
for (cookie in cookieJar.cookieStore.cookies) {
cookieText += "${cookie.name}=${cookie.value}; "
}
setRequestProperty("Cookie", cookieText)
}
// Set post body
if (args != null){
setRequestProperty("Content-Type", "application/json; charset=utf-8")
outputStream.write(args.toByteArray())
outputStream.flush()
}
// Save set cookies
val thisHeaderFields: Map<String, List<String>> = headerFields
val cookiesHeader = thisHeaderFields["Set-Cookie"]
if (cookiesHeader != null) {
for (cookie in cookiesHeader) {
val thisCookie = HttpCookie.parse(cookie)
cookieJar.cookieStore.add(URI("https://www.deezer.com"), thisCookie[0])
}
}
BufferedReader(InputStreamReader(inputStream)).use {
result = JSONTokener(it.readText()).nextValue() as JSONObject
}
}
if (result.has("error") && result.get("error").toString() != "[]"){
val error = result.getJSONObject("error").toString()
if (
!(error != "{\"GATEWAY_ERROR\":\"invalid api token\"}" && error != "{\"VALID_TOKEN_REQUIRED\":\"Invalid CSRF token\"}")
){
apiToken = getToken()
return apiCallGW(method, args)
}
if (result.has("payload") && result.getJSONObject("payload").has("FALLBACK")){
val thisArgs = JSONObject(args)
val fallback = result.getJSONObject("payload").getJSONObject("FALLBACK")
for (key in fallback.keys()){
thisArgs.put(key, fallback.get(key))
}
return apiCallGW(method, thisArgs.toString(0).replace("\n", ""))
}
throw Exception(result.getJSONObject("error").toString(0).replace("\n", ""))
}
val unknownResult = result.get("results")
if (unknownResult is JSONObject) result = unknownResult
else {
result = JSONObject()
result.put("data", unknownResult)
}
if (apiToken == null && method == "deezer.getUserData") apiToken = result.getString("checkForm")
return result
}
private fun getToken(): String{
val tokenData = apiCallGW("deezer.getUserData")
return tokenData.getString("checkForm")
}
fun getAccessToken(email: String, password: String): String?{
val clientId = "172365"
val clientSecret = "fb0bec7ccc063dab0417eb7b0d847f34"
var accessToken: String? = null
val hashedPassword = password.toMD5()
val hash = "$clientId$email$hashedPassword$clientSecret".toMD5()
var response: JSONObject
val request = URL("https://api.deezer.com/auth/token?app_id=$clientId&login=$email&password=$hashedPassword&hash=$hash")
with(request.openConnection() as HttpsURLConnection) {
setRequestProperty("User-Agent", httpHeaders["User-Agent"])
BufferedReader(InputStreamReader(inputStream)).use {
response = JSONTokener(it.readText()).nextValue() as JSONObject
}
}
if (response.has("access_token")) accessToken = response.getString("access_token")
if (accessToken == null) logout()
return accessToken
}
fun getArlFromAccessToken(accessToken: String): String? {
val request = URL("https://api.deezer.com/platform/generic/track/3135556")
with(request.openConnection() as HttpsURLConnection) {
setRequestProperty("User-Agent", httpHeaders["User-Agent"])
setRequestProperty("Authorization", "Bearer $accessToken")
// Save set Cookies
val thisHeaderFields: Map<String, List<String>> = headerFields
val cookiesHeader = thisHeaderFields["Set-Cookie"]
if (cookiesHeader != null) {
for (cookie in cookiesHeader) {
val thisCookie = HttpCookie.parse(cookie)
cookieJar.cookieStore.add(URI("https://www.deezer.com"), thisCookie[0])
}
}
}
return apiCallGW("user.getArl").getString("data")
}
fun login(arl: String): Boolean{
val thisArl = arl.trim()
val arlCookie = HttpCookie("arl", thisArl)
arlCookie.domain = ".deezer.com"
arlCookie.path = "/"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
arlCookie.isHttpOnly = true
}
cookieJar.cookieStore.add(URI("https://www.deezer.com"), arlCookie)
var userData = apiCallGW("deezer.getUserData")
if (userData.length() == 0) {
isLoggedIn = false
return isLoggedIn
}
userData = userData.getJSONObject("USER")
if (userData.getInt("USER_ID") == 0){
println("USER_ID = 0")
println(cookieJar.cookieStore.cookies)
isLoggedIn = false
return isLoggedIn
}
postLogin(userData)
changeAccount(0)
isLoggedIn = true
return isLoggedIn
}
fun loginViaEmail(email: String, password: String){
val accessToken = SharedObjects.dz.getAccessToken(email, password) ?: return
val arl = SharedObjects.dz.getArlFromAccessToken(accessToken) ?: return
login(arl)
}
private fun changeAccount(childPos: Int): Pair<DeezerUser, Int> {
var thisChildPos = childPos
if (childs.size-1 < thisChildPos) thisChildPos = 0
currentUser = childs[thisChildPos]
selectedAccount = thisChildPos
httpHeaders["Accept-Language"] = currentUser!!.language
return Pair(currentUser!!, thisChildPos)
}
private fun postLogin(userData: JSONObject) {
childs.clear()
val isFamily = userData.getJSONObject("MULTI_ACCOUNT").getBoolean("ENABLED") && ! userData.getJSONObject("MULTI_ACCOUNT").getBoolean("IS_SUB_ACCOUNT")
if (isFamily){
val deezerChilds = apiCallGW("deezer.getChildAccounts")
for (i in 0 until deezerChilds.length()){
val child = deezerChilds.getJSONObject(i.toString())
childs.add(DeezerUser(userData, child))
}
} else {
childs.add(DeezerUser(userData))
}
}
fun logout() {
cookieJar = CookieManager()
httpHeaders["Accept-Language"] = ""
childs.clear()
currentUser = null
isLoggedIn = false
}
fun getTrackURL(trackToken: String, format: String): String?{
if (!isLoggedIn) return null
if (format == "MP4_RA" && !currentUser!!.canStream.reality) return null
if (format == "FLAC" && !currentUser!!.canStream.lossless) return null
if (format == "MP3_320" && !currentUser!!.canStream.high) return null
if (format == "MP3_128" && !currentUser!!.canStream.standard) return null
var result: JSONObject
val request = URL("https://media.deezer.com/v1/get_url")
with(request.openConnection() as HttpsURLConnection) {
requestMethod = "POST"
doInput = true
doOutput = true
// Set Headers
for (key in httpHeaders.keys){
setRequestProperty(key, httpHeaders[key])
}
// Set Cookies
if (cookieJar.cookieStore.cookies.size > 0) {
var cookieText = ""
for (cookie in cookieJar.cookieStore.cookies) {
cookieText += "${cookie.name}=${cookie.value}; "
}
setRequestProperty("Cookie", cookieText)
}
// Set post body
setRequestProperty("Content-Type", "application/json; charset=utf-8")
outputStream.write("""
{
"license_token": "${currentUser!!.licenseToken}",
"media": [{"type": "FULL", "formats": [{ "cipher": "BF_CBC_STRIPE", "format": "$format"}]}],
"track_tokens": ["$trackToken"]
}
""".trimIndent().toByteArray())
outputStream.flush()
// Save set cookies
val thisHeaderFields: Map<String, List<String>> = headerFields
val cookiesHeader = thisHeaderFields["Set-Cookie"]
if (cookiesHeader != null) {
for (cookie in cookiesHeader) {
val thisCookie = HttpCookie.parse(cookie)
cookieJar.cookieStore.add(URI("https://www.deezer.com"), thisCookie[0])
}
}
BufferedReader(InputStreamReader(inputStream)).use {
result = JSONTokener(it.readText()).nextValue() as JSONObject
}
}
if (result.has("data")){
for (i in 0 until result.getJSONArray("data").length()){
val data: JSONObject = result.getJSONArray("data").getJSONObject(i)
if (data.has("errors")){
println(data.get("errors").toString())
return null
}
return if (data.has("media") && data.getJSONArray("media").length() > 0){
data.getJSONArray("media").getJSONObject(0).getJSONArray("sources").getJSONObject(0).getString("url")
} else {
null
}
}
}
return null
}
}

View file

@ -0,0 +1,98 @@
package app.deemix.downloader
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import app.deemix.downloader.types.DownloadItem
import com.squareup.picasso.Picasso
class DownloadItemAdapter(private val dataSet: Map<String, DownloadItem>, private val order: ArrayList<String>) :
RecyclerView.Adapter<DownloadItemAdapter.DownloadViewHolder>() {
class DownloadViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.downloadTitle)
val artist: TextView = view.findViewById(R.id.downloadArtist)
val progress: TextView = view.findViewById(R.id.downloadProgress)
val quality: TextView = view.findViewById(R.id.qualityLabel)
val fails: TextView = view.findViewById(R.id.downloadFails)
val bar: ProgressBar = view.findViewById(R.id.downloadBar)
val cover: ImageView = view.findViewById(R.id.coverImage)
val status: ImageView = view.findViewById(R.id.downloadStatus)
private fun statusIcon(status: String): Int{
return when (status){
"inQueue" -> R.drawable.ic_baseline_list_24
"downloading" -> R.drawable.ic_baseline_arrow_downward_24
"downloaded" -> R.drawable.ic_baseline_done_24
"downloadedWithErrors" -> R.drawable.ic_baseline_warning_24
"failed" -> R.drawable.ic_baseline_error_24
else -> R.drawable.ic_baseline_list_24
}
}
fun updateDownloadItem(downloadItem: DownloadItem){
progress.text = "${downloadItem.downloaded+downloadItem.failed}/${downloadItem.size}"
status.setImageResource(statusIcon(downloadItem.status))
if (downloadItem.failed > 0){
fails.text = "${downloadItem.failed} (!)"
fails.visibility = View.VISIBLE
} else {
fails.visibility = View.GONE
}
when {
downloadItem.progress == -1 -> {
bar.isIndeterminate = true
}
downloadItem.progress >= 100 -> {
bar.isIndeterminate = false
bar.progress = 100
}
else -> {
bar.isIndeterminate = false
bar.progress = downloadItem.progress
}
}
}
}
// Create new views (invoked by the layout manager)
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DownloadViewHolder {
// Create a new view, which defines the UI of the list item
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.download_item, viewGroup, false)
return DownloadViewHolder(view)
}
private fun qualityText(quality: String): String{
return when (quality) {
"9" -> "FLAC"
"3" -> "320"
"1" -> "128"
else -> "MISC"
}
}
// Replace the contents of a view (invoked by the layout manager)
override fun onBindViewHolder(downloadViewHolder: DownloadViewHolder, position: Int) {
val downloadItem = dataSet[order[position]]!!
downloadViewHolder.title.text = downloadItem.title
downloadViewHolder.artist.text = downloadItem.artist
downloadViewHolder.quality.text = qualityText(downloadItem.bitrate)
Picasso.get()
.load(downloadItem.cover)
.placeholder(R.drawable.no_cover)
.error(R.drawable.no_cover)
.into(downloadViewHolder.cover)
downloadViewHolder.updateDownloadItem(downloadItem)
}
// Return the size of your dataset (invoked by the layout manager)
override fun getItemCount() = order.size
}

View file

@ -0,0 +1,369 @@
package app.deemix.downloader
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.os.Environment
import android.support.annotation.RequiresApi
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.PRIORITY_MIN
import androidx.work.*
import app.deemix.downloader.SharedObjects.dz
import app.deemix.downloader.SharedObjects.queue
import app.deemix.downloader.Utils.createBlowfishKey
import app.deemix.downloader.Utils.decryptBlowfish
import app.deemix.downloader.types.Album
import app.deemix.downloader.types.DownloadItem
import app.deemix.downloader.types.Track
import org.jaudiotagger.audio.AudioFileIO
import org.jaudiotagger.tag.FieldKey
import org.jaudiotagger.tag.images.ArtworkFactory
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.URL
import javax.net.ssl.HttpsURLConnection
import kotlin.math.roundToInt
import java.lang.Exception
class DownloadWorker(private val appContext: Context, workerParams: WorkerParameters):
CoroutineWorker(appContext, workerParams) {
companion object {
private var uuid = ""
private lateinit var currentItem: DownloadItem
private var totalSize = 0
private var progressNext: Double = 0.00
private var bitrate: String = "0"
private var title = ""
val NOTIFICATION_ID = 42
lateinit var notification: Notification
}
private fun createNotification(): Notification {
val channelId =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel("my_service", "My Background Service")
} else {
// If earlier version channel ID is not used
// https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context)
""
}
val notificationBuilder = NotificationCompat.Builder(appContext, channelId )
return notificationBuilder.setOngoing(true)
.setSmallIcon(R.drawable.ic_baseline_get_app_24)
.setPriority(PRIORITY_MIN)
.setCategory(Notification.CATEGORY_SERVICE)
.setContentTitle(title)
.setTicker(title)
.build()
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(channelId: String, channelName: String): String{
val chan = NotificationChannel(channelId,
channelName, NotificationManager.IMPORTANCE_NONE)
chan.lightColor = Color.BLUE
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
val service = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
service.createNotificationChannel(chan)
return channelId
}
override suspend fun doWork(): Result {
// Get the UUID
val thisUuid = inputData.getString("uuid")
if (thisUuid == null){
println("[DOWNLOAD ERROR] No UUID found")
return Result.failure()
}
uuid = thisUuid
currentItem = queue[uuid] ?: return Result.success()
currentItem.progress = 0
currentItem.downloaded = 0
currentItem.failed = 0
currentItem.errors = ArrayList()
currentItem.status = "downloading"
title = "${currentItem.artist} - ${currentItem.title}"
// Get the data from disk
val downloadObject: JSONObject
try {
appContext.openFileInput("$uuid.json").use { stream ->
val text = stream.bufferedReader().use {
it.readText()
}
downloadObject = JSONObject(text)
}
} catch (e: Exception){
println("[DOWNLOAD ERROR] Error: $e")
return Result.failure()
}
downloadObject.put("status", currentItem.status)
appContext.openFileOutput("$uuid.json", Context.MODE_PRIVATE).use { output ->
output.write(downloadObject.toString().toByteArray())
}
notification = createNotification()
sendUpdate()
// Get the downloadData
var downloadData: JSONObject? = null
val downloadType: String = downloadObject.getString("itemType")
if (downloadType == "single") downloadData = downloadObject.getJSONObject("single")
else if (downloadType == "collection") downloadData = downloadObject.getJSONObject("collection")
if (downloadData == null) {
println("[DOWNLOAD ERROR] No download Data")
return Result.failure()
}
totalSize = downloadObject.getInt("size")
progressNext = 0.00
bitrate = downloadObject.getString("bitrate")
// Start Download
if (downloadType == "single"){
val track: Track = Track().restoreObject(downloadData.getJSONObject("trackAPI"))
val album: Album? = if (downloadData.has("albumAPI")) Album().restoreObject(downloadData.getJSONObject("albumAPI")) else null
downloadWrapper(track, album)
} else if (downloadType == "collection"){
val album: Album? = if (downloadData.has("albumAPI")) Album().restoreObject(downloadData.getJSONObject("albumAPI")) else null
for (i in 0 until downloadData.getJSONArray("tracks").length()){
val track: Track = Track().restoreObject(downloadData.getJSONArray("tracks").getJSONObject(i))
downloadWrapper(track, album)
}
}
currentItem.progress = 100
downloadObject.put("progress", currentItem.progress)
downloadObject.put("downloaded", currentItem.downloaded)
downloadObject.put("failed", currentItem.failed)
downloadObject.put("errors", JSONArray(currentItem.errors))
currentItem.status = "downloaded"
if (currentItem.failed == currentItem.downloaded + currentItem.failed){
currentItem.status = "failed"
} else if (currentItem.failed > 0){
currentItem.status = "downloadedWithErrors"
}
downloadObject.put("status", currentItem.status)
sendUpdate()
appContext.openFileOutput("$uuid.json", Context.MODE_PRIVATE).use { output ->
output.write(downloadObject.toString().toByteArray())
}
return Result.success()
}
private suspend fun sendUpdate(){
if (isStopped) return
setProgress( Data.Builder()
.putString("uuid", uuid)
.putInt("progress", currentItem.progress)
.putInt("downloaded", currentItem.downloaded)
.putInt("failed", currentItem.failed)
.putStringArray("errors", currentItem.errors.toTypedArray())
.putString("status", currentItem.status)
.build()
)
setForeground(ForegroundInfo(NOTIFICATION_ID, notification))
}
private suspend fun download(track: Track, album: Album? = null){
// Get Tags
val sngId = track.id
if (album != null) track.album = album
if (track.isLocal){
// Only to get refreshed track tokens
val trackGW = dz.apiCallGW("song.getData", "{\"SNG_ID\": $sngId}")
track.parseGW(trackGW)
} else {
val trackPageGW = dz.apiCallGW("deezer.pageTrack", "{\"SNG_ID\": $sngId}")
track.parseGWPage(trackPageGW)
// Only standard API has track bpm
if (track.bpm == null){
val trackAPI = dz.apiCall("track/$sngId")
if (trackAPI.has("bpm")) {
track.bpm = trackAPI.getString("bpm").toFloat()
}
}
// Only standard API has album genres
if (track.album.genres == null){
try {
val albumAPI = dz.apiCall("album/${track.album.id}")
track.album.parseAPI(albumAPI)
} catch (_: Exception){}
}
// Only gw api has album discTotal
if (track.album.discTotal == null){
try {
val albumAPI = dz.apiCallGW("album.getData", "{\"ALB_ID\": ${track.album.id}}")
track.album.parseGW(albumAPI)
} catch (_: Exception){}
}
if (track.album.date != null && track.date == null) track.date = track.album.date
}
track.cleanUp()
// Get the correct bitrate
val thisBitrate = bitrate
val extension = when (thisBitrate){
"9" -> "flac"
else -> "mp3"
}
val format = when (thisBitrate){
"9" -> "FLAC"
"3" -> "MP3_320"
"1" -> "MP3_128"
else -> "MP3_128"
}
// Apply settings
// Generate filename and filepath from metadata
val filePath = "${Environment.getExternalStorageDirectory().absolutePath}/Music/Deezer"
val fileName = "${track.artist.name} - ${track.title}"
val finalFilePath = "$filePath/$fileName.$extension"
// Create the download folder if it doesn't exists
File(filePath).mkdirs()
// Generate cover URLs
val coverSize = 800
track.album.embeddedCoverURL = track.album.pic.getURL(coverSize, "jpg-80")
// Download and cache the coverart
val coverTempFile = File("${appContext.cacheDir}/alb${track.album.id}_$coverSize.jpg")
if (!coverTempFile.exists()) {
val thisAlbumArt: ByteArray? = URL(track.album.embeddedCoverURL).readBytes()
if (thisAlbumArt != null && !coverTempFile.exists()) {
coverTempFile.createNewFile()
coverTempFile.writeBytes(thisAlbumArt)
}
}
track.album.embeddedCoverPath = coverTempFile.absolutePath
// Download the track
val trackURL: String = dz.getTrackURL(track.trackToken!!, format)
?: throw Exception("No URL FOUND")
val blowfishKey = createBlowfishKey(sngId)
val request = URL(trackURL)
with(request.openConnection() as HttpsURLConnection) {
connect()
val outputFile = File(finalFilePath)
outputFile.createNewFile()
var place = 0
val chunk = ByteArray(16384)
var modifiedStream = ByteArray(0)
while (place < contentLength){
val size = inputStream.read(chunk) // Returns the actual size of the chunk
var cutChunk = chunk.copyOfRange(0, size) // Cuts down the chunk to the actual size
modifiedStream += cutChunk
while (modifiedStream.size >= 2048 * 3){
var decryptedChunk = ByteArray(0)
val decryptingChunk = modifiedStream.copyOfRange(0, 2048 * 3)
modifiedStream = modifiedStream.copyOfRange(2048 * 3, modifiedStream.size)
if (decryptingChunk.size > 2048){
decryptedChunk = decryptBlowfish(decryptingChunk.copyOfRange(0, 2048), blowfishKey)
decryptedChunk += decryptingChunk.copyOfRange(2048, decryptingChunk.size)
}
outputFile.appendBytes(decryptedChunk)
}
place += size
updateProgress(size, contentLength)
}
if (modifiedStream.size >= 2048) {
var decryptedChunk = decryptBlowfish(modifiedStream.copyOfRange(0, 2048), blowfishKey)
decryptedChunk += modifiedStream.copyOfRange(2048, modifiedStream.size)
outputFile.appendBytes(decryptedChunk)
} else {
outputFile.appendBytes(modifiedStream)
}
}
// Add tags to the track
if (!track.isLocal)
tagFile(finalFilePath, track)
}
private fun tagFile(finalFilePath: String, track: Track) {
val f = AudioFileIO.read(File(finalFilePath))
val tag = f.createDefaultTag()
tag.setField(FieldKey.TITLE, track.title)
for (artist in track.artists){
tag.addField(FieldKey.ARTIST, artist)
}
tag.setField(FieldKey.ALBUM, track.album.title)
for (artist in track.album.artists){
tag.addField(FieldKey.ALBUM_ARTIST, artist)
}
tag.setField(FieldKey.TRACK, track.trackNumber)
tag.setField(FieldKey.TRACK_TOTAL, track.album.trackTotal)
tag.setField(FieldKey.DISC_NO, track.discNumber)
tag.setField(FieldKey.TRACK_TOTAL, track.album.discTotal)
if (track.album.genres != null){
for (genre in track.album.genres!!){
tag.addField(FieldKey.GENRE, genre)
}
}
if (track.album.date != null){
tag.setField(FieldKey.YEAR, track.album.date!!.toString())
}
// TODO: Add TLEN
if (track.bpm != null && track.bpm != 0f) tag.setField(FieldKey.BPM, track.bpm.toString())
if (!track.album.label.isNullOrEmpty()) tag.setField(FieldKey.RECORD_LABEL, track.album.label)
tag.setField(FieldKey.ISRC, track.isrc)
if (!track.album.barcode.isNullOrEmpty()) tag.setField(FieldKey.BARCODE, track.album.barcode)
// TODO: Add explicit
// TODO: Add replaygain
// TODO: Add unsync lyrics USLT
// TODO: Add sync lyrics SYLT
// TODO: Add involved people and composer
tag.setField(FieldKey.COPYRIGHT, track.album.copyright)
// TODO: Add compilation check IS_COMPILATION
// TODO: Add source and sourceid
// TODO: Add rating
val artwork = ArtworkFactory.createArtworkFromFile(File(track.album.embeddedCoverPath))
tag.setField(artwork)
f.tag = tag
f.commit()
}
private suspend fun updateProgress(chunkSize: Int, fileSize: Int){
progressNext += ((chunkSize.toDouble() / fileSize.toDouble()) / totalSize.toDouble()) * 100.00
if (progressNext.roundToInt() != currentItem.progress && progressNext.roundToInt() % 2 == 0){
currentItem.progress = progressNext.roundToInt()
sendUpdate()
}
}
private suspend fun downloadWrapper(track: Track, album: Album? = null){
try {
download(track, album)
currentItem.downloaded += 1
} catch (e: Exception){
currentItem.failed += 1
currentItem.errors.add(e.toString())
println(e)
Log.e("deemix", "DownloadeError: ", e)
}
}
}

View file

@ -0,0 +1,105 @@
package app.deemix.downloader
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import app.deemix.downloader.SharedObjects.dz
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class LoginActivity : AppCompatActivity() {
private lateinit var loginButton: Button
private lateinit var emailField: EditText
private lateinit var passwordField: EditText
private lateinit var loading: ProgressBar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.login_activity)
loginButton = findViewById<Button>(R.id.loginButton)
emailField = findViewById<EditText>(R.id.emailField)
passwordField = findViewById<EditText>(R.id.passwordField)
loading = findViewById<ProgressBar>(R.id.loginLoading)
loginButton.setOnClickListener {
if (!emailField.text.isNullOrBlank() && !passwordField.text.isNullOrBlank()){
GlobalScope.launch(Dispatchers.IO) {
toggleLogin(false)
val email = emailField.text.toString()
val password = passwordField.text.toString()
val accessToken = dz.getAccessToken(email, password)
if (accessToken == null) {
showToast("Couldn't retrieve accessToken")
toggleLogin(true)
return@launch
}
val arl = dz.getArlFromAccessToken(accessToken)
if (arl == null) {
showToast("Couldn't retrieve arl")
toggleLogin(true)
return@launch
}
val loggedIn = dz.login(arl)
if (loggedIn){
saveLogin(accessToken, arl)
val thisIntent = Intent(this@LoginActivity, MainActivity::class.java)
thisIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_TASK_ON_HOME or Intent.FLAG_ACTIVITY_CLEAR_TASK
if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain"){
thisIntent.action = Intent.ACTION_SEND
thisIntent.type = "text/plain"
thisIntent.putExtra(Intent.EXTRA_TEXT, intent.getStringExtra(Intent.EXTRA_TEXT))
}
this@LoginActivity.startActivity(thisIntent)
this@LoginActivity.finish()
} else {
showToast("Couldn't login")
toggleLogin(true)
return@launch
}
}
}
}
toggleLogin(true)
}
private fun saveLogin(accessToken: String, arl: String) {
openFileOutput("login", Context.MODE_PRIVATE).use { output ->
output.write("$accessToken\n$arl".toByteArray())
}
}
private fun toggleLogin(value: Boolean){
runOnUiThread {
if (value) {
loginButton.isEnabled = true
loading.visibility = View.GONE
} else {
loginButton.isEnabled = false
loading.visibility = View.VISIBLE
}
}
}
private fun showToast(text: String){
runOnUiThread {
Toast.makeText(
this, text,
Toast.LENGTH_LONG
).show()
}
}
}

View file

@ -0,0 +1,626 @@
package app.deemix.downloader
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.webkit.URLUtil
import android.widget.EditText
import android.widget.ImageButton
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Observer
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.work.*
import app.deemix.downloader.SharedObjects.dz
import app.deemix.downloader.SharedObjects.fullQueue
import app.deemix.downloader.SharedObjects.queue
import app.deemix.downloader.Utils.getFinalURL
import app.deemix.downloader.types.*
import app.deemix.downloader.types.Collection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
import java.net.URL
class MainActivity : AppCompatActivity() {
private lateinit var adapter: DownloadItemAdapter
private var constraints: Constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).setRequiresStorageNotLow(true).build()
private val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE)
private var hasBeenInit: Boolean = false
private lateinit var sharedPreferences: SharedPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
println("onCreate")
// Set layout to main activity
setContentView(R.layout.activity_main)
// Get layout components
val downloadInputBox = findViewById<EditText>(R.id.downloadInputBox)
val downloadQueueView = findViewById<RecyclerView>(R.id.downloadQueue)
val settingsButton = findViewById<ImageButton>(R.id.settingsButton)
// Create the download queue list with the Recycler View
downloadQueueView.layoutManager = LinearLayoutManager(this)
val itemTouchHelperCallback = object :
ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return false
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
val uuid = fullQueue[position]
GlobalScope.launch(Dispatchers.IO) {
removeFromQueue(uuid, position)
}
}
}
val itemTouchHelper = ItemTouchHelper(itemTouchHelperCallback)
itemTouchHelper.attachToRecyclerView(downloadQueueView)
adapter = DownloadItemAdapter(queue, fullQueue)
downloadQueueView.adapter = adapter
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
// Open settings when clicking the button
settingsButton.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
// Add deezer links to queue on downloadInputBox enter key press
downloadInputBox.setOnKeyListener(View.OnKeyListener { v, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
var link = downloadInputBox.text.toString()
downloadInputBox.setText("") // Clear the text box
if (URLUtil.isValidUrl(link) and link.contains("deezer")){
link = link.replace("http://", "https://")
GlobalScope.launch(Dispatchers.IO) {
addUrlToQueue(link)
}
}
// Hide the keyboard
val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(v.windowToken, 0)
// Remove focus
downloadInputBox.clearFocus()
return@OnKeyListener true
}
false
})
// Listener for the download queue
WorkManager.getInstance(this)
.getWorkInfosForUniqueWorkLiveData("downloadQueue")
.observe(this, Observer { listOfWorkInfo: List<WorkInfo>? ->
if (listOfWorkInfo == null || listOfWorkInfo.isEmpty()) return@Observer
var currentItem: WorkInfo? = null
for (workInfo in listOfWorkInfo){
if (workInfo.state == WorkInfo.State.RUNNING){
currentItem = workInfo
break
}
}
if (currentItem == null) return@Observer
val update = currentItem.progress
val uuid = update.getString("uuid")
val position = fullQueue.indexOf(uuid)
val currentDownloadItem = queue[uuid] ?: return@Observer
currentDownloadItem.progress = update.getInt("progress", 0)
currentDownloadItem.failed = update.getInt("failed", 0)
currentDownloadItem.downloaded = update.getInt("downloaded", 0)
currentDownloadItem.status = update.getString("status").toString()
if (currentDownloadItem.status != "inQueue" && currentDownloadItem.status != "downloading"){
currentDownloadItem.progress = 100
}
val viewHolder = downloadQueueView.findViewHolderForAdapterPosition(position)
if (viewHolder != null){
(viewHolder as DownloadItemAdapter.DownloadViewHolder).updateDownloadItem(currentDownloadItem)
}
})
setupPermissions()
}
private fun setupPermissions() {
val shouldAskPermission: ArrayList<String> = ArrayList()
for (permission in permissions){
val hasPermission = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
println("$permission, $hasPermission")
if (!hasPermission){
shouldAskPermission.add(permission)
}
}
if (shouldAskPermission.size > 0) {
val builder = AlertDialog.Builder(this)
builder.setMessage("Permission to write and read to storage is required by this app.")
.setTitle("Permission required")
builder.setPositiveButton("OK") { _, _ ->
ActivityCompat.requestPermissions(this, shouldAskPermission.toTypedArray(), 1)
}
val dialog = builder.create()
dialog.show()
} else {
attemptAutoLogin()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1) {
var accepted = true
for (grant in grantResults){
accepted = accepted && grant == PackageManager.PERMISSION_GRANTED
}
if (!accepted) {
val builder = AlertDialog.Builder(this)
builder.setMessage("The app can't work if it can't write to the storage")
.setTitle("Permissions not granted")
builder.setPositiveButton("OK") { _, _ ->
finishAndRemoveTask()
}
val dialog = builder.create()
dialog.show()
} else {
attemptAutoLogin()
}
}
}
private fun attemptAutoLogin(){
GlobalScope.launch(Dispatchers.IO) {
if (dz.isLoggedIn){
init()
} else {
// Try retrieving the login data
var loginText: String
try {
openFileInput("login").use { stream ->
loginText = stream.bufferedReader().use {
it.readText()
}
}
} catch (e: Exception){ loginText = "" }
// No login file, show login fragment
if (loginText == ""){
showLoginActivity()
return@launch
}
// Parse the login text file
val splitLogin = loginText.split("\n")
val accessToken: String = splitLogin[0]
var arl: String = splitLogin[1]
// Try to login
var loggedIn = dz.login(arl)
if (loggedIn) {
init(true)
return@launch
}
// Try to get a new arl from accessToken
if (accessToken != ""){
val testArl = dz.getArlFromAccessToken(accessToken)
if (testArl == null){
showLoginActivity()
return@launch
}
arl = testArl
loggedIn = dz.login(arl)
if (loggedIn) {
saveLogin(accessToken, arl) // if the arl changed, save it
init(true)
return@launch
}
}
// Login failed, remove the file and show the login form
forgetLogin()
showLoginActivity()
}
}
}
private fun showLoginActivity(){
val thisIntent = Intent(this, LoginActivity::class.java)
thisIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_TASK_ON_HOME or Intent.FLAG_ACTIVITY_CLEAR_TASK
if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain"){
thisIntent.action = Intent.ACTION_SEND
thisIntent.type = "text/plain"
thisIntent.putExtra(Intent.EXTRA_TEXT, intent.getStringExtra(Intent.EXTRA_TEXT))
}
startActivity(thisIntent)
finish()
}
private fun saveLogin(accessToken: String, arl: String) {
openFileOutput("login", Context.MODE_PRIVATE).use { output ->
output.write("$accessToken\n$arl".toByteArray())
}
}
private fun forgetLogin(){
deleteFile("login")
}
private fun init(justLoggedIn: Boolean = false){
if (!hasBeenInit){
// Restore the queue
if (justLoggedIn) {
runOnUiThread {
Toast.makeText(
this, "Logged in as ${dz.currentUser!!.name}",
Toast.LENGTH_LONG
).show()
}
}
restoreQueue()
initSettings()
// Add deezer links to queue on intent action send text
if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain"){
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val url = "https://${text?.substringAfterLast("https://")}"
if (URLUtil.isValidUrl(url) and url.contains("deezer")){
addUrlToQueue(url)
}
}
hasBeenInit = true
}
runOnUiThread { findViewById<ConstraintLayout>(R.id.loadingScreen).visibility = View.GONE }
}
private fun initSettings() {
if (sharedPreferences.getString("download_quality", "0") == "0"){
var quality = 1
if (dz.currentUser!!.canStream.high) quality = 3
if (dz.currentUser!!.canStream.lossless) quality = 9
sharedPreferences.edit()
.putString("download_quality", quality.toString())
.apply()
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onResume(){
super.onResume()
adapter.notifyDataSetChanged()
}
private fun addUrlToQueue(url: String){
// Extend and clean the URL if needed
var link = url
if (link.contains("deezer.page.link")) link = getFinalURL(link)
if (link.contains("?")) link = link.substringBefore('?')
if (link.contains("&")) link = link.substringBefore('&')
if (link.endsWith("/")) link = link.substring(0, link.length-1)
// Find the download type
var downloadType: String? = null
when {
"/track/" in link -> downloadType = "track"
"/album/" in link -> downloadType = "album"
//"/artist/" in link -> downloadType = "artist"
//"/playlist/" in link -> downloadType = "playlist"
}
if (downloadType == null){
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Type not recognized!",
Toast.LENGTH_SHORT
).show()
}
return
}
// Find the download ID
val downloadId: String? = """/$downloadType/(.+)""".toRegex().find(link)?.groups?.get(1)?.value
if (downloadId == null) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Couldn't find the id!",
Toast.LENGTH_SHORT
).show()
}
return
}
// Generate the download item
val downloadItem: DownloadItem
try {
downloadItem = when (downloadType) {
"track" -> generateTrackItem(downloadId)
"album" -> generateAlbumItem(downloadId)
//"artist" -> downloadItem = generateArtistItem(downloadId)
//"playlist" -> downloadItem = generatePlaylistItem(downloadId)
else -> throw Exception("Type not reconized")
}
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Error while trying to generate the download item!\n$e",
Toast.LENGTH_SHORT
).show()
}
Log.e("deemix", e.message, e)
return
}
downloadItem.bitrate = sharedPreferences.getString("download_quality", "1")!!
downloadItem.generateUUID()
// Check if item is already in queue
if (queue.keys.contains(downloadItem.uuid)){
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Already in queue!",
Toast.LENGTH_SHORT
).show()
}
return
}
// Add item to viewable queue
downloadItem.status = "inQueue"
fullQueue.add(downloadItem.uuid)
queue[downloadItem.uuid] = downloadItem.minimalClone()
runOnUiThread {
adapter.notifyItemChanged(fullQueue.indexOf(downloadItem.uuid))
}
openFileOutput("queue", Context.MODE_PRIVATE).use { output ->
output.write(TextUtils.join(";", fullQueue).toByteArray())
}
openFileOutput("${downloadItem.uuid}.json", Context.MODE_PRIVATE).use { output ->
output.write(downloadItem.toString().toByteArray())
}
// Add to actual queue
addToQueue(downloadItem.uuid)
}
private fun isAlreadyInQueue(uuid: String): Boolean{
val jobs = WorkManager.getInstance(this).getWorkInfosByTag(uuid).get()
return jobs.size > 0
}
private fun addToQueue(uuid: String) {
val inputData: Data = Data.Builder()
.putString("uuid", uuid)
.build()
val downloadWork: OneTimeWorkRequest = OneTimeWorkRequestBuilder<DownloadWorker>()
.setInputData(inputData)
.setConstraints(constraints)
.addTag(uuid)
.build()
WorkManager
.getInstance(this)
.enqueueUniqueWork(
"downloadQueue",
ExistingWorkPolicy.APPEND,
downloadWork
)
}
private fun removeFromQueue(uuid: String, position: Int){
fullQueue.removeAt(position)
runOnUiThread { adapter.notifyItemRemoved(position) }
openFileOutput("queue", Context.MODE_PRIVATE).use { output ->
output.write(TextUtils.join(";", fullQueue).toByteArray())
}
val deletedItem = queue[uuid] ?: return
if (deletedItem.status == "downloading" || deletedItem.status == "inQueue"){
WorkManager.getInstance(this).cancelAllWorkByTag(uuid)
runOnUiThread { Toast.makeText(
this@MainActivity,
"Removed $uuid from queue",
Toast.LENGTH_SHORT
).show() }
}
deleteFile("$uuid.json")
queue.remove(uuid)
WorkManager.getInstance(this).pruneWork()
}
private fun restoreQueue() {
fullQueue.clear()
var queueText: String
try {
openFileInput("queue").use { stream ->
queueText = stream.bufferedReader().use {
it.readText()
}
}
} catch (e: Exception){
queueText = ""
}
val thisQueue = queueText.split(';')
for (uuid in thisQueue){
if (uuid.isBlank()) continue
fullQueue.add(uuid)
var downloadObject: JSONObject
try {
openFileInput("$uuid.json").use { stream ->
val text = stream.bufferedReader().use {
it.readText()
}
downloadObject = JSONObject(text)
}
} catch (e: Exception){
fullQueue.remove(uuid)
continue
}
var downloadItem: DownloadItem? = null
if (downloadObject.getString("itemType") == "single") downloadItem = Single(downloadObject)
else if (downloadObject.getString("itemType") == "collection") downloadItem = Collection(downloadObject)
if (downloadItem != null){
queue[uuid] = downloadItem.minimalClone()
if (downloadItem.status == "inQueue" && !isAlreadyInQueue(downloadItem.uuid)){
addToQueue(downloadItem.uuid)
}
runOnUiThread {adapter.notifyItemChanged(fullQueue.indexOf(uuid))}
}
}
}
private fun generateTrackItem(downloadId: String, track: Track? = null, album: Album? = null): DownloadItem {
val item = Single()
val thisTrack: Track
if (track == null) {
thisTrack = Track()
if (downloadId.toInt() < 0){
val trackAPI = dz.apiCallGW("song.getData", "{\"SNG_ID\": $downloadId}")
thisTrack.parseGW(trackAPI)
} else {
val trackAPI = dz.apiCall("track/$downloadId")
thisTrack.parseAPI(trackAPI)
}
} else {
thisTrack = track
}
item.id = thisTrack.id
item.type = "track"
item.title = thisTrack.title!!
item.artist = thisTrack.artist.name
item.explicit = thisTrack.explicit == true
item.cover = thisTrack.album.pic.getURL(72, "jpg-80")
val single = JSONObject()
single.put("trackAPI", thisTrack.toJSON())
if (album != null) single.put("albumAPI", album.toJSON())
item.single = single
return item
}
private fun generateAlbumItem(downloadId: String, rootArtist: Artist? = null): DownloadItem {
val item = Collection()
val thisAlbum = Album()
var thisId = downloadId
var albumAPI: JSONObject? = null
if (thisId.startsWith("upc")){
val upcs = ArrayList<String>()
upcs.add(thisId.substring(4))
upcs.add(upcs[0].toInt().toString()) // Try UPC without leading zeros as well
var lastError = Exception()
for (upc in upcs){
try {
albumAPI = dz.apiCall("album/upc:$upc")
break
} catch (e: Exception){
lastError = e
albumAPI = null
}
}
if (albumAPI == null){
throw Exception("Generation Error: $thisId, $lastError")
}
thisId = albumAPI.getString("id")
} else {
albumAPI = dz.apiCall("album/$thisId")
}
// Get extra info about album
// This saves extra api calls when downloading
val albumGW: JSONObject = dz.apiCallGW("album.getData", "{\"ALB_ID\": $thisId}")
thisAlbum.parseAPI(albumAPI)
thisAlbum.parseGW(albumGW)
thisAlbum.rootArtist = rootArtist
var songs = JSONArray()
var songsFromGW = false
val data = albumAPI.getJSONObject("tracks")
if (data.length() == thisAlbum.trackTotal!!.toInt()){
songs = data.getJSONArray("data")
}
if (songs.length() == 0){
val body = dz.apiCallGW("song.getListByAlbum", "{\"ALB_ID\": $thisId, \"nb\": -1}")
songs = body.getJSONArray("data")
songsFromGW = true
}
thisAlbum.trackTotal = songs.length().toString()
val tracks = JSONArray()
for (i in 0 until songs.length()) {
val currentTrackAPI = songs.getJSONObject(i)
val track = Track()
if (songsFromGW) track.parseGW(currentTrackAPI)
else track.parseAPI(currentTrackAPI)
track.position = i+1
tracks.put(track.toJSON())
}
if (tracks.length() == 1){
val singleTrack = Track().restoreObject(tracks.getJSONObject(0))
return generateTrackItem(singleTrack.id, singleTrack, thisAlbum)
}
item.id = thisAlbum.id
item.type = "album"
item.title = thisAlbum.title
item.artist = thisAlbum.artist.name
item.explicit = thisAlbum.explicit == true
item.size = songs.length()
item.cover = thisAlbum.pic.getURL(72, "jpg-80")
val collection = JSONObject()
collection.put("tracks", tracks)
collection.put("albumAPI", thisAlbum.toJSON())
item.collection = collection
return item
}
/*
private fun generateArtistItem(downloadId: String): DownloadItem {
val item = DownloadItem()
return item
}
private fun generatePlaylistItem(downloadId: String): DownloadItem {
val item = DownloadItem()
return item
}
*/
}

View file

@ -0,0 +1,77 @@
package app.deemix.downloader
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Bundle
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.preference.PreferenceFragmentCompat
import app.deemix.downloader.SharedObjects.dz
import app.deemix.downloader.types.DeezerQuality
import com.squareup.picasso.Picasso
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.net.URL
class SettingsActivity : AppCompatActivity() {
private lateinit var loggedInPanel:ConstraintLayout
private lateinit var userAvatar:ImageView
private lateinit var userName:TextView
private lateinit var userInfo:TextView
private lateinit var logoutButton:Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.settings_activity)
supportFragmentManager
.beginTransaction()
.replace(R.id.preferencesFragment, SettingsFragment())
.commit()
supportActionBar?.setDisplayHomeAsUpEnabled(true)
loggedInPanel = findViewById<ConstraintLayout>(R.id.loggedInSection)
userAvatar = findViewById<ImageView>(R.id.userAvatar)
userName = findViewById<TextView>(R.id.userName)
userInfo = findViewById<TextView>(R.id.userInfo)
logoutButton = findViewById<Button>(R.id.logoutButton)
logoutButton.setOnClickListener {
GlobalScope.launch(Dispatchers.IO) {
dz.logout()
deleteFile("login")
val intent = Intent(this@SettingsActivity, LoginActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_TASK_ON_HOME or Intent.FLAG_ACTIVITY_CLEAR_TASK
this@SettingsActivity.startActivity(intent)
this@SettingsActivity.finish()
}
}
updateLoginPanels()
}
fun getDisplaySubscription(canStream: DeezerQuality): String{
if (canStream.lossless) return "Hi-Fi"
if (canStream.high) return "Premium"
return "Free"
}
fun updateLoginPanels(){
if (dz.isLoggedIn){
userName.text = dz.currentUser!!.name
userInfo.text = "${getDisplaySubscription(dz.currentUser!!.canStream)} | ${dz.currentUser!!.country}"
Picasso.get().load("https://e-cdns-images.dzcdn.net/images/user/${dz.currentUser!!.picture}/125x125-000000-80-0-0.jpg")
.into(userAvatar)
}
}
class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences, rootKey)
}
}
}

View file

@ -0,0 +1,10 @@
package app.deemix.downloader
import app.deemix.downloader.types.DownloadItem
object SharedObjects {
var dz = Deezer()
var fullQueue: ArrayList<String> = ArrayList()
var queue: MutableMap<String, DownloadItem> = HashMap()
}

View file

@ -0,0 +1,112 @@
package app.deemix.downloader
import android.webkit.URLUtil
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.math.BigInteger
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object Utils {
private const val secret = "g4el58wc0zvf9na1"
private val secretIvSpec = IvParameterSpec(byteArrayOf(0,1,2,3,4,5,6,7))
@Throws(IOException::class)
fun getFinalURL(url: String): String {
val con: HttpURLConnection = URL(url).openConnection() as HttpURLConnection
con.instanceFollowRedirects = false
con.connect()
con.inputStream
if (con.responseCode == HttpURLConnection.HTTP_MOVED_PERM || con.responseCode == HttpURLConnection.HTTP_MOVED_TEMP) {
val redirectUrl: String = con.getHeaderField("Location")
if (URLUtil.isValidUrl(redirectUrl)) return getFinalURL(redirectUrl)
}
return url
}
private fun bitwiseXor(firstVal: Char, secondVal: Char, thirdVal: Char): Char {
return (BigInteger(byteArrayOf(firstVal.code.toByte())) xor
BigInteger(byteArrayOf(secondVal.code.toByte())) xor
BigInteger(byteArrayOf(thirdVal.code.toByte()))).toByte().toInt().toChar()
}
fun createBlowfishKey(trackId: String): String {
val trackMd5Hex = trackId.toMD5()
var blowfishKey = ""
for (i in 0..15) {
val nextChar = bitwiseXor(trackMd5Hex[i], trackMd5Hex[i + 16], secret[i])
blowfishKey += nextChar
}
return blowfishKey
}
fun decryptBlowfish(chunk: ByteArray, blowfishKey: String): ByteArray {
val secretKeySpec = SecretKeySpec(blowfishKey.toByteArray(), "Blowfish")
val thisTrackCipher = Cipher.getInstance("BLOWFISH/CBC/NoPadding")
thisTrackCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, secretIvSpec)
return thisTrackCipher.update(chunk)
}
fun concatTitleVersion(titleShort: String, titleVersion: String): String {
var thisTitle = titleShort.trim()
val thisVersion = titleVersion.trim()
if (!thisTitle.contains(thisVersion)) thisTitle += " $thisVersion"
return thisTitle
}
fun isExplicit(explicitLyrics: Int): Boolean{
return arrayOf(1, 4).contains(explicitLyrics)
}
}
private fun bytesToHex(bytes: ByteArray): String {
var hexString = ""
for (byte in bytes) {
hexString += String.format("%02X", byte)
}
return hexString
}
fun String.toMD5(): String {
val bytes = MessageDigest.getInstance("MD5").digest(this.toByteArray(Charsets.ISO_8859_1))
return bytesToHex(bytes).lowercase()
}
fun <T> ArrayList<T>.toJSONArray(): JSONArray {
val result = JSONArray()
for (value in this){
result.put(value)
}
return result
}
fun <T> JSONArray.toArrayList(): ArrayList<T>{
val result = ArrayList<T>()
for (i in 0 until this.length()) {
result.add(this.get(i) as T)
}
return result
}
fun <T> MutableMap<String, ArrayList<T>>.toJSON(): JSONObject{
val result = JSONObject()
for (key in this.keys){
result.put(key, this[key]!!.toJSONArray())
}
return result
}
fun <T> JSONObject.toHashMap(): MutableMap<String, ArrayList<T>>{
val result: MutableMap<String, ArrayList<T>> = HashMap()
for (key in this.keys()){
result[key] = this.getJSONArray(key).toArrayList()
}
return result
}

View file

@ -0,0 +1,188 @@
package app.deemix.downloader.types
import app.deemix.downloader.Utils.concatTitleVersion
import app.deemix.downloader.Utils.isExplicit
import app.deemix.downloader.toArrayList
import app.deemix.downloader.toHashMap
import app.deemix.downloader.toJSON
import app.deemix.downloader.toJSONArray
import org.json.JSONObject
import java.util.*
import kotlin.collections.ArrayList
import kotlin.collections.HashMap
class Album {
var id = "0"
var title = ""
var pic = DeezerPicture()
var artist = Artist()
var artistRoles: MutableMap<String, ArrayList<String>> = HashMap()
var artists: ArrayList<String> = ArrayList()
var date: DeezerDate? = null
var trackTotal: String? = null
var discTotal: String? = null
var embeddedCoverPath: String? = null
var embeddedCoverURL: String? = null
var explicit: Boolean? = null
var genres: ArrayList<String>? = null
var barcode: String? = null
var label: String? = null
var copyright: String? = null
var recordType: String = "album"
var bitrate: String? = null
var rootArtist: Artist? = null
var variousArtists: Artist? = null
init {
pic.type = "cover"
}
fun parseAPI(albumAPI: JSONObject): Album {
id = albumAPI.getString("id")
title = albumAPI.getString("title")
pic.md5 = albumAPI.getString("md5_image")
artist.parseAPI(albumAPI.getJSONObject("artist"))
val data = albumAPI.getJSONArray("contributors")
for (i in 0 until data.length()) {
val artist = data.getJSONObject(i)
val isVariousArtist = artist.getString("id") == "5080"
val isMainArtist = artist.getString("role") == "Main"
if (isVariousArtist) {
variousArtists = Artist().parseAPI(artist)
continue
}
val artistName = artist.getString("name")
val artistRole = artist.getString("role")
if (!artists.contains(artistName))
artists.add(artistName)
if (isMainArtist || !artistRoles["Main"]!!.contains(artistName) && !isMainArtist){
if (!artistRoles.containsKey(artistRole))
artistRoles[artistRole] = ArrayList()
artistRoles[artistRole]!!.add(artistName)
}
}
date = DeezerDate(albumAPI.getString("release_date"))
trackTotal = albumAPI.getString("nb_tracks")
explicit = albumAPI.getBoolean("explicit_lyrics")
val thisGenres = albumAPI.getJSONObject("genres").getJSONArray("data")
genres = ArrayList()
for (i in 0 until thisGenres.length()) {
val genre = thisGenres.getJSONObject(i).getString("name")
if (!genres!!.contains(genre)) genres!!.add(genre)
}
barcode = albumAPI.getString("upc")
label = albumAPI.getString("label")
recordType = albumAPI.getString("record_type")
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
return this
}
fun parseGW(albumAPI: JSONObject): Album {
id = albumAPI.getString("ALB_ID")
title = albumAPI.getString("ALB_TITLE").trim()
pic.md5 = albumAPI.getString("ALB_PICTURE")
artist.id = albumAPI.getString("ART_ID")
artist.name = albumAPI.getString("ART_NAME")
date = DeezerDate(albumAPI.getString("PHYSICAL_RELEASE_DATE"))
trackTotal = albumAPI.getString("NUMBER_TRACK")
discTotal = albumAPI.getString("NUMBER_DISK")
explicit = isExplicit(albumAPI.getJSONObject("EXPLICIT_ALBUM_CONTENT").getInt("EXPLICIT_LYRICS_STATUS"))
label = albumAPI.getString("LABEL_NAME")
copyright = albumAPI.getString("COPYRIGHT")
return this
}
fun parseGWPage(albumAPI: JSONObject): Album{
id = albumAPI.getString("ALB_ID")
title = albumAPI.getString("ALB_TITLE").trim()
val version = albumAPI.getString("VERSION").trim()
title = concatTitleVersion(title, version)
pic.md5 = albumAPI.getString("ALB_PICTURE")
artist.id = albumAPI.getString("ART_ID")
artist.name = albumAPI.getString("ART_NAME")
val data = albumAPI.getJSONArray("ARTISTS")
for (i in 0 until data.length()) {
val artist = data.getJSONObject(i)
val isVariousArtist = artist.getString("ART_ID") == "5080"
val isMainArtist = artist.getString("ROLE") == "0"
if (isVariousArtist) {
variousArtists = Artist().parseGW(artist)
continue
}
val artistName = artist.getString("ART_NAME")
val artistRole = when (artist.getString("ROLE_ID")){
"0" -> "Main"
"5" -> "Featured"
else -> "Other"
}
if (!artists.contains(artistName))
artists.add(artistName)
if (isMainArtist || !artistRoles["Main"]!!.contains(artistName) && !isMainArtist){
if (!artistRoles.containsKey(artistRole))
artistRoles[artistRole] = ArrayList()
artistRoles[artistRole]!!.add(artistName)
}
}
date = DeezerDate(albumAPI.getString("PHYSICAL_RELEASE_DATE"))
explicit = isExplicit(albumAPI.getJSONObject("EXPLICIT_ALBUM_CONTENT").getInt("EXPLICIT_LYRICS_STATUS"))
barcode = albumAPI.getString("UPC")
label = albumAPI.getString("LABEL_NAME")
return this
}
fun restoreObject(albumAPI: JSONObject): Album{
id = albumAPI.getString("id")
title = albumAPI.getString("title")
pic.restoreObject(albumAPI.getJSONObject("pic"))
artist.restoreObject(albumAPI.getJSONObject("artist"))
artistRoles = albumAPI.getJSONObject("artistRoles").toHashMap()
artists = albumAPI.getJSONArray("artists").toArrayList()
if (albumAPI.has("date")) date = DeezerDate(albumAPI.getString("date"))
if (albumAPI.has("trackTotal")) trackTotal = albumAPI.getString("trackTotal")
if (albumAPI.has("discTotal")) discTotal = albumAPI.getString("discTotal")
if (albumAPI.has("embeddedCoverPath")) embeddedCoverPath = albumAPI.getString("embeddedCoverPath")
if (albumAPI.has("embeddedCoverURL")) embeddedCoverURL = albumAPI.getString("embeddedCoverURL")
if (albumAPI.has("explicit")) explicit = albumAPI.getBoolean("explicit")
if (albumAPI.has("genres")) genres = albumAPI.getJSONArray("genres").toArrayList()
if (albumAPI.has("barcode")) barcode = albumAPI.getString("barcode")
if (albumAPI.has("label")) label = albumAPI.getString("label")
if (albumAPI.has("copyright")) copyright = albumAPI.getString("copyright")
if (albumAPI.has("bitrate")) bitrate = albumAPI.getString("bitrate")
if (albumAPI.has("rootArtist")) rootArtist = Artist().restoreObject(albumAPI.getJSONObject("rootArtist"))
recordType = albumAPI.getString("recordType")
return this
}
fun toJSON(): JSONObject {
val obj = JSONObject()
obj.put("id", id)
obj.put("title", title)
obj.put("pic", pic.toJSON())
obj.put("artist", artist.toJSON())
obj.put("artistRoles", artistRoles.toJSON())
obj.put("artists", artists.toJSONArray())
if (date != null) obj.put("date", date)
if (trackTotal != null) obj.put("trackTotal", trackTotal)
if (discTotal != null) obj.put("discTotal", discTotal)
if (embeddedCoverPath != null) obj.put("embeddedCoverPath", embeddedCoverPath)
if (embeddedCoverURL != null) obj.put("embeddedCoverURL", embeddedCoverURL)
if (explicit != null) obj.put("explicit", explicit)
if (genres != null) obj.put("genres", genres!!.toJSONArray())
if (barcode != null) obj.put("barcode", barcode)
if (label != null) obj.put("label", label)
if (copyright != null) obj.put("copyright", copyright)
if (bitrate != null) obj.put("bitrate", bitrate)
if (rootArtist != null) obj.put("rootArtist", rootArtist)
obj.put("recordType", recordType)
return obj
}
}

View file

@ -0,0 +1,55 @@
package app.deemix.downloader.types
import org.json.JSONObject
class Artist {
var id: String = "0"
var name: String = ""
var pic = DeezerPicture()
var role: String = "Main"
var shouldSave = true
init {
pic.type = "artist"
}
fun parseAPI(artistAPI: JSONObject): Artist{
id = artistAPI.getString("id")
name = artistAPI.getString("name")
if (artistAPI.has("md5_image"))
pic.md5 =artistAPI.getString("md5_image")
else if (artistAPI.has("picture_small"))
pic.md5 = artistAPI.getString("picture_small").substringAfter("/artist/").substringBefore("/")
if (artistAPI.has("role")) role = artistAPI.getString("role")
return this
}
fun parseGW(artistAPI: JSONObject): Artist{
id = artistAPI.getString("ART_ID")
name = artistAPI.getString("ART_NAME")
pic.md5 = artistAPI.getString("ART_PICTURE")
role = when (artistAPI.getString("ROLE_ID")){
"0" -> "Main"
"5" -> "Featured"
else -> "Other"
}
return this
}
fun restoreObject(artistAPI: JSONObject): Artist {
id = artistAPI.getString("id")
name = artistAPI.getString("name")
pic.restoreObject(artistAPI.getJSONObject("pic"))
role = artistAPI.getString("role")
return this
}
fun toJSON(): JSONObject{
val obj = JSONObject()
obj.put("id", id)
obj.put("name", name)
obj.put("pic", pic.toJSON())
obj.put("role", role)
return obj
}
}

View file

@ -0,0 +1,25 @@
package app.deemix.downloader.types
class DeezerDate {
var day: String = "00"
var month: String = "00"
var year: String = "0000"
constructor(date: String){
year = date.substring(0, 4)
month = date.substring(5, 7)
day = date.substring(8, 10)
fixDayMonth()
}
fun fixDayMonth(){
if (month.toInt() > 12){
val monthTemp = month
month = day
day = monthTemp
}
}
override fun toString(): String = "$year-$month-$day"
fun toID3String(): String = "$day$month"
}

View file

@ -0,0 +1,35 @@
package app.deemix.downloader.types
import org.json.JSONObject
class DeezerPicture {
var md5: String = ""
var type: String = ""
fun getURL(size: Int, format: String): String{
val url = "https://e-cdns-images.dzcdn.net/images/$type/$md5/${size}x${size}"
if (format.startsWith("jpg")){
var quality = 80
if (format.contains("-")) quality = format.substring(4).toInt()
return "$url-000000-$quality-0-0.jpg"
}
if (format == "png"){
return "$url-none-100-0-0.png"
}
return "$url.jpg"
}
fun restoreObject(picture: JSONObject): DeezerPicture{
md5 = picture.getString("md5")
type = picture.getString("type")
return this
}
fun toJSON(): JSONObject{
val obj = JSONObject()
obj.put("md5", md5)
obj.put("type", type)
return obj
}
}

View file

@ -0,0 +1,56 @@
package app.deemix.downloader.types
import org.json.JSONObject
class DeezerQuality(soundQuality: JSONObject) {
var low: Boolean = false
var standard: Boolean = false
var high: Boolean = false
var lossless: Boolean = false
var reality: Boolean = false
init {
low = soundQuality.getBoolean("low")
standard = soundQuality.getBoolean("standard")
high = soundQuality.getBoolean("high")
lossless = soundQuality.getBoolean("lossless")
reality = soundQuality.getBoolean("reality")
}
}
class DeezerUser(userData: JSONObject, child: JSONObject? = null) {
var id: String
var name: String
var picture: String = ""
var licenseToken: String
var canStream: DeezerQuality
var country: String
var language: String
var lovedTracksID: String
init {
if (child != null){
id = child.getString("USER_ID")
name = child.getString("BLOG_NAME")
picture = child.getString("USER_PICTURE")
lovedTracksID = child.getString("LOVEDTRACKS_ID")
} else {
id = userData.getString("USER_ID")
name = userData.getString("BLOG_NAME")
picture = userData.getString("USER_PICTURE")
lovedTracksID = userData.getString("LOVEDTRACKS_ID")
}
licenseToken = userData.getJSONObject("OPTIONS").getString("license_token")
canStream = DeezerQuality(userData.getJSONObject("OPTIONS").getJSONObject("web_sound_quality"))
country = userData.getJSONObject("OPTIONS").getString("license_country")
var lang = userData.getJSONObject("SETTING").getJSONObject("global").getString("language")
.replace("[^0-9A-Za-z *,-.;=]".toRegex(), "")
lang = if (lang.length > 2 && lang[2] == '-'){
lang.substring(0, 5)
} else {
lang.substring(0, 2)
}
language = lang
}
}

View file

@ -0,0 +1,141 @@
package app.deemix.downloader.types
import org.json.JSONArray
import org.json.JSONObject
open class DownloadItem {
var type: String = ""
var id: String = ""
var bitrate: String = "3"
var title: String = ""
var artist: String = ""
var cover: String = ""
var explicit: Boolean = false
open var size: Int = 0
var downloaded: Int = 0
var failed: Int = 0
var progress: Int = -1
var errors: ArrayList<String> = ArrayList()
var uuid: String = ""
var isCanceled: Boolean = false
open var itemType: String = ""
var status: String = ""
constructor()
constructor(obj: JSONObject){
type = obj.getString("type")
id = obj.getString("id")
bitrate = obj.getString("bitrate")
title = obj.getString("title")
artist = obj.getString("artist")
cover = obj.getString("cover")
explicit = obj.getBoolean("explicit")
size = obj.getInt("size")
downloaded = obj.getInt("downloaded")
failed = obj.getInt("failed")
progress = obj.getInt("progress")
for (i in 0 until obj.getJSONArray("errors").length()){
errors.add(obj.getJSONArray("errors").getString(i))
}
uuid = obj.getString("uuid")
isCanceled = obj.getBoolean("isCanceled")
itemType = obj.getString("itemType")
status = obj.getString("status")
}
constructor(obj: DownloadItem){
type = obj.type
id = obj.id
bitrate = obj.bitrate
title = obj.title
artist = obj.artist
cover = obj.cover
explicit = obj.explicit
size = obj.size
downloaded = obj.downloaded
failed = obj.failed
progress = obj.progress
errors = obj.errors
uuid = obj.uuid
isCanceled = obj.isCanceled
itemType = obj.itemType
status = obj.status
}
fun generateUUID(){
uuid = "${type}_${id}_${bitrate}"
}
open fun toJSON(): JSONObject {
val obj = JSONObject()
obj.put("type", type)
obj.put("id", id)
obj.put("bitrate", bitrate)
obj.put("title", title)
obj.put("artist", artist)
obj.put("cover", cover)
obj.put("explicit", explicit)
obj.put("size", size)
obj.put("downloaded", downloaded)
obj.put("failed", failed)
obj.put("progress", progress)
obj.put("errors", JSONArray(errors))
obj.put("uuid", uuid)
obj.put("isCanceled", isCanceled)
obj.put("itemType", itemType)
obj.put("status", status)
return obj
}
override fun toString(): String {
return toJSON().toString(2)
}
fun minimalClone(): DownloadItem {
return DownloadItem(this)
}
}
class Single : DownloadItem {
override var size = 1
override var itemType = "single"
var single: JSONObject = JSONObject()
constructor()
constructor(obj: JSONObject): super(obj){
single = obj.getJSONObject("single")
}
constructor(obj: Single): super(obj){
single = obj.single
}
override fun toJSON(): JSONObject {
val obj = super.toJSON()
obj.put("single", single)
return obj
}
}
class Collection : DownloadItem {
override var itemType = "collection"
var collection: JSONObject = JSONObject()
constructor()
constructor(obj: JSONObject): super(obj){
collection = obj.getJSONObject("collection")
}
constructor(obj: Collection): super(obj){
collection = obj.collection
}
override fun toJSON(): JSONObject {
val obj = super.toJSON()
obj.put("collection", collection)
return obj
}
}

View file

@ -0,0 +1,28 @@
package app.deemix.downloader.types
import app.deemix.downloader.toArrayList
import org.json.JSONObject
class Lyrics {
var id: String = "0"
var sync: String = ""
var unsync: String = ""
var syncID3: ArrayList< Pair<String, Int>> = ArrayList()
fun restoreObject(lyricsAPI: JSONObject): Lyrics {
id = lyricsAPI.getString("id")
sync = lyricsAPI.getString("sync")
unsync = lyricsAPI.getString("unsync")
//syncID3 = lyricsAPI.getJSONArray("syncID3").toArrayList()
return this
}
fun toJSON(): JSONObject {
val obj = JSONObject()
obj.put("id", id)
obj.put("sync", sync)
obj.put("unsync", unsync)
//obj.put("syncID3", syncID3) // TODO: toJSON
return obj
}
}

View file

@ -0,0 +1,263 @@
package app.deemix.downloader.types
import app.deemix.downloader.Utils.concatTitleVersion
import app.deemix.downloader.Utils.isExplicit
import app.deemix.downloader.toArrayList
import app.deemix.downloader.toHashMap
import app.deemix.downloader.toJSON
import app.deemix.downloader.toJSONArray
import org.json.JSONObject
class Track {
lateinit var id: String
// Full title
var title: String = ""
// The title without the version
var titleShort: String? = null
// Just the version
var titleVersion: String? = null
var trackToken: String? = null
var trackTokenExpiration: Int = 0
var fallbackID: String? = null
// Just the main artist of the track
var artist: Artist = Artist()
// List of all roles with their respective artists
var artistRoles: MutableMap<String, ArrayList<String>> = HashMap()
// Just all the artists as strings
var artists: ArrayList<String> = ArrayList()
// All the contributors of the track
var contributors: MutableMap<String, ArrayList<String>> = HashMap()
var album: Album = Album()
var date: DeezerDate? = null
var lyrics: Lyrics? = null
var duration: Int = 0 // in milliseconds
var trackNumber: String? = null
var discNumber: String? = null
var bpm: Float? = null
var explicit: Boolean? = null
var isrc: String? = null
var replayGain: Float? = null
var isAvailableSomewhere: Boolean? = null
var position: Int = 0
var searched: Boolean = false
// Computed
var isLocal: Boolean = false
init {
artistRoles["Main"] = ArrayList()
}
fun parseAPI(trackAPI: JSONObject): Track {
id = trackAPI.getString("id")
title = trackAPI.getString("title")
titleShort = trackAPI.getString("title_short")
titleVersion = if (trackAPI.has("titleVersion"))
trackAPI.getString("title_version")
else ""
artist.parseAPI(trackAPI.getJSONObject("artist"))
duration = trackAPI.getInt("duration") * 1000
explicit = trackAPI.getBoolean("explicit_lyrics")
if (trackAPI.has("isrc")) isrc = trackAPI.getString("isrc")
if (trackAPI.has("track_position")) trackNumber = trackAPI.getString("track_position")
if (trackAPI.has("disk_number")) discNumber = trackAPI.getString("disk_number")
if (trackAPI.has("bpm")) bpm = trackAPI.getString("bpm").toFloat() // Only on public API
if (trackAPI.has("gain")) replayGain = trackAPI.getString("gain").toFloat()
if (trackAPI.has("available_countries")) isAvailableSomewhere = trackAPI.getJSONArray("available_countries").length() > 0
if (trackAPI.has("contributors")) {
val data = trackAPI.getJSONArray("contributors")
for (i in 0 until data.length()) {
val artist = data.getJSONObject(i)
val isVariousArtist = artist.getString("id") == "5080"
val isMainArtist = artist.getString("role") == "Main"
if (data.length() > 1 && isVariousArtist) continue
val artistName = artist.getString("name")
val artistRole = artist.getString("role")
if (!artists.contains(artistName))
artists.add(artistName)
if (isMainArtist || !artistRoles["Main"]!!.contains(artistName) && !isMainArtist){
if (!artistRoles.containsKey(artistRole))
artistRoles[artistRole] = ArrayList()
artistRoles[artistRole]!!.add(artistName)
}
}
}
if (trackAPI.has("release_date")) date = DeezerDate(trackAPI.getString("release_date"))
if (trackAPI.has("md5_image")) album.pic.md5 = trackAPI.getString("md5_image")
if (trackAPI.has("album")) {
val thisAlbum = trackAPI.getJSONObject("album")
album.id = thisAlbum.getString("id")
album.title = thisAlbum.getString("title")
album.pic.md5 = thisAlbum.getString("md5_image")
album.date = DeezerDate(thisAlbum.getString("release_date"))
}
return this
}
fun parseGW(trackAPI: JSONObject): Track{
id = trackAPI.getString("SNG_ID")
album.id = trackAPI.getString("ALB_ID")
album.pic.md5 = trackAPI.getString("ALB_PICTURE")
album.title = trackAPI.getString("ALB_TITLE")
artist.id = trackAPI.getString("ART_ID")
artist.name = trackAPI.getString("ART_NAME")
duration = trackAPI.getInt("DURATION") * 1000
isrc = trackAPI.getString("ISRC")
titleShort = trackAPI.getString("SNG_TITLE")
title = titleShort!!
trackToken = trackAPI.getString("TRACK_TOKEN")
trackTokenExpiration = trackAPI.getInt("TRACK_TOKEN_EXPIRE")
isLocal = id.toInt() < 0
if (isLocal) return this
val data = trackAPI.getJSONArray("ARTISTS")
for (i in 0 until data.length()) {
val artist = data.getJSONObject(i)
val isVariousArtist = artist.getString("ART_ID") == "5080"
val isMainArtist = artist.getString("ROLE_ID") == "0"
if (data.length() > 1 && isVariousArtist) continue
val artistName = artist.getString("ART_NAME")
val artistRole = when (artist.getString("ROLE_ID")){
"0" -> "Main"
"5" -> "Featured"
else -> "Other"
}
if (!artists.contains(artistName))
artists.add(artistName)
if (isMainArtist || !artistRoles["Main"]!!.contains(artistName) && !isMainArtist){
if (!artistRoles.containsKey(artistRole))
artistRoles[artistRole] = ArrayList()
artistRoles[artistRole]!!.add(artistName)
}
}
discNumber = trackAPI.getString("DISK_NUMBER")
explicit = isExplicit(trackAPI.getInt("EXPLICIT_LYRICS"))
// GENRE_ID
// LYRICS_ID
date = DeezerDate(trackAPI.getString("PHYSICAL_RELEASE_DATE"))
// contributors = SNG_CONTRIBUTORS
trackNumber = trackAPI.getString("TRACK_NUMBER")
titleVersion = if (trackAPI.has("VERSION"))
trackAPI.getString("VERSION")
else ""
replayGain = trackAPI.getString("GAIN").toFloat()
title = concatTitleVersion(titleShort?:"", titleVersion?:"")
return this
}
fun parseGWPage(trackAPI: JSONObject): Track{
val data = trackAPI.getJSONObject("DATA")
parseGW(data)
artist.pic.md5 = data.getString("ART_PICTURE")
album.copyright = data.getString("COPYRIGHT")
// isAvailableSomewhere = AVAILABLE_COUNTRIES
if (trackAPI.has("LYRICS")){
val lyricsData = trackAPI.getJSONObject("LYRICS")
}
if (trackAPI.has("ISRC")){
val isrcData = trackAPI.getJSONObject("ISRC")
}
return this
}
fun cleanUp(){
// Remove unwanted charaters in track name
// Example: track/127793
title = title.replace(Regex("\\s\\s+"), " ")
// Make sure there is at least one artist
// and that the first artist is the main one
if (artists.contains(artist.name)){
if (artists.indexOf(artist.name) != 0){
artists.remove(artist.name)
}
}
if (!artists.contains(artist.name)) artists.add(0, artist.name)
if (artistRoles["Main"]!!.contains(artist.name)){
if (artistRoles["Main"]!!.indexOf(artist.name) != 0){
artistRoles["Main"]!!.remove(artist.name)
}
}
if (!artistRoles["Main"]!!.contains(artist.name)) artistRoles["Main"]!!.add(0, artist.name)
if (album.artists.contains(album.artist.name)){
if (album.artists.indexOf(album.artist.name) != 0){
album.artists.remove(album.artist.name)
}
}
if (!album.artists.contains(album.artist.name)) album.artists.add(0, album.artist.name)
if (album.artistRoles["Main"]!!.contains(album.artist.name)){
if (album.artistRoles["Main"]!!.indexOf(album.artist.name) != 0){
album.artistRoles["Main"]!!.remove(album.artist.name)
}
}
if (!album.artistRoles["Main"]!!.contains(album.artist.name)) album.artistRoles["Main"]!!.add(0, album.artist.name)
}
fun restoreObject(trackAPI: JSONObject): Track {
id = trackAPI.getString("id")
if (trackAPI.has("title")) title = trackAPI.getString("title")
if (trackAPI.has("titleShort")) titleShort = trackAPI.getString("titleShort")
if (trackAPI.has("titleVersion")) titleVersion = trackAPI.getString("titleVersion")
if (trackAPI.has("fallbackID")) fallbackID = trackAPI.getString("fallbackID")
artist.restoreObject(trackAPI.getJSONObject("artist"))
artistRoles = trackAPI.getJSONObject("artistRoles").toHashMap()
contributors = trackAPI.getJSONObject("contributors").toHashMap()
artists = trackAPI.getJSONArray("artists").toArrayList()
album.restoreObject(trackAPI.getJSONObject("album"))
if (trackAPI.has("date")) date = DeezerDate(trackAPI.getString("date"))
if (trackAPI.has("lyrics")) lyrics = Lyrics().restoreObject(trackAPI.getJSONObject("lyrics"))
duration = trackAPI.getInt("duration")
if (trackAPI.has("trackNumber")) trackNumber = trackAPI.getString("trackNumber")
if (trackAPI.has("discNumber")) discNumber = trackAPI.getString("discNumber")
if (trackAPI.has("bpm")) bpm = trackAPI.getString("bpm").toFloat()
if (trackAPI.has("explicit")) explicit = trackAPI.getBoolean("explicit")
if (trackAPI.has("isrc")) isrc = trackAPI.getString("isrc")
if (trackAPI.has("replayGain")) replayGain = trackAPI.getString("replayGain").toFloat()
if (trackAPI.has("isAvailableSomewhere")) isAvailableSomewhere = trackAPI.getBoolean("isAvailableSomewhere")
position = trackAPI.getInt("position")
searched = trackAPI.getBoolean("searched")
return this
}
fun toJSON(): JSONObject{
val obj = JSONObject()
obj.put("id", id)
obj.put("title", title)
if (titleShort != null) obj.put("titleShort", titleShort)
if (titleVersion != null) obj.put("titleVersion", titleVersion)
if (fallbackID != null) obj.put("fallbackID", fallbackID)
obj.put("artist", artist.toJSON())
obj.put("artistRoles", artistRoles.toJSON())
obj.put("contributors", contributors.toJSON())
obj.put("artists", artists.toJSONArray())
obj.put("album", album.toJSON())
if (date != null) obj.put("date", date.toString())
if (lyrics != null) obj.put("lyrics", lyrics!!.toJSON())
obj.put("duration", duration)
if (trackNumber != null) obj.put("trackNumber", trackNumber)
if (discNumber != null) obj.put("discNumber", discNumber)
if (bpm != null) obj.put("bpm", bpm)
if (explicit != null) obj.put("explicit", explicit)
if (isrc != null) obj.put("isrc", isrc)
if (replayGain != null) obj.put("replayGain", replayGain)
if (isAvailableSomewhere != null) obj.put("isAvailableSomewhere", isAvailableSomewhere)
obj.put("position", position)
obj.put("searched", searched)
return obj
}
}

View file

@ -0,0 +1,44 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="m31.502,31c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.504 0.502,0.504h6.17c0.278,0 0.504,-0.225 0.504,-0.504v-6.17c0,-0.278 -0.225,-0.502 -0.504,-0.502zM31.502,38.765c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.504,-0.224 0.504,-0.502v-6.17c0,-0.278 -0.225,-0.502 -0.504,-0.502zM39.268,38.765c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM62.562,38.765c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM31.502,46.529c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.504,-0.224 0.504,-0.502v-6.172c0,-0.278 -0.225,-0.502 -0.504,-0.502zM39.268,46.529c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM54.797,46.529c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.172c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM62.562,46.529c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM70.327,46.529c-0.278,0 -0.504,0.224 -0.504,0.502v6.172c0,0.278 0.225,0.502 0.504,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM31.502,54.294c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.504,-0.224 0.504,-0.502v-6.172c0,-0.278 -0.225,-0.502 -0.504,-0.502zM39.268,54.294c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM47.032,54.294c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.172c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM54.797,54.294c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.172c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM62.562,54.294c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM70.327,54.294c-0.278,0 -0.504,0.224 -0.504,0.502v6.172c0,0.278 0.225,0.502 0.504,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM31.502,62.06c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.504,-0.224 0.504,-0.502v-6.17c0,-0.278 -0.225,-0.502 -0.504,-0.502zM39.268,62.06c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM47.032,62.06c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.172c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM54.797,62.06c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.172c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM62.562,62.06c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM70.327,62.06c-0.278,0 -0.504,0.224 -0.504,0.502v6.17c0,0.278 0.225,0.502 0.504,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM31.502,69.824c-0.278,0 -0.502,0.225 -0.502,0.504v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.504,-0.224 0.504,-0.502v-6.17c0,-0.278 -0.225,-0.504 -0.504,-0.504zM39.268,69.824c-0.278,0 -0.502,0.225 -0.502,0.504v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.504 -0.502,-0.504zM47.032,69.824c-0.278,0 -0.502,0.225 -0.502,0.504v6.17c0,0.278 0.224,0.502 0.502,0.502h6.172c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.504 -0.502,-0.504zM54.797,69.824c-0.278,0 -0.502,0.225 -0.502,0.504v6.17c0,0.278 0.224,0.502 0.502,0.502h6.172c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.504 -0.502,-0.504zM62.562,69.824c-0.278,0 -0.502,0.225 -0.502,0.504v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.504 -0.502,-0.504zM70.327,69.824c-0.278,0 -0.504,0.225 -0.504,0.504v6.17c0,0.278 0.225,0.502 0.504,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.504 -0.502,-0.504z"
android:strokeLineJoin="round"
android:strokeWidth="3.0664"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startY="30.999683"
android:startX="53.80671"
android:endY="77.000305"
android:endX="53.80671"
android:type="linear"
android:tileMode="clamp">
<item android:offset="0" android:color="#FF007AFF"/>
<item android:offset="1" android:color="#FF5856D6"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m62.562,38.765c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM54.797,46.529c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.172c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM62.562,46.529c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM54.797,54.294c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.172c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM62.562,54.294c-0.278,0 -0.502,0.224 -0.502,0.502v6.172c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.172c0,-0.278 -0.224,-0.502 -0.502,-0.502zM47.032,62.06c-0.088,0 -0.166,0.028 -0.238,0.068l6.844,6.844c0.04,-0.072 0.068,-0.151 0.068,-0.239v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM54.797,62.06c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.172c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM62.562,62.06c-0.278,0 -0.502,0.224 -0.502,0.502v6.17c0,0.278 0.224,0.502 0.502,0.502h6.17c0.278,0 0.502,-0.224 0.502,-0.502v-6.17c0,-0.278 -0.224,-0.502 -0.502,-0.502zM70.327,62.06c-0.278,0 -0.504,0.224 -0.504,0.502v6.17c0,0.033 0.013,0.061 0.019,0.092l6.746,-6.746c-0.03,-0.006 -0.058,-0.019 -0.09,-0.019zM54.797,69.824c-0.088,0 -0.167,0.028 -0.239,0.068l6.846,6.846c0.04,-0.072 0.068,-0.151 0.068,-0.239v-6.17c0,-0.278 -0.224,-0.504 -0.502,-0.504zM62.562,69.824c-0.278,0 -0.502,0.225 -0.502,0.504v6.17c0,0.032 0.013,0.06 0.019,0.09l6.746,-6.746c-0.031,-0.006 -0.059,-0.019 -0.092,-0.019z"
android:strokeLineJoin="miter"
android:strokeWidth="0.920009"
android:strokeColor="#00000000"
android:strokeLineCap="butt">
<aapt:attr name="android:fillColor">
<gradient
android:startY="38.66649"
android:startX="61.66677"
android:endY="57.414715"
android:endX="61.661545"
android:type="linear"
android:tileMode="clamp">
<item android:offset="0" android:color="#FF007AFF"/>
<item android:offset="1" android:color="#FFFFFFFF"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="1dp"
android:height="8dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:pathData="M 0,1.9999996e-8 H 0.26458335 V 2.1166668 H 0 Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20,12l-1.41,-1.41L13,16.17V4h-2v12.17l-5.58,-5.59L4,12l8,8 8,-8z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M11,15h2v2h-2zM11,7h2v6h-2zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,13h2v-2L3,11v2zM3,17h2v-2L3,15v2zM3,9h2L5,7L3,7v2zM7,13h14v-2L7,11v2zM7,17h14v-2L7,15v2zM7,7v2h14L21,7L7,7z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

View file

@ -0,0 +1,164 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="1000dp"
android:height="191.2dp"
android:viewportWidth="1000"
android:viewportHeight="191.2">
<path
android:fillColor="@color/logo_text"
android:pathData="M234.1,126.94c0,40.1 24.7,64.1 61.4,64.1c18.3,0 33.7,-5.1 42.3,-18.5v18.5h33.5L371.3,0.04h-34.8v81.2c-7.9,-13.4 -22.5,-19.4 -40.8,-19.4C259.9,61.74 234.1,86.24 234.1,126.94L234.1,126.94zM337.6,126.94c0,22.9 -15.6,37.2 -34.3,37.2c-19.4,0 -34.3,-14.3 -34.3,-37.2c0,-23.3 15,-37.9 34.3,-37.9C322,89.04 337.6,103.84 337.6,126.94z"/>
<path
android:fillColor="@color/logo_text"
android:pathData="M479.6,141.74c-4,14.8 -14.1,22.2 -30,22.2c-18.5,0 -33.7,-11.2 -34.1,-31h87.7c1.1,-4.9 1.6,-10.1 1.6,-15.8c0,-35.5 -24.2,-55.5 -59.9,-55.5c-38.1,0 -64.3,27.1 -64.3,63.9c0,41 28.9,65.6 68.9,65.6c30.2,0 50.7,-12.6 59.7,-37.7L479.6,141.74zM415.5,109.74C418.8,95.44 430.7,87.04 445,87.04c15.6,0 26.2,8.4 26.2,21.6l-0.2,1.1L415.5,109.74z"/>
<path
android:fillColor="@color/logo_text"
android:pathData="M608.7,141.74c-4,14.8 -14.1,22.2 -30,22.2c-18.5,0 -33.7,-11.2 -34.1,-31h87.7c1.1,-4.9 1.6,-10.1 1.6,-15.8c0,-35.5 -24.2,-55.5 -59.9,-55.5c-38.1,0 -64.3,27.1 -64.3,63.9c0,41 28.9,65.6 68.9,65.6c30.2,0 50.7,-12.6 59.7,-37.7L608.7,141.74zM544.6,109.74c3.3,-14.3 15.2,-22.7 29.5,-22.7c15.6,0 26.2,8.4 26.2,21.6l-0.2,1.1L544.6,109.74z"/>
<path
android:fillColor="@color/logo_text"
android:pathData="M756.7,191.24L756.7,159.74h-73.1l71.1,-69.2v-28.8L642.2,61.74v30h68.7L640,161.24v30L756.7,191.24z"/>
<path
android:fillColor="@color/logo_text"
android:pathData="M858.4,141.74c-4,14.8 -14.1,22.2 -30,22.2c-18.5,0 -33.7,-11.2 -34.1,-31L882,132.94c1.1,-4.9 1.6,-10.1 1.6,-15.8c0,-35.5 -24.2,-55.5 -59.9,-55.5c-38.1,0 -64.3,27.1 -64.3,63.9c0,41 28.9,65.6 68.9,65.6c30.2,0 50.7,-12.6 59.7,-37.7L858.4,141.74zM794.3,109.74c3.3,-14.3 15.2,-22.7 29.5,-22.7c15.6,0 26.2,8.4 26.2,21.6l-0.2,1.1L794.3,109.74z"/>
<path
android:fillColor="@color/logo_text"
android:pathData="M966.1,106.44c0,1.3 0,3.7 0,3.7h33.9c0,0 0,-6.4 0,-9.9c0,-22.2 -13.9,-38.3 -37.2,-38.3c-15,0 -25.6,7.3 -31.1,19.4v-19.4h-35v129.3h35L931.7,107.94c0,-13.2 7.1,-20.5 18,-20.5C959.7,87.54 966.1,96.94 966.1,106.44z"/>
<path
android:pathData="M155.5,61.74h42.9v25.1h-42.9z"
android:fillColor="#40AB5D"
android:fillType="evenOdd"/>
<path
android:pathData="M155.5,96.54h42.9v25.1h-42.9z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startY="121.860596"
android:startX="177.16011"
android:endY="96.17528"
android:endX="176.75674"
android:type="linear">
<item android:offset="0" android:color="#FF358C7B"/>
<item android:offset="0.5256" android:color="#FF33A65E"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M155.5,131.34h42.9v25.1h-42.9z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startY="155.15503"
android:startX="154.86931"
android:endY="132.64366"
android:endX="199.05013"
android:type="linear">
<item android:offset="0" android:color="#FF222B90"/>
<item android:offset="1" android:color="#FF367B99"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M0,166.14h42.9v25.1h-42.9z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startY="178.70355"
android:startX="0.007833929"
android:endY="178.70355"
android:endX="42.871952"
android:type="linear">
<item android:offset="0" android:color="#FFFF9900"/>
<item android:offset="1" android:color="#FFFF8000"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M51.8,166.14h42.9v25.1h-42.9z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startY="178.70355"
android:startX="51.847775"
android:endY="178.70355"
android:endX="94.71189"
android:type="linear">
<item android:offset="0" android:color="#FFFF8000"/>
<item android:offset="1" android:color="#FFCC1953"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M103.7,166.14h42.9v25.1h-42.9z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startY="178.70355"
android:startX="103.68771"
android:endY="178.70355"
android:endX="146.55183"
android:type="linear">
<item android:offset="0" android:color="#FFCC1953"/>
<item android:offset="1" android:color="#FF241284"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M155.5,166.14h42.9v25.1h-42.9z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startY="178.70355"
android:startX="155.47691"
android:endY="178.70355"
android:endX="198.34103"
android:type="linear">
<item android:offset="0" android:color="#FF222B90"/>
<item android:offset="1" android:color="#FF3559A6"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M103.7,131.34h42.9v25.1h-42.9z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startY="150.66125"
android:startX="101.995865"
android:endY="137.13744"
android:endX="148.24368"
android:type="linear">
<item android:offset="0" android:color="#FFCC1953"/>
<item android:offset="1" android:color="#FF241284"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M51.8,131.34h42.9v25.1h-42.9z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startY="135.54341"
android:startX="50.322067"
android:endY="152.25546"
android:endX="96.2376"
android:type="linear">
<item android:offset="0.002669841" android:color="#FFFFCC00"/>
<item android:offset="0.9999" android:color="#FFCE1938"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M51.8,96.54h42.9v25.1h-42.9z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startY="92.64302"
android:startX="55.450558"
android:endY="125.547455"
android:endX="91.10911"
android:type="linear">
<item android:offset="0.002669841" android:color="#FFFFD100"/>
<item android:offset="1" android:color="#FFFD5A22"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#111111"
android:pathData="M0,0h108v108h-108z" />
</vector>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="19.05dp"
android:height="19.050001dp"
android:viewportWidth="19.05"
android:viewportHeight="19.050001">
<path
android:fillColor="#333333"
android:strokeWidth="1.32292"
android:strokeLineJoin="bevel"
android:strokeLineCap="round"
android:pathData="M 0 -5e-7 H 19.050001 V 19.0500005 H 0 V -5e-7 Z" />
<path
android:fillColor="#ffffff"
android:strokeWidth="0.529167"
android:pathData="M 9.5250003,4.7625001 V 10.345209 C 9.2127919,10.165292 8.8529586,10.054167 8.4666669,10.054167 c -1.1694584,0 -2.1166667,0.947208 -2.1166667,2.116667 0,1.169458 0.9472083,2.116666 2.1166667,2.116666 1.1694584,0 2.1166671,-0.947208 2.1166671,-2.116666 V 6.8791669 H 12.7 V 4.7625001 Z" />
</vector>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="true" android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="@color/secondary_background"/>
<corners android:radius="10dp"/>
<stroke android:color="@color/light_blue" android:width="2dp"/>
</shape>
</item>
<item android:state_enabled="true">
<shape android:shape="rectangle">
<solid android:color="@color/secondary_background"/>
<corners android:radius="10dp"/>
</shape>
</item>
</selector>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/dark_blue"/>
<corners android:radius="4dp"/>
</shape>
</item>
</selector>

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rootLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<ImageButton
android:id="@+id/settingsButton"
style="@android:style/Widget.Material.ImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="6dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/settings"
android:src="@drawable/ic_baseline_settings_24"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/downloadInputBox"
app:layout_constraintTop_toTopOf="parent"
app:tint="?android:attr/textColorSecondary" />
<EditText
android:id="@+id/downloadInputBox"
style="@style/Widget.AppCompat.AutoCompleteTextView"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginStart="6dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="4dp"
android:background="@drawable/search_input"
android:drawableStart="@drawable/ic_baseline_link_24"
android:drawablePadding="4dp"
android:ems="10"
android:hint="@string/downloadInputHint"
android:inputType="textUri"
android:paddingStart="6dp"
android:paddingEnd="6dp"
app:layout_constraintEnd_toStartOf="@+id/settingsButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/downloadQueue"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginStart="6dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="6dp"
android:layout_marginBottom="6dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/downloadInputBox" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/loadingScreen"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardBackgroundColor="@color/cards_background"
app:cardCornerRadius="5dp"
app:cardElevation="1dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp">
<LinearLayout
android:id="@+id/trackBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="72dp"
android:layout_height="72dp">
<ImageView
android:id="@+id/coverImage"
android:layout_width="72dp"
android:layout_height="72dp"
android:contentDescription="@string/cover_image"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/no_cover" />
<TextView
android:id="@+id/qualityLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/tag_background"
android:padding="2dp"
android:textColor="@color/white"
android:textSize="11sp"
app:layout_constraintBottom_toBottomOf="@+id/coverImage"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="start|center_vertical"
android:orientation="vertical"
android:paddingStart="8dp"
android:paddingEnd="4dp">
<TextView
android:id="@+id/downloadTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
<TextView
android:id="@+id/downloadArtist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="@dimen/secondary_alpha"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical|end"
android:orientation="vertical"
android:paddingStart="4dp"
android:paddingEnd="0dp">
<TextView
android:id="@+id/downloadProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/downloadFails"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<ProgressBar
android:id="@+id/downloadBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="24dp"
android:layout_marginEnd="4dp"
android:indeterminate="true"
android:max="100"
android:progress="0"
app:layout_constraintEnd_toStartOf="@+id/downloadStatus"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/trackBox" />
<ImageView
android:id="@+id/downloadStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:foregroundTint="#505050"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/downloadBar"
app:layout_constraintTop_toBottomOf="@+id/trackBox"
app:srcCompat="@drawable/ic_baseline_list_24" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View file

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingBottom="16dp">
<ImageView
android:id="@+id/imageView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
android:layout_marginEnd="48dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_deezer_logo" />
<EditText
android:id="@+id/emailField"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="24dp"
android:hint="@string/prompt_email"
android:inputType="textEmailAddress"
android:minHeight="48dp"
android:selectAllOnFocus="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
<EditText
android:id="@+id/passwordField"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="24dp"
android:hint="@string/prompt_password"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:minHeight="48dp"
android:selectAllOnFocus="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/emailField" />
<Button
android:id="@+id/loginButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginStart="48dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="48dp"
android:layout_marginBottom="64dp"
android:enabled="true"
android:text="@string/action_sign_in"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/passwordField"
app:layout_constraintVertical_bias="0.2" />
<ProgressBar
android:id="@+id/loginLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="32dp"
android:layout_marginTop="64dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="64dp"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/passwordField"
app:layout_constraintHorizontal_bias="0.497"
app:layout_constraintStart_toStartOf="@+id/passwordField"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.9" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,72 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/loggedInSection"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/userAvatar"
android:layout_width="115dp"
android:layout_height="115dp"
android:layout_marginStart="8dp"
android:layout_marginTop="12dp"
android:contentDescription="@string/userAvatar_description"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars" />
<TextView
android:id="@+id/userName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
app:layout_constraintStart_toEndOf="@+id/userAvatar"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/userInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintStart_toEndOf="@+id/userAvatar"
app:layout_constraintTop_toBottomOf="@+id/userName" />
<Button
android:id="@+id/logoutButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:text="@string/logout_button"
app:layout_constraintStart_toEndOf="@+id/userAvatar"
app:layout_constraintTop_toBottomOf="@+id/userInfo" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/preferencesFragment"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="blue">#FF1976d2</color>
<color name="dark_blue">#FF004ba0</color>
<color name="light_blue">#ff63a4ff</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="primary_background">#1D1D1D</color>
<color name="secondary_background">#000000</color>
<color name="cards_background">#000000</color>
<color name="logo_text">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,18 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.DeezerDownloader" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/blue</item>
<item name="colorPrimaryVariant">@color/dark_blue</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/light_blue</item>
<item name="colorSecondaryVariant">@color/blue</item>
<item name="colorOnSecondary">@color/white</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">@color/secondary_background</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
<!-- Customize your theme here. -->
<item name="cardBackgroundColor">@color/secondary_background</item>
</style>
</resources>

View file

@ -0,0 +1,14 @@
<resources>
<!-- Reply Preference -->
<string-array name="download_quality_entries">
<item>FLAC</item>
<item>MP3 320kbps</item>
<item>MP3 128kbps</item>
</string-array>
<string-array name="download_quality_values">
<item>9</item>
<item>3</item>
<item>1</item>
</string-array>
</resources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="blue">#FF1976d2</color>
<color name="dark_blue">#FF004ba0</color>
<color name="light_blue">#ff63a4ff</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="primary_background">#FFFFFF</color>
<color name="secondary_background">#ededed</color>
<color name="cards_background">#FFFFFF</color>
<color name="logo_text">#000000</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="secondary_alpha" type="dimen" format="float">0.57</item>
</resources>

View file

@ -0,0 +1,25 @@
<resources>
<string name="app_name">Deezer Downloader</string>
<string name="settings">Settings</string>
<string name="downloadInputHint">URL...</string>
<string name="title_activity_settings">SettingsActivity</string>
<!-- Preference Titles -->
<string name="account_header">Account</string>
<string name="download_header">Downloads</string>
<!-- Account Preferences -->
<string name="logout_button">Logout</string>
<!-- Download Preferences -->
<string name="download_quality">Download quality</string>
<string name="download_quality_description">Choose at which quality to download the tracks</string>
<!-- Strings related to login -->
<string name="prompt_email">Email</string>
<string name="prompt_password">Password</string>
<string name="action_sign_in">Log in</string>
<string name="title_activity_login">Login</string>
<string name="cover_image">Cover Image</string>
<string name="userAvatar_description">User\'s Avatar</string>
</resources>

View file

@ -0,0 +1,17 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.DeezerDownloader" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/blue</item>
<item name="colorPrimaryVariant">@color/dark_blue</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/light_blue</item>
<item name="colorSecondaryVariant">@color/blue</item>
<item name="colorOnSecondary">@color/white</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">@color/secondary_background</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,15 @@
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
android:icon="@drawable/ic_baseline_get_app_24"
android:title="@string/download_header">
<ListPreference
android:defaultValue="1"
android:entries="@array/download_quality_entries"
android:entryValues="@array/download_quality_values"
android:icon="@drawable/ic_baseline_equalizer_24"
android:key="download_quality"
android:summary="@string/download_quality_description"
android:title="@string/download_quality" />
</PreferenceCategory>
</PreferenceScreen>

View file

@ -0,0 +1,17 @@
package app.deemix.downloader
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

10
build.gradle Normal file
View file

@ -0,0 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.2.1' apply false
id 'com.android.library' version '7.2.1' apply false
id 'org.jetbrains.kotlin.android' version '1.6.20' apply false
}
task clean(type: Delete) {
delete rootProject.buildDir
}

23
gradle.properties Normal file
View file

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,6 @@
#Sun Mar 13 21:48:14 CET 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

185
gradlew vendored Executable file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View file

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

16
settings.gradle Normal file
View file

@ -0,0 +1,16 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Deezer Downloader"
include ':app'