initial commit

This commit is contained in:
DJDoubleD
2024-07-21 23:42:21 +02:00
commit 096bf16a32
166 changed files with 38913 additions and 0 deletions

71
.gitignore vendored Normal file
View File

@@ -0,0 +1,71 @@
#Key stuff
key.properties
**/*.keystore
**/*.jks
android/key.properties
android/local.properties
scrobblenaut/.idea
scrobblenaut/.dart_tool
.gradle/
android/.gradle
android/.idea
.flutter-plugins
.flutter-plugins-dependencies
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
android/.idea/
android/local.properties
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
.vscode/
android/app/.cxx
# Flutter/Dart/Pub related
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
coverage/
pubspec.lock
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Exceptions to above rules.
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
# Envied
.env
./lib/utils/env.g.dart

18
.gitmodules vendored Normal file
View File

@@ -0,0 +1,18 @@
[submodule "custom_navigator"]
path = custom_navigator
url = https://github.com/DJDoubleD/custom_navigator.git
[submodule "equalizer_flutter"]
path = equalizer_flutter
url = https://github.com/DJDoubleD/equalizer_flutter.git
[submodule "external_path"]
path = external_path
url = https://github.com/DJDoubleD/external_path.git
[submodule "marquee"]
path = marquee
url = https://github.com/DJDoubleD/marquee.git
[submodule "move_to_background"]
path = move_to_background
url = https://github.com/DJDoubleD/move_to_background.git
[submodule "scrobblenaut"]
path = scrobblenaut
url = https://github.com/DJDoubleD/Scrobblenaut.git

45
.metadata Normal file
View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
version:
revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
channel: stable
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
- platform: android
create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
- platform: ios
create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
- platform: linux
create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
- platform: macos
create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
- platform: web
create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
- platform: windows
create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

72
README.md Normal file
View File

@@ -0,0 +1,72 @@
![ReFreezer](./assets/banner.png?raw=true)
[![Latest Version](https://img.shields.io/github/v/release/DJDoubleD/ReFreezer?color=blue)](../../releases/latest)
[![Release date](https://img.shields.io/github/release-date/DJDoubleD/ReFreezer)](../../releases/latest)
[![Downloads Latest](https://img.shields.io/github/downloads/DJDoubleD/ReFreezer/latest/total?color=blue&label=downloads%20latest)](../../releases)
[![Downloads Total](https://img.shields.io/github/downloads/DJDoubleD/ReFreezer/total?color=blue&label=downloads%20total)](../../releases)
[![Dart](https://img.shields.io/badge/Dart-0175C2?style=for-the-badge&logo=dart&logoColor=white)](https://dart.dev/)
[![Flutter](https://img.shields.io/badge/Flutter-02569B?style=for-the-badge&logo=flutter&logoColor=white)](https://flutter.dev/)
[![Java](https://img.shields.io/badge/Java-ED8B00?style=for-the-badge&logo=openjdk&logoColor=white)](https://www.java.com/)
---
<!--- # ReFreezer --->
An alternative Deezer music streaming & downloading client, based on Freezer.
The entire codebase has been updated/rewritten to be compatible with the latest version of flutter, the dart SDK & android (current build target is API level 34).
## Features & changes
- Restored all features of the old Freezer app, most notably:
- Restored all login options
- Restored Highest quality streaming and download options (premium account required, free accounts limited to MP3 128kbps)
- Support downloading to external storage (sdcard) for android 11 and up
- Restored homescreen and added new Flow & Mood smart playlist options
- Improved/fixed queue screen and queue handling (shuffle & rearranging)
- Updated lyrics screen to also support unsynced lyrics
- Some minor UI changes to better accomadate horizontal/tablet view
- Updated entire codebase to fully support latest flutter & dart SDK versions
- Updated to gradle version 8.7
- Removed included c libraries (openssl & opencrypto) and replaced them with custom native java implementation
- Replaced the included decryptor-jni c library with a custom native java implementation
- Implemented null-safety
- Removed the need of custom just_audio & audio_service plugin versions & refactored source code to use the latest version of the official plugins
- Multiple other fixes
## Compile from source
Install the latest flutter SDK: <https://flutter.dev/docs/get-started/install>
(Optional) Generate keys for release build: <https://flutter.dev/docs/deployment/android>
Download source:
```powershell
git clone https://github.com/DJDoubleD/ReFreezer
git submodule init
git submodule update
```
Compile:
```powershell
flutter pub get
dart run build_runner clean
dart run build_runner build --delete-conflicting-outputs
flutter build apk --split-per-abi --release
```
NOTE: You have to use own keys, or build debug using `flutter build apk --debug`
## Disclaimer & Legal
**ReFreezer** was not developed for piracy, but educational and private usage.
It may be illegal to use this in your country!
I will not be responsible for how you use **ReFreezer**.
**ReFreezer** uses both Deezer's public and internal API's, but is not endorsed, certified or otherwise approved in any way by Deezer.
The Deezer brand and name is the registered trademark of its respective owner.
**ReFreezer** has no partnership, sponsorship or endorsement with Deezer.
By using **ReFreezer** you agree to the following: <https://www.deezer.com/legal/cgu>

46
analysis_options.yaml Normal file
View File

@@ -0,0 +1,46 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at
# https://dart-lang.github.io/linter/lints/index.html.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
constant_identifier_names: false
library_private_types_in_public_api: false
prefer_adjacent_string_concatenation: false
prefer_collection_literals: false
prefer_interpolation_to_compose_strings: false
unnecessary_string_escapes: false
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
analyzer:
exclude:
- "**/*.g.dart"
# workaround for https://github.com/dart-lang/sdk/issues/42910
- "Scrobblenaut/**"
- "custom_navigator/**"
- "equalizer_flutter/**"
- "external_path/**"
- "move_to_background/**"
- "saf/**"

13
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties
**/*.keystore
**/*.jks

103
android/app/build.gradle Normal file
View File

@@ -0,0 +1,103 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace 'r.r.refreezer'
compileSdk 34
//ndkVersion flutter.ndkVersion
ndkVersion "26.1.10909125"
buildFeatures.buildConfig = true
compileOptions {
// Flag to enable support for the new language APIs
coreLibraryDesugaringEnabled true
// Sets Java compatibility to Java 8
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
defaultConfig {
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
applicationId "r.r.refreezer"
minSdk 21
targetSdk 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
signingConfigs {
release {
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
shrinkResources false
minifyEnabled false
}
debug {
applicationIdSuffix '.debug'
versionNameSuffix ' DEBUG'
}
}
/*externalNativeBuild {
ndkBuild {
path file('src/main/jni/Android.mk')
}
}*/
lint {
disable 'InvalidPackage'
}
}
dependencies {
implementation files('libs/extension-flac.aar') // Required for older Android version
implementation("androidx.activity:activity-ktx:1.9.0")
//implementation group: 'net.jthink', name: 'jaudiotagger', version: '3.0.1' // requires java 8 so no android 6
implementation group: 'net.jthink', name: 'jaudiotagger', version: '2.2.5'
implementation group: 'org.nanohttpd', name: 'nanohttpd', version: '2.3.1'
implementation 'androidx.documentfile:documentfile:1.0.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
}
flutter {
source '../..'
}

Binary file not shown.

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,150 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
io.flutter.app.FlutterApplication is an android.app.Application that
calls FlutterMain.startInitialization(this); in its onCreate method.
In most cases you can leave this as-is, but you if you want to provide
additional functionality it is fine to subclass or reimplement
FlutterApplication and put your custom class here.
-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!--<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
android:required="false"
android:minSdkVersion="30"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"
android:required="false"
android:minSdkVersion="30"/>
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:name="${applicationName}"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher_round"
android:label="ReFreezer"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true">
<!-- DownloadService is marked as exported=false to restrict access to
only the current app. Was originally exported=true -->
<service
android:name=".DownloadService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false"
android:process=":ReFreezerDownloadService" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:banner="@mipmap/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:logo="@mipmap/ic_launcher"
android:windowSoftInputMode="adjustResize"
android:exported = "true">
<!--
Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI.
-->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep Links -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="www.deezer.com" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="deezer.com" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="deezer.page.link" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="www.deezer.page.link" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!--
Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java
-->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true" tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<!-- ADD THIS "RECEIVER" element -->
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true" tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,639 @@
package r.r.refreezer;
import android.util.Log;
import android.util.Pair;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.tag.FieldKey;
import org.jaudiotagger.tag.Tag;
import org.jaudiotagger.tag.TagOptionSingleton;
import org.jaudiotagger.tag.flac.FlacTag;
import org.jaudiotagger.tag.id3.ID3v23Tag;
import org.jaudiotagger.tag.id3.valuepair.ImageFormats;
import org.jaudiotagger.tag.images.Artwork;
import org.jaudiotagger.tag.images.ArtworkFactory;
import org.jaudiotagger.tag.reference.PictureTypes;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.RandomAccessFile;
import java.net.URL;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Scanner;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import javax.net.ssl.HttpsURLConnection;
public class Deezer {
static String USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36";
DownloadLog logger;
String token;
String arl;
String sid;
String licenseToken;
String contentLanguage = "en";
boolean authorized = false;
boolean authorizing = false;
Deezer() {}
//Initialize for logging
void init(DownloadLog logger, String arl) {
//Load native
//System.loadLibrary("decryptor-jni");
this.logger = logger;
this.arl = arl;
}
// Method for when using c libraries for decryption
//public native void decryptFile(String trackId, String inputFilename, String outputFilename);
//Authorize GWLight API
public void authorize() {
if (!authorized || sid == null || token == null) {
authorizing = true;
try {
callGWAPI("deezer.getUserData", "{}");
authorized = true;
} catch (Exception e) {
logger.warn("Error authorizing to Deezer API! " + e);
}
}
authorizing = false;
}
//Make POST request
private String POST(String _url, String data, String cookies) {
String result = null;
try {
URL url = new URL(_url);
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setConnectTimeout(20000);
connection.setDoOutput(true);
connection.setRequestMethod("POST");
connection.setRequestProperty("User-Agent", USER_AGENT);
connection.setRequestProperty("Accept-Language", contentLanguage + ",*");
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "*/*");
if (cookies != null) {
connection.setRequestProperty("Cookie", cookies);
}
//Write body
try (DataOutputStream wr = new DataOutputStream(connection.getOutputStream())) {
wr.writeBytes(data);
}
//Get response
try (Scanner scanner = new Scanner(connection.getInputStream())) {
StringBuilder output = new StringBuilder();
while (scanner.hasNext()) {
output.append(scanner.nextLine());
}
result = output.toString();
}
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
public JSONObject callGWAPI(String method, String body) throws Exception {
//Get token
if (token == null) {
token = "null";
callGWAPI("deezer.getUserData", "{}");
}
String data = POST(
"https://www.deezer.com/ajax/gw-light.php?method=" + method + "&input=3&api_version=1.0&api_token=" + token,
body,
"arl=" + arl + "; sid=" + sid
);
//Parse JSON
JSONObject out = new JSONObject(data);
//Save token
if ((token == null || token.equals("null")) && method.equals("deezer.getUserData")) {
token = out.getJSONObject("results").getString("checkForm");
sid = out.getJSONObject("results").getString("SESSION_ID");
// Get User license code
try {
JSONObject userData = out.getJSONObject("results").getJSONObject("USER");
licenseToken = userData.getJSONObject("OPTIONS").getString("license_token");
} catch (JSONException e) {
e.printStackTrace();
logger.warn("Error getting user License Token - FLAC not available! " + e);
}
}
return out;
}
//api.deezer.com/$method/$param
public JSONObject callPublicAPI(String method, String param) throws Exception {
URL url = new URL("https://api.deezer.com/" + method + "/" + param);
HttpsURLConnection connection = (HttpsURLConnection)url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept-Language", contentLanguage + ",*");
connection.setConnectTimeout(20000);
connection.connect();
//Get string data
StringBuilder data = new StringBuilder();
InputStream inputStream = connection.getInputStream();
try (Scanner scanner = new Scanner(new InputStreamReader(inputStream))) {
while (scanner.hasNext()) {
data.append(scanner.nextLine());
}
} finally {
connection.disconnect();
}
//Parse JSON & return
return new JSONObject(data.toString());
}
//Generate track download URL
public String generateTrackUrl(String trackId, String md5origin, String mediaVersion, int quality) {
try {
int magic = 164;
ByteArrayOutputStream step1 = new ByteArrayOutputStream();
step1.write(md5origin.getBytes());
step1.write(magic);
step1.write(Integer.toString(quality).getBytes());
step1.write(magic);
step1.write(trackId.getBytes());
step1.write(magic);
step1.write(mediaVersion.getBytes());
//Get MD5
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update(step1.toByteArray());
byte[] digest = md5.digest();
String md5hex = DeezerDecryptor.bytesToHex(digest).toLowerCase();
//Step 2
ByteArrayOutputStream step2 = new ByteArrayOutputStream();
step2.write(md5hex.getBytes());
step2.write(magic);
step2.write(step1.toByteArray());
step2.write(magic);
//Pad step2 with dots, to get correct length
while(step2.size()%16 > 0) step2.write(46);
//Prepare AES encryption
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
SecretKeySpec key = new SecretKeySpec("jo6aey6haid2Teih".getBytes(), "AES");
cipher.init(Cipher.ENCRYPT_MODE, key);
//Encrypt
StringBuilder step3 = new StringBuilder();
for (int i=0; i<step2.size()/16; i++) {
byte[] b = Arrays.copyOfRange(step2.toByteArray(), i*16, (i+1)*16);
step3.append(DeezerDecryptor.bytesToHex(cipher.doFinal(b)).toLowerCase());
}
//Return joined to URL
return "https://e-cdns-proxy-" + md5origin.charAt(0) + ".dzcdn.net/mobile/1/" + step3;
} catch (Exception e) {
e.printStackTrace();
logger.error("Error generating track URL! ID: " + trackId + " " + e);
}
return null;
}
// Returns URL and whether encrypted
public Pair<String, Boolean> getTrackUrl(String trackId, String trackToken, String md5origin, String mediaVersion,
int quality, int refreshAttempt) {
// Hi-Fi url gen
if (this.licenseToken != null && (quality == 3 || quality == 9)) {
String url = null;
String format = "FLAC";
if (quality == 3) format = "MP3_320";
try {
// Create track_url payload
String payload = "{\n" +
"\"license_token\": \"" + licenseToken + "\",\n" +
"\"media\": [{ \"type\": \"FULL\", \"formats\": [{ \"cipher\": \"BF_CBC_STRIPE\", \"format\": \"" + format + "\"}]}],\n" +
"\"track_tokens\": [\"" + trackToken + "\"]\n" +
"}";
String output = POST("https://media.deezer.com/v1/get_url", payload, "arl=" + arl);
JSONObject result = new JSONObject(output);
if (result.has("data")){
for (int i = 0; i < result.getJSONArray("data").length(); i++){
JSONObject data = result.getJSONArray("data").getJSONObject(i);
if (data.has("errors")){
JSONArray errors = data.getJSONArray("errors");
for (int j = 0; j < errors.length(); j++) {
JSONObject error = errors.getJSONObject(j);
if (error.getInt("code") == 2001 && refreshAttempt < 1) {
// Track token is expired, attempt 1 track data refresh
JSONObject privateJson = callGWAPI("song.getListData", "{\"sng_ids\": [" + trackId + "]}");
JSONObject trackData = privateJson.getJSONObject("results").getJSONArray("data").getJSONObject(0);
trackId = trackData.getString("SNG_ID");
trackToken = trackData.getString("TRACK_TOKEN");
md5origin = trackData.getString("MD5_ORIGIN");
mediaVersion = trackData.getString("MEDIA_VERSION");
// Retry getTrackUrl with refreshed track data and increment retry count
return getTrackUrl(trackId, trackToken, md5origin, mediaVersion, quality, refreshAttempt + 1);
}
}
logger.warn("Failed in getting streaming URL: " + data.get("errors"));
}
if (data.has("media") && data.getJSONArray("media").length() > 0){
url = data.getJSONArray("media").getJSONObject(0).getJSONArray("sources").getJSONObject(0).getString("url");
break;
}
}
}
} catch (Exception e) {
e.printStackTrace();
logger.warn("Error getting streaming URL: " + e);
}
return new Pair<String, Boolean>(url,true);
}
// Legacy url generation, now only for MP3_128
return new Pair<String, Boolean>(generateTrackUrl(trackId, md5origin, mediaVersion, quality), true);
}
public static String sanitize(String input) {
return input.replaceAll("[\\\\/?*:%<>|\"]", "").replace("$", "\\$");
}
public static String generateFilename(String original, JSONObject publicTrack, JSONObject publicAlbum, int newQuality) throws Exception {
original = original.replaceAll("%title%", sanitize(publicTrack.getString("title")));
original = original.replaceAll("%album%", sanitize(publicTrack.getJSONObject("album").getString("title")));
original = original.replaceAll("%artist%", sanitize(publicTrack.getJSONObject("artist").getString("name")));
// Album might not be available
try {
original = original.replaceAll("%albumArtist%", sanitize(publicAlbum.getJSONObject("artist").getString("name")));
} catch (Exception e) {
original = original.replaceAll("%albumArtist%", sanitize(publicTrack.getJSONObject("artist").getString("name")));
}
//Artists
String artists = "";
String feats = "";
for (int i=0; i<publicTrack.getJSONArray("contributors").length(); i++) {
String artist = publicTrack.getJSONArray("contributors").getJSONObject(i).getString("name");
if (!artists.contains(artist))
artists += ", " + artist;
if (i > 0 && !artists.contains(artist) && !feats.contains(artist))
feats += ", " + artist;
}
original = original.replaceAll("%artists%", sanitize(artists).substring(2));
if (feats.length() >= 2)
original = original.replaceAll("%feats%", sanitize(feats).substring(2));
//Track number
int trackNumber = publicTrack.getInt("track_position");
original = original.replaceAll("%trackNumber%", Integer.toString(trackNumber));
original = original.replaceAll("%0trackNumber%", String.format("%02d", trackNumber));
//Year
original = original.replaceAll("%year%", publicTrack.getString("release_date").substring(0, 4));
original = original.replaceAll("%date%", publicTrack.getString("release_date"));
//Remove leading dots
original = original.replaceAll("/\\.+", "/");
if (newQuality == 9) return original + ".flac";
return original + ".mp3";
}
//Deezer patched something so getting metadata of user uploaded MP3s is not working anymore
public static String generateUserUploadedMP3Filename(String original, String title) {
String[] ignored = {"%feats%", "%trackNumber%", "%0trackNumber%", "%year%", "%date%", "%album%", "%artist%", "%artists%", "%albumArtist%"};
for (String i : ignored) {
original = original.replaceAll(i, "");
}
original = original.replace("%title%", sanitize(title));
return original;
}
//Tag track with data from API
public void tagTrack(String path, JSONObject publicTrack, JSONObject publicAlbum, String cover, JSONObject lyricsData, JSONObject privateJson, DownloadService.DownloadSettings settings) throws Exception {
TagOptionSingleton.getInstance().setAndroid(true);
//Load file
AudioFile f = AudioFileIO.read(new File(path));
boolean isFlac = true;
if (f.getAudioHeader().getFormat().contains("MPEG")) {
f.setTag(new ID3v23Tag());
isFlac = false;
}
Tag tag = f.getTag();
if (settings.tags.title) tag.setField(FieldKey.TITLE, publicTrack.getString("title"));
if (settings.tags.album) tag.setField(FieldKey.ALBUM, publicTrack.getJSONObject("album").getString("title"));
//Artist
String artists = "";
for (int i=0; i<publicTrack.getJSONArray("contributors").length(); i++) {
String artist = publicTrack.getJSONArray("contributors").getJSONObject(i).getString("name");
if (!artists.contains(artist))
artists += settings.artistSeparator + artist;
}
boolean albumAvailable = !publicAlbum.has("error");
if (settings.tags.artist) tag.addField(FieldKey.ARTIST, artists.substring(settings.artistSeparator.length()));
if (settings.tags.track) tag.setField(FieldKey.TRACK, String.format("%02d", publicTrack.getInt("track_position")));
if (settings.tags.disc) tag.setField(FieldKey.DISC_NO, Integer.toString(publicTrack.getInt("disk_number")));
if (settings.tags.albumArtist && albumAvailable) tag.setField(FieldKey.ALBUM_ARTIST, publicAlbum.getJSONObject("artist").getString("name"));
if (settings.tags.date) tag.setField(FieldKey.YEAR, publicTrack.getString("release_date").substring(0, 4));
if (settings.tags.label && albumAvailable) tag.setField(FieldKey.RECORD_LABEL, publicAlbum.getString("label"));
if (settings.tags.isrc) tag.setField(FieldKey.ISRC, publicTrack.getString("isrc"));
if (settings.tags.upc && albumAvailable) tag.setField(FieldKey.BARCODE, publicAlbum.getString("upc"));
if (settings.tags.trackTotal && albumAvailable) tag.setField(FieldKey.TRACK_TOTAL, Integer.toString(publicAlbum.getInt("nb_tracks")));
//BPM
if (publicTrack.has("bpm") && (int)publicTrack.getDouble("bpm") > 0)
if (settings.tags.bpm) tag.setField(FieldKey.BPM, Integer.toString((int)publicTrack.getDouble("bpm")));
//Unsynced lyrics
if (lyricsData != null && settings.tags.lyrics) {
try {
String lyrics = lyricsData.getString("LYRICS_TEXT");
tag.setField(FieldKey.LYRICS, lyrics);
} catch (Exception e) {
Log.w("WARN", "Error adding unsynced lyrics!");
}
}
//Genres
String genres = "";
if (albumAvailable) {
for (int i=0; i<publicAlbum.getJSONObject("genres").getJSONArray("data").length(); i++) {
String genre = publicAlbum.getJSONObject("genres").getJSONArray("data").getJSONObject(0).getString("name");
if (!genres.contains(genre)) {
genres += ", " + genre;
}
}
if (genres.length() > 2 && settings.tags.genre)
tag.setField(FieldKey.GENRE, genres.substring(2));
}
//Additional tags from private api
if (settings.tags.contributors) {
try {
if (privateJson != null && privateJson.has("SNG_CONTRIBUTORS")) {
JSONObject contrib = privateJson.getJSONObject("SNG_CONTRIBUTORS");
//Composer
if (contrib.has("composer")) {
JSONArray composers = contrib.getJSONArray("composer");
String composer = "";
for (int i = 0; i < composers.length(); i++)
composer += settings.artistSeparator + composers.getString(i);
if (composer.length() > 2)
tag.setField(FieldKey.COMPOSER, composer.substring(settings.artistSeparator.length()));
}
//Engineer
if (contrib.has("engineer")) {
JSONArray engineers = contrib.getJSONArray("engineer");
String engineer = "";
for (int i = 0; i < engineers.length(); i++)
engineer += settings.artistSeparator + engineers.getString(i);
if (engineer.length() > 2)
tag.setField(FieldKey.ENGINEER, engineer.substring(settings.artistSeparator.length()));
}
//Mixer
if (contrib.has("mixer")) {
JSONArray mixers = contrib.getJSONArray("mixer");
String mixer = "";
for (int i = 0; i < mixers.length(); i++)
mixer += settings.artistSeparator + mixers.getString(i);
if (mixer.length() > 2)
tag.setField(FieldKey.MIXER, mixer.substring(settings.artistSeparator.length()));
}
//Producer
if (contrib.has("producer")) {
JSONArray producers = contrib.getJSONArray("producer");
String producer = "";
for (int i = 0; i < producers.length(); i++)
producer += settings.artistSeparator + producers.getString(i);
if (producer.length() > 2)
tag.setField(FieldKey.MIXER, producer.substring(settings.artistSeparator.length()));
}
//FLAC Only
if (isFlac) {
//Author
if (contrib.has("author")) {
JSONArray authors = contrib.getJSONArray("author");
String author = "";
for (int i = 0; i < authors.length(); i++)
author += settings.artistSeparator + authors.getString(i);
if (author.length() > 2)
((FlacTag) tag).setField("AUTHOR", author.substring(settings.artistSeparator.length()));
}
//Writer
if (contrib.has("writer")) {
JSONArray writers = contrib.getJSONArray("writer");
String writer = "";
for (int i = 0; i < writers.length(); i++)
writer += settings.artistSeparator + writers.getString(i);
if (writer.length() > 2)
((FlacTag) tag).setField("WRITER", writer.substring(settings.artistSeparator.length()));
}
}
}
} catch (Exception e) {
logger.warn("Error writing contributors data: " + e);
}
}
File coverFile = new File(cover);
boolean addCover = (coverFile.exists() && coverFile.length() > 0);
if (isFlac) {
//FLAC Specific tags
if (settings.tags.date) ((FlacTag)tag).setField("DATE", publicTrack.getString("release_date"));
//Cover
if (addCover && settings.tags.albumArt) {
try (RandomAccessFile cf = new RandomAccessFile(coverFile, "r")) {
byte[] coverData = new byte[(int) cf.length()];
cf.read(coverData);
tag.setField(((FlacTag) tag).createArtworkField(
coverData,
PictureTypes.DEFAULT_ID,
ImageFormats.MIME_TYPE_JPEG,
"cover",
settings.albumArtResolution,
settings.albumArtResolution,
24,
0
));
} catch (Exception e) {
logger.warn("Error writing coverFile artwork: " + e);
}
}
} else {
if (addCover && settings.tags.albumArt) {
Artwork art = ArtworkFactory.createArtworkFromFile(coverFile);
tag.addField(art);
}
}
//Save
AudioFileIO.write(f);
}
//Create JSON file, privateJsonData = `song.getLyrics`
public static String generateLRC(JSONObject privateJsonData, JSONObject publicTrack) throws Exception {
String output = "";
//Create metadata
String title = publicTrack.getString("title");
String album = publicTrack.getJSONObject("album").getString("title");
String artists = "";
for (int i=0; i<publicTrack.getJSONArray("contributors").length(); i++) {
artists += ", " + publicTrack.getJSONArray("contributors").getJSONObject(i).getString("name");
}
//Write metadata
output += "[ar:" + artists.substring(2) + "]\r\n[al:" + album + "]\r\n[ti:" + title + "]\r\n";
//Get lyrics
int counter = 0;
JSONArray syncLyrics = privateJsonData.getJSONArray("LYRICS_SYNC_JSON");
for (int i=0; i<syncLyrics.length(); i++) {
JSONObject lyric = syncLyrics.getJSONObject(i);
if (lyric.has("lrc_timestamp") && lyric.has("line")) {
output += lyric.getString("lrc_timestamp") + lyric.getString("line") + "\r\n";
counter += 1;
}
}
if (counter == 0) throw new Exception("Empty Lyrics!");
return output;
}
static class QualityInfo {
int quality;
String md5origin;
String mediaVersion;
String trackId;
String trackToken;
int initialQuality;
DownloadLog logger;
boolean encrypted;
QualityInfo(int quality, String trackId, String trackToken, String md5origin, String mediaVersion, DownloadLog logger) {
this.quality = quality;
this.initialQuality = quality;
this.trackId = trackId;
this.trackToken = trackToken;
this.mediaVersion = mediaVersion;
this.md5origin = md5origin;
this.logger = logger;
}
String fallback(Deezer deezer) {
//Quality fallback
try {
String url = qualityFallback(deezer);
//No quality
if (quality == -1)
throw new Exception("No quality to fallback to!");
//Success
return url;
} catch (Exception e) {
logger.warn("Quality fallback failed! ID: " + trackId + " " + e);
quality = initialQuality;
}
//Track ID Fallback
JSONObject privateJson = null;
try {
//Fetch meta
JSONObject privateRaw = deezer.callGWAPI("deezer.pageTrack", "{\"sng_id\": \"" + trackId + "\"}");
privateJson = privateRaw.getJSONObject("results").getJSONObject("DATA");
if (privateJson.has("FALLBACK")) {
//Fetch new track
String fallbackId = privateJson.getJSONObject("FALLBACK").getString("SNG_ID");
if (!fallbackId.equals(trackId)) {
JSONObject newPrivate = deezer.callGWAPI("song.getListData", "{\"sng_ids\": [" + fallbackId + "]}");
JSONObject trackData = newPrivate.getJSONObject("results").getJSONArray("data").getJSONObject(0);
trackId = trackData.getString("SNG_ID");
trackToken = trackData.getString("TRACK_TOKEN");
md5origin = trackData.getString("MD5_ORIGIN");
mediaVersion = trackData.getString("MEDIA_VERSION");
return fallback(deezer);
}
}
} catch (Exception e) {
logger.error("ID fallback failed! ID: " + trackId + " " + e);
}
//ISRC Fallback
try {
JSONObject newTrackJson = deezer.callPublicAPI("track", "isrc:" + privateJson.getString("ISRC"));
//Same track check
if (newTrackJson.getInt("id") == Integer.parseInt(trackId)) throw new Exception("No more to ISRC fallback!");
//Get private data
privateJson = deezer.callGWAPI("song.getListData", "{\"sng_ids\": [" + newTrackJson.getInt("id") + "]}");
JSONObject trackData = privateJson.getJSONObject("results").getJSONArray("data").getJSONObject(0);
trackId = trackData.getString("SNG_ID");
trackToken = trackData.getString("TRACK_TOKEN");
md5origin = trackData.getString("MD5_ORIGIN");
mediaVersion = trackData.getString("MEDIA_VERSION");
return fallback(deezer);
} catch (Exception e) {
logger.error("ISRC Fallback failed, track unavailable! ID: " + trackId + " " + e);
}
return null;
}
private String qualityFallback(Deezer deezer) throws Exception {
Pair<String,Boolean> urlGen = deezer.getTrackUrl(trackId, trackToken, md5origin, mediaVersion, quality, 0);
this.encrypted = urlGen.second;
// initialise as "404 Not Found"
int urlResponseCode = 404;
if (urlGen.first != null) {
//Create HEAD requests to check if exists
URL url = new URL(urlGen.first);
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setRequestMethod("HEAD");
connection.setRequestProperty("User-Agent", USER_AGENT);
connection.setRequestProperty("Accept-Language", "*");
connection.setRequestProperty("Accept", "*/*");
urlResponseCode = connection.getResponseCode();
}
//Track not available
if (urlResponseCode > 400) {
logger.warn("Quality fallback, response code: " + urlResponseCode + ", current: " + Integer.toString(quality));
//-1 if no quality available
if (quality == 1) {
quality = -1;
return null;
}
if (quality == 3) quality = 1;
if (quality == 9) quality = 3;
return qualityFallback(deezer);
}
return urlGen.first;
}
}
}

View File

@@ -0,0 +1,94 @@
package r.r.refreezer;
import android.util.Log;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.MessageDigest;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
public class DeezerDecryptor {
public static void decryptFile(String trackId, String inputFilename, String outputFilename) throws IOException {
try (FileInputStream fis = new FileInputStream(inputFilename);
FileOutputStream fos = new FileOutputStream(outputFilename)) {
byte[] key = getKey(trackId);
byte[] buffer = new byte[2048];
int bytesRead;
int chunkCounter = 0;
while ((bytesRead = fis.read(buffer)) != -1) {
// Only every 3rd chunk of exactly 2048 bytes should be decrypted
if (bytesRead == 2048 && (chunkCounter % 3) == 0) {
buffer = decryptChunk(key, buffer);
}
fos.write(buffer, 0, bytesRead);
chunkCounter++;
}
} catch (IOException e) {
throw e;
}
}
public static String bytesToHex(byte[] bytes) {
final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
char[] hexChars = new char[bytes.length * 2];
for (int j = 0; j < bytes.length; j++) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
}
return new String(hexChars);
}
/**
* Generates the Track decryption key based on the provided track ID and a secret.
* @param id Track ID used to generate decryption key
* @return Decryption key for Track
*/
static byte[] getKey(String id) {
final String secret = "g4el58wc0zvf9na1";
try {
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update(id.getBytes());
byte[] md5id = md5.digest();
String idmd5 = bytesToHex(md5id).toLowerCase();
String key = "";
for(int i=0; i<16; i++) {
int s0 = idmd5.charAt(i);
int s1 = idmd5.charAt(i+16);
int s2 = secret.charAt(i);
key += (char)(s0^s1^s2);
}
return key.getBytes();
} catch (Exception e) {
Log.e("E", e.toString());
return new byte[0];
}
}
/**
* Decrypts a 2048-byte chunk of data using the Blowfish algorithm in CBC mode with no padding.
* The decryption key and the initial vector (IV) are used to decrypt the data.
* @param key Track key
* @param data 2048-byte chunk of data to decrypt
* @return Decrypted 2048-byte chunk
*
*/
static byte[] decryptChunk(byte[] key, byte[] data) {
try {
byte[] IV = {00, 01, 02, 03, 04, 05, 06, 07};
SecretKeySpec Skey = new SecretKeySpec(key, "Blowfish");
Cipher cipher = Cipher.getInstance("Blowfish/CBC/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, Skey, new javax.crypto.spec.IvParameterSpec(IV));
return cipher.doFinal(data);
}catch (Exception e) {
Log.e("D", e.toString());
return new byte[0];
}
}
}

View File

@@ -0,0 +1,113 @@
package r.r.refreezer;
import android.content.ContentValues;
import android.database.Cursor;
import java.util.HashMap;
public class Download {
int id;
String path;
boolean priv;
int quality;
String trackId;
String streamTrackId;
String trackToken;
String md5origin;
String mediaVersion;
DownloadState state;
String title;
String image;
//Dynamic
long received;
long filesize;
Download(int id, String path, boolean priv, int quality, DownloadState state, String trackId, String md5origin, String mediaVersion, String title, String image, String trackToken, String streamTrackId) {
this.id = id;
this.path = path;
this.priv = priv;
this.trackId = trackId;
this.md5origin = md5origin;
this.state = state;
this.mediaVersion = mediaVersion;
this.title = title;
this.image = image;
this.quality = quality;
this.trackToken = trackToken;
this.streamTrackId = streamTrackId;
}
enum DownloadState {
NONE(0),
DOWNLOADING (1),
POST(2),
DONE(3),
DEEZER_ERROR(4),
ERROR(5);
private final int value;
private DownloadState(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
//Negative TrackIDs = User uploaded MP3s.
public boolean isUserUploaded() {
return trackId.startsWith("-");
}
//Get download from SQLite cursor, HAS TO ALIGN (see DownloadsDatabase onCreate)
static Download fromSQL(Cursor cursor) {
return new Download(cursor.getInt(0),
cursor.getString(1),
cursor.getInt(2) == 1,
cursor.getInt(3),
DownloadState.values()[cursor.getInt(4)],
cursor.getString(5),
cursor.getString(6),
cursor.getString(7),
cursor.getString(8),
cursor.getString(9),
cursor.getString(10),
cursor.getString(11)
);
}
//Convert object from method call to SQL ContentValues
static ContentValues flutterToSQL(HashMap data) {
ContentValues values = new ContentValues();
values.put("path", (String)data.get("path"));
values.put("private", ((boolean)data.get("private")) ? 1 : 0);
values.put("state", 0);
values.put("trackId", (String)data.get("trackId"));
values.put("md5origin", (String)data.get("md5origin"));
values.put("mediaVersion", (String)data.get("mediaVersion"));
values.put("title", (String)data.get("title"));
values.put("image", (String)data.get("image"));
values.put("quality", (int)data.get("quality"));
values.put("trackToken", (String)data.get("trackToken"));
values.put("streamTrackId", (String)data.get("streamTrackId"));
return values;
}
//Used to send data to Flutter
HashMap toHashMap() {
HashMap map = new HashMap();
map.put("id", id);
map.put("path", path);
map.put("private", priv);
map.put("quality", quality);
map.put("trackId", trackId);
map.put("state", state.getValue());
map.put("title", title);
map.put("image", image);
//Only useful data, some are passed in updates
return map;
}
}

View File

@@ -0,0 +1,101 @@
package r.r.refreezer;
import android.content.Context;
import android.util.Log;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Locale;
public class DownloadLog {
BufferedWriter writer;
//Open/Create file
void open(Context context) {
File file = new File(context.getExternalFilesDir(""), "download.log");
try {
if (!file.exists()) {
file.createNewFile();
}
writer = new BufferedWriter(new FileWriter(file, true));
} catch (Exception ignored) {
Log.e("DOWN", "Error opening download log!");
}
}
//Close log
void close() {
try {
writer.close();
} catch (Exception ignored) {
Log.w("DOWN", "Error closing download log!");
}
}
String time() {
SimpleDateFormat format = new SimpleDateFormat("yyyy.MM.dd HH:mm:ss", Locale.US);
return format.format(Calendar.getInstance().getTime());
}
//Write error to log
void error(String info) {
if (writer == null) return;
String data = "E:" + time() + ": " + info;
try {
writer.write(data);
writer.newLine();
writer.flush();
} catch (Exception ignored) {
Log.w("DOWN", "Error writing into log.");
}
Log.e("DOWN", data);
}
//Write error to log with download info
void error(String info, Download download) {
if (writer == null) return;
String data = "E:" + time() + " (TrackID: " + download.trackId + ", ID: " + Integer.toString(download.id) + "): " +info;
try {
writer.write(data);
writer.newLine();
writer.flush();
} catch (Exception ignored) {
Log.w("DOWN", "Error writing into log.");
}
Log.e("DOWN", data);
}
//Write warning to log
void warn(String info) {
if (writer == null) return;
String data = "W:" + time() + ": " + info;
try {
writer.write(data);
writer.newLine();
writer.flush();
} catch (Exception ignored) {
Log.w("DOWN", "Error writing into log.");
}
Log.w("DOWN", data);
}
//Write warning to log with download info
void warn(String info, Download download) {
if (writer == null) return;
String data = "W:" + time() + " (TrackID: " + download.trackId + ", ID: " + Integer.toString(download.id) + "): " +info;
try {
writer.write(data);
writer.newLine();
writer.flush();
} catch (Exception ignored) {
Log.w("DOWN", "Error writing into log.");
}
Log.w("DOWN", data);
}
}

View File

@@ -0,0 +1,950 @@
package r.r.refreezer;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import android.util.Log;
import androidx.core.app.ActivityCompat;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.channels.FileChannel;
import java.text.DecimalFormat;
import java.util.ArrayList;
import javax.net.ssl.HttpsURLConnection;
public class DownloadService extends Service {
//Message commands
static final int SERVICE_LOAD_DOWNLOADS = 1;
static final int SERVICE_START_DOWNLOAD = 2;
static final int SERVICE_ON_PROGRESS = 3;
static final int SERVICE_SETTINGS_UPDATE = 4;
static final int SERVICE_STOP_DOWNLOADS = 5;
static final int SERVICE_ON_STATE_CHANGE = 6;
static final int SERVICE_REMOVE_DOWNLOAD = 7;
static final int SERVICE_RETRY_DOWNLOADS = 8;
static final int SERVICE_REMOVE_DOWNLOADS = 9;
static final String NOTIFICATION_CHANNEL_ID = "refreezerdownloads";
static final int NOTIFICATION_ID_START = 6969;
boolean running = false;
DownloadSettings settings;
Context context;
SQLiteDatabase db;
Deezer deezer = new Deezer();
Messenger serviceMessenger;
Messenger activityMessenger;
NotificationManagerCompat notificationManager;
ArrayList<Download> downloads = new ArrayList<>();
ArrayList<DownloadThread> threads = new ArrayList<>();
ArrayList<Boolean> updateRequests = new ArrayList<>();
boolean updating = false;
Handler progressUpdateHandler = new Handler();
DownloadLog logger = new DownloadLog();
public DownloadService() {
}
@Override
public void onCreate() {
super.onCreate();
//Setup notifications
context = this;
notificationManager = NotificationManagerCompat.from(context);
createNotificationChannel();
createProgressUpdateHandler();
//Setup logger, deezer api
logger.open(context);
deezer.init(logger, "");
//Get DB
DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext());
db = dbHelper.getWritableDatabase();
}
@Override
public void onDestroy() {
//Cancel notifications
notificationManager.cancelAll();
//Logger
logger.close();
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
//Set messengers
serviceMessenger = new Messenger(new IncomingHandler(this));
if (intent != null)
activityMessenger = intent.getParcelableExtra("activityMessenger");
return serviceMessenger.getBinder();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
//Get messenger
if (intent != null) {
activityMessenger = intent.getParcelableExtra("activityMessenger");
}
//return super.onStartCommand(intent, flags, startId);
//Prevent battery savers I guess
return START_STICKY;
}
//Android O+ Notifications
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, "Downloads", NotificationManager.IMPORTANCE_MIN);
NotificationManager nManager = getSystemService(NotificationManager.class);
nManager.createNotificationChannel(channel);
}
}
//Update download tasks
private void updateQueue() {
db.beginTransaction();
//Clear downloaded tracks
for (int i = threads.size() - 1; i >= 0; i--) {
Download.DownloadState state = threads.get(i).download.state;
if (state == Download.DownloadState.NONE || state == Download.DownloadState.DONE || state == Download.DownloadState.ERROR || state == Download.DownloadState.DEEZER_ERROR) {
Download d = threads.get(i).download;
//Update in queue
for (int j = 0; j < downloads.size(); j++) {
if (downloads.get(j).id == d.id) {
downloads.set(j, d);
}
}
updateProgress();
//Save to DB
ContentValues row = new ContentValues();
row.put("state", state.getValue());
row.put("quality", d.quality);
db.update("Downloads", row, "id == ?", new String[]{Integer.toString(d.id)});
//Update library
if (state == Download.DownloadState.DONE && !d.priv) {
Uri uri = Uri.fromFile(new File(threads.get(i).outFile.getPath()));
sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri));
}
//Remove thread
threads.remove(i);
}
}
db.setTransactionSuccessful();
db.endTransaction();
//Create new download tasks
if (running) {
int nThreads = settings.downloadThreads - threads.size();
for (int i = 0; i < nThreads; i++) {
for (int j = 0; j < downloads.size(); j++) {
if (downloads.get(j).state == Download.DownloadState.NONE) {
//Update download
Download d = downloads.get(j);
d.state = Download.DownloadState.DOWNLOADING;
downloads.set(j, d);
//Create thread
DownloadThread thread = new DownloadThread(d);
thread.start();
threads.add(thread);
break;
}
}
}
//Check if last download
if (threads.isEmpty()) {
running = false;
}
}
//Send updates to UI
updateProgress();
updateState();
}
//Send state change to UI
private void updateState() {
Bundle b = new Bundle();
b.putBoolean("running", running);
//Get count of not downloaded tracks
int queueSize = 0;
for (int i = 0; i < downloads.size(); i++) {
if (downloads.get(i).state == Download.DownloadState.NONE)
queueSize++;
}
b.putInt("queueSize", queueSize);
sendMessage(SERVICE_ON_STATE_CHANGE, b);
}
//Wrapper to prevent threads racing
private void updateQueueWrapper() {
updateRequests.add(true);
if (!updating) {
updating = true;
while (!updateRequests.isEmpty()) {
updateQueue();
//Because threading
if (!updateRequests.isEmpty())
updateRequests.remove(0);
}
}
updating = false;
}
//Loads downloads from database
private void loadDownloads() {
Cursor cursor = db.query("Downloads", null, null, null, null, null, null);
//Parse downloads
while (cursor.moveToNext()) {
//Duplicate check
int downloadId = cursor.getInt(0);
Download.DownloadState state = Download.DownloadState.values()[cursor.getInt(1)];
boolean skip = false;
for (int i = 0; i < downloads.size(); i++) {
if (downloads.get(i).id == downloadId) {
if (downloads.get(i).state != state) {
//Different state, update state, only for finished/error
if (downloads.get(i).state.getValue() >= 3) {
downloads.set(i, Download.fromSQL(cursor));
}
}
skip = true;
break;
}
}
//Add to queue
if (!skip)
downloads.add(Download.fromSQL(cursor));
}
cursor.close();
updateState();
}
//Stop downloads
private void stop() {
running = false;
for (int i = 0; i < threads.size(); i++) {
threads.get(i).stopDownload();
}
updateState();
}
public class DownloadThread extends Thread {
Download download;
File parentDir;
File outFile;
JSONObject trackJson;
JSONObject albumJson;
JSONObject privateJson;
JSONObject lyricsData = null;
boolean stopDownload = false;
DownloadThread(Download download) {
this.download = download;
}
@Override
public void run() {
//Set state
download.state = Download.DownloadState.DOWNLOADING;
//Authorize deezer api
if (!deezer.authorized && !deezer.authorizing)
deezer.authorize();
while (deezer.authorizing)
try {
Thread.sleep(50);
} catch (Exception ignored) {
}
//Don't fetch meta if user uploaded mp3
if (!download.isUserUploaded()) {
try {
JSONObject privateRaw = deezer.callGWAPI("deezer.pageTrack", "{\"sng_id\": \"" + download.trackId + "\"}");
privateJson = privateRaw.getJSONObject("results").getJSONObject("DATA");
if (privateRaw.getJSONObject("results").has("LYRICS")) {
lyricsData = privateRaw.getJSONObject("results").getJSONObject("LYRICS");
}
trackJson = deezer.callPublicAPI("track", download.trackId);
albumJson = deezer.callPublicAPI("album", Integer.toString(trackJson.getJSONObject("album").getInt("id")));
} catch (Exception e) {
logger.error("Unable to fetch track and album metadata! " + e.toString(), download);
e.printStackTrace();
download.state = Download.DownloadState.ERROR;
exit();
return;
}
}
//Fallback
Deezer.QualityInfo qualityInfo = new Deezer.QualityInfo(this.download.quality, this.download.streamTrackId, this.download.trackToken, this.download.md5origin, this.download.mediaVersion, logger);
String sURL = null;
if (!download.isUserUploaded()) {
try {
sURL = qualityInfo.fallback(deezer);
if (sURL == null)
throw new Exception("No more to fallback!");
download.quality = qualityInfo.quality;
} catch (Exception e) {
logger.error("Fallback failed " + e.toString());
download.state = Download.DownloadState.DEEZER_ERROR;
exit();
return;
}
} else {
//User uploaded MP3
qualityInfo.quality = 3;
}
if (!download.priv) {
//Check file
try {
if (download.isUserUploaded()) {
outFile = new File(Deezer.generateUserUploadedMP3Filename(download.path, download.title));
} else {
outFile = new File(Deezer.generateFilename(download.path, trackJson, albumJson, qualityInfo.quality));
}
parentDir = new File(outFile.getParent());
} catch (Exception e) {
logger.error("Error generating track filename (" + download.path + "): " + e.toString(), download);
e.printStackTrace();
download.state = Download.DownloadState.ERROR;
exit();
return;
}
} else {
//Private track
outFile = new File(download.path);
parentDir = new File(outFile.getParent());
}
//File already exists
if (outFile.exists()) {
//Delete if overwriting enabled
if (settings.overwriteDownload) {
outFile.delete();
} else {
download.state = Download.DownloadState.DONE;
exit();
return;
}
}
//Temporary encrypted file
File tmpFile = new File(getCacheDir(), download.id + ".ENC");
//Get start bytes offset
long start = 0;
if (tmpFile.exists()) {
start = tmpFile.length();
}
//Download
try {
URL url = new URL(sURL);
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
//Set headers
connection.setConnectTimeout(30000);
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36");
connection.setRequestProperty("Accept-Language", "*");
connection.setRequestProperty("Accept", "*/*");
connection.setRequestProperty("Range", "bytes=" + start + "-");
connection.connect();
//Open streams
BufferedInputStream inputStream = new BufferedInputStream(connection.getInputStream());
OutputStream outputStream = new FileOutputStream(tmpFile.getPath(), true);
//Save total
download.filesize = start + connection.getContentLength();
//Download
byte[] buffer = new byte[4096];
long received = 0;
int read;
while ((read = inputStream.read(buffer, 0, 4096)) != -1) {
outputStream.write(buffer, 0, read);
received += read;
download.received = start + received;
//Stop/Cancel download
if (stopDownload) {
download.state = Download.DownloadState.NONE;
try {
inputStream.close();
outputStream.close();
connection.disconnect();
} catch (Exception ignored) {
}
exit();
return;
}
}
//On done
inputStream.close();
outputStream.close();
connection.disconnect();
//Update
download.state = Download.DownloadState.POST;
updateProgress();
} catch (Exception e) {
//Download error
logger.error("Download error: " + e.toString(), download);
e.printStackTrace();
download.state = Download.DownloadState.ERROR;
exit();
return;
}
//Post processing
//Decrypt
if (qualityInfo.encrypted) {
try {
File decFile = new File(tmpFile.getPath() + ".DEC");
DeezerDecryptor.decryptFile(download.streamTrackId, tmpFile.getPath(), decFile.getPath());
tmpFile.delete();
tmpFile = decFile;
} catch (Exception e) {
logger.error("Decryption error: " + e.toString(), download);
e.printStackTrace();
//Shouldn't ever fail
}
}
//If exists (duplicate download in DB), don't overwrite.
if (outFile.exists()) {
download.state = Download.DownloadState.DONE;
exit();
return;
}
//Create dirs and copy
if (!parentDir.exists() && !parentDir.mkdirs()) {
//Log & Exit
logger.error("Couldn't create output folder: " + parentDir.getPath() + "! ", download);
download.state = Download.DownloadState.ERROR;
exit();
return;
}
if (!tmpFile.renameTo(outFile)) {
try {
//Copy file
FileInputStream inputStream = new FileInputStream(tmpFile);
FileOutputStream outputStream = new FileOutputStream(outFile);
FileChannel inputChannel = inputStream.getChannel();
FileChannel outputChannel = outputStream.getChannel();
inputChannel.transferTo(0, inputChannel.size(), outputChannel);
inputStream.close();
outputStream.close();
//Delete temp
tmpFile.delete();
} catch (Exception e) {
//Clean
try {
outFile.delete();
tmpFile.delete();
} catch (Exception ignored) {
}
//Log & Exit
logger.error("Error moving file! " + outFile.getPath() + ", " + e.toString(), download);
e.printStackTrace();
download.state = Download.DownloadState.ERROR;
exit();
return;
}
}
//Cover & Tags, ignore on user uploaded
if (!download.priv && !download.isUserUploaded()) {
//Download cover for each track
File coverFile = new File(outFile.getPath().substring(0, outFile.getPath().lastIndexOf('.')) + ".jpg");
try {
URL url = new URL("http://e-cdn-images.deezer.com/images/cover/" + trackJson.getString("md5_image") + "/" + Integer.toString(settings.albumArtResolution) + "x" + Integer.toString(settings.albumArtResolution) + "-000000-80-0-0.jpg");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
//Set headers
connection.setRequestMethod("GET");
connection.connect();
//Open streams
InputStream inputStream = connection.getInputStream();
OutputStream outputStream = new FileOutputStream(coverFile.getPath());
//Download
byte[] buffer = new byte[4096];
int read = 0;
while ((read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
}
//On done
try {
inputStream.close();
outputStream.close();
connection.disconnect();
} catch (Exception ignored) {
}
} catch (Exception e) {
logger.error("Error downloading cover! " + e.toString(), download);
e.printStackTrace();
}
//Lyrics
if (lyricsData != null) {
if (settings.downloadLyrics) {
try {
String lrcData = Deezer.generateLRC(lyricsData, trackJson);
//Create file
String lrcFilename = outFile.getPath().substring(0, outFile.getPath().lastIndexOf(".") + 1) + "lrc";
FileOutputStream fileOutputStream = new FileOutputStream(lrcFilename);
fileOutputStream.write(lrcData.getBytes());
fileOutputStream.close();
} catch (Exception e) {
logger.warn("Error downloading lyrics! " + e.toString(), download);
}
}
}
//Tag
try {
deezer.tagTrack(outFile.getPath(), trackJson, albumJson, coverFile.getPath(), lyricsData, privateJson, settings);
} catch (Exception e) {
Log.e("ERR", "Tagging error!");
e.printStackTrace();
}
//Delete cover if disabled
if (!settings.trackCover)
coverFile.delete();
//Album cover
if (settings.albumCover)
downloadAlbumCover(albumJson);
}
download.state = Download.DownloadState.DONE;
//Queue update
updateQueueWrapper();
stopSelf();
}
//Each track has own album art, this is to download cover.jpg
void downloadAlbumCover(JSONObject albumJson) {
//Checks
if (albumJson == null || !albumJson.has("md5_image")) return;
File coverFile = new File(parentDir, "cover.jpg");
if (coverFile.exists()) return;
//Don't download if doesn't have album
if (!download.path.matches(".*/.*%album%.*/.*")) return;
try {
//Create to lock
coverFile.createNewFile();
URL url = new URL("http://e-cdn-images.deezer.com/images/cover/" + albumJson.getString("md5_image") + "/" + Integer.toString(settings.albumArtResolution) + "x" + Integer.toString(settings.albumArtResolution) + "-000000-80-0-0.jpg");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
//Set headers
connection.setRequestMethod("GET");
connection.connect();
//Open streams
InputStream inputStream = connection.getInputStream();
OutputStream outputStream = new FileOutputStream(coverFile.getPath());
//Download
byte[] buffer = new byte[4096];
int read = 0;
while ((read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
}
//On done
try {
inputStream.close();
outputStream.close();
connection.disconnect();
} catch (Exception ignored) {
}
//Create .nomedia to not spam gallery
if (settings.nomediaFiles)
new File(parentDir, ".nomedia").createNewFile();
} catch (Exception e) {
logger.warn("Error downloading album cover! " + e.toString(), download);
coverFile.delete();
}
}
void stopDownload() {
stopDownload = true;
}
//Clean stop/exit
private void exit() {
updateQueueWrapper();
stopSelf();
}
}
//500ms loop to update notifications and UI
private void createProgressUpdateHandler() {
progressUpdateHandler.postDelayed(() -> {
updateProgress();
createProgressUpdateHandler();
}, 500);
}
//Updates notification and UI
private void updateProgress() {
if (threads.size() > 0) {
//Convert threads to bundles, send to activity;
Bundle b = new Bundle();
ArrayList<Bundle> down = new ArrayList<>();
for (int i = 0; i < threads.size(); i++) {
//Create bundle
Download download = threads.get(i).download;
down.add(createProgressBundle(download));
//Notification
updateNotification(download);
}
b.putParcelableArrayList("downloads", down);
sendMessage(SERVICE_ON_PROGRESS, b);
}
}
//Create bundle with download progress & state
private Bundle createProgressBundle(Download download) {
Bundle bundle = new Bundle();
bundle.putInt("id", download.id);
bundle.putLong("received", download.received);
bundle.putLong("filesize", download.filesize);
bundle.putInt("quality", download.quality);
bundle.putInt("state", download.state.getValue());
return bundle;
}
private void updateNotification(Download download) {
//Cancel notification for done/none/error downloads
if (download.state == Download.DownloadState.NONE || download.state.getValue() >= 3) {
notificationManager.cancel(NOTIFICATION_ID_START + download.id);
return;
}
NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context, DownloadService.NOTIFICATION_CHANNEL_ID)
.setContentTitle(download.title)
.setSmallIcon(R.drawable.ic_logo)
.setPriority(NotificationCompat.PRIORITY_MIN);
//Show progress when downloading
if (download.state == Download.DownloadState.DOWNLOADING) {
if (download.filesize <= 0) download.filesize = 1;
notificationBuilder.setContentText(String.format("%s / %s", formatFilesize(download.received), formatFilesize(download.filesize)));
notificationBuilder.setProgress(100, (int) ((download.received / (float) download.filesize) * 100), false);
}
//Indeterminate on PostProcess
if (download.state == Download.DownloadState.POST) {
//TODO: Use strings
notificationBuilder.setContentText("Post processing...");
notificationBuilder.setProgress(1, 1, true);
}
if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
notificationManager.notify(NOTIFICATION_ID_START + download.id, notificationBuilder.build());
}
}
//https://stackoverflow.com/questions/3263892/format-file-size-as-mb-gb-etc
public static String formatFilesize(long size) {
if(size <= 0) return "0B";
final String[] units = new String[] { "B", "KB", "MB", "GB", "TB" };
int digitGroups = (int) (Math.log10(size)/Math.log10(1024));
return new DecimalFormat("#,##0.##").format(size/Math.pow(1024, digitGroups)) + " " + units[digitGroups];
}
//Handler for incoming messages
class IncomingHandler extends Handler {
IncomingHandler(Context context) {
context.getApplicationContext();
}
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
//Load downloads from DB
case SERVICE_LOAD_DOWNLOADS:
loadDownloads();
break;
//Start/Resume
case SERVICE_START_DOWNLOAD:
running = true;
if (downloads.isEmpty())
loadDownloads();
updateQueue();
updateState();
break;
//Load settings
case SERVICE_SETTINGS_UPDATE:
settings = DownloadSettings.fromBundle(msg.getData());
deezer.arl = settings.arl;
deezer.contentLanguage = settings.deezerLanguage;
break;
//Stop downloads
case SERVICE_STOP_DOWNLOADS:
stop();
break;
//Remove download
case SERVICE_REMOVE_DOWNLOAD:
int downloadId = msg.getData().getInt("id");
for (int i=0; i<downloads.size(); i++) {
Download d = downloads.get(i);
//Only remove if not downloading
if (d.id == downloadId) {
if (d.state == Download.DownloadState.DOWNLOADING || d.state == Download.DownloadState.POST) {
return;
}
downloads.remove(i);
break;
}
}
//Remove from DB
db.delete("Downloads", "id == ?", new String[]{Integer.toString(downloadId)});
updateState();
break;
//Retry failed downloads
case SERVICE_RETRY_DOWNLOADS:
db.beginTransaction();
for (int i=0; i<downloads.size(); i++) {
Download d = downloads.get(i);
if (d.state == Download.DownloadState.DEEZER_ERROR || d.state == Download.DownloadState.ERROR) {
//Retry only failed
d.state = Download.DownloadState.NONE;
downloads.set(i, d);
//Update DB
ContentValues values = new ContentValues();
values.put("state", 0);
db.update("Downloads", values, "id == ?", new String[]{Integer.toString(d.id)});
}
}
db.setTransactionSuccessful();
db.endTransaction();
updateState();
break;
//Remove downloads by state
case SERVICE_REMOVE_DOWNLOADS:
//Don't remove currently downloading, user has to stop first
Download.DownloadState state = Download.DownloadState.values()[msg.getData().getInt("state")];
if (state == Download.DownloadState.DOWNLOADING || state == Download.DownloadState.POST) return;
db.beginTransaction();
int i = (downloads.size() - 1);
while (i >= 0) {
Download d = downloads.get(i);
if (d.state == state) {
//Remove
db.delete("Downloads", "id == ?", new String[]{Integer.toString(d.id)});
downloads.remove(i);
}
i--;
}
//Delete from DB, done downloads after app restart aren't in downloads array
db.delete("Downloads", "state == ?", new String[]{Integer.toString(msg.getData().getInt("state"))});
//Save
db.setTransactionSuccessful();
db.endTransaction();
updateState();
break;
default:
super.handleMessage(msg);
}
}
}
//Send message to MainActivity
void sendMessage(int type, Bundle data) {
if (serviceMessenger != null) {
Message msg = Message.obtain(null, type);
msg.setData(data);
try {
activityMessenger.send(msg);
} catch (RemoteException e) {
e.printStackTrace();
}
}
}
static class DownloadSettings {
int downloadThreads;
boolean overwriteDownload;
boolean downloadLyrics;
boolean trackCover;
String arl;
boolean albumCover;
boolean nomediaFiles;
String artistSeparator;
int albumArtResolution;
String deezerLanguage = "en";
String deezerCountry = "US";
SelectedTags tags;
private DownloadSettings(int downloadThreads, boolean overwriteDownload, boolean downloadLyrics, boolean trackCover, String arl, boolean albumCover, boolean nomediaFiles, String artistSeparator, int albumArtResolution, String deezerLanguage, String deezerCountry, SelectedTags tags) {
this.downloadThreads = downloadThreads;
this.overwriteDownload = overwriteDownload;
this.downloadLyrics = downloadLyrics;
this.trackCover = trackCover;
this.arl = arl;
this.albumCover = albumCover;
this.nomediaFiles = nomediaFiles;
this.artistSeparator = artistSeparator;
this.albumArtResolution = albumArtResolution;
this.deezerLanguage = deezerLanguage;
this.deezerCountry = deezerCountry;
this.tags = tags;
}
//Parse settings from bundle sent from UI
static DownloadSettings fromBundle(Bundle b) {
JSONObject json;
try {
json = new JSONObject(b.getString("json"));
return new DownloadSettings(
json.getInt("downloadThreads"),
json.getBoolean("overwriteDownload"),
json.getBoolean("downloadLyrics"),
json.getBoolean("trackCover"),
json.getString("arl"),
json.getBoolean("albumCover"),
json.getBoolean("nomediaFiles"),
json.getString("artistSeparator"),
json.getInt("albumArtResolution"),
json.getString("deezerLanguage"),
json.getString("deezerCountry"),
new SelectedTags(json.getJSONArray("tags"))
);
} catch (Exception e) {
//Shouldn't happen
Log.e("ERR", "Error loading settings!");
return null;
}
}
}
static class SelectedTags {
boolean title = false;
boolean album = false;
boolean artist = false;
boolean track = false;
boolean disc = false;
boolean albumArtist = false;
boolean date = false;
boolean label = false;
boolean isrc = false;
boolean upc = false;
boolean trackTotal = false;
boolean bpm = false;
boolean lyrics = false;
boolean genre = false;
boolean contributors = false;
boolean albumArt = false;
SelectedTags(JSONArray json) {
//Array of tags, check if exist
try {
for (int i=0; i<json.length(); i++) {
switch (json.getString(i)) {
case "title":
title = true; break;
case "album":
album = true; break;
case "artist":
artist = true; break;
case "track":
track = true; break;
case "disc":
disc = true; break;
case "albumArtist":
albumArtist = true; break;
case "date":
date = true; break;
case "label":
label = true; break;
case "isrc":
isrc = true; break;
case "upc":
upc = true; break;
case "trackTotal":
trackTotal = true; break;
case "bpm":
bpm = true; break;
case "lyrics":
lyrics = true; break;
case "genre":
genre = true; break;
case "contributors":
contributors = true; break;
case "art":
albumArt = true; break;
}
}
} catch (Exception e) {
//Shouldn't happen
Log.e("ERR", "Error toggling tag: " + e.toString());
}
}
}
}

View File

@@ -0,0 +1,46 @@
package r.r.refreezer;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
public class DownloadsDatabase extends SQLiteOpenHelper {
public static final int DATABASE_VERSION = 1;
public DownloadsDatabase(Context context) {
super(context, context.getDatabasePath("downloads").toString(), null, DATABASE_VERSION);
}
public void onCreate(SQLiteDatabase db) {
/*
Downloads:
id - Download ID (to prevent private/public duplicates)
path - Folder name, actual path calculated later,
private - 1 = Offline, 0 = Download,
quality = Deezer quality int,
state = DownloadState value
trackId - Track ID,
md5origin - MD5Origin,
mediaVersion - MediaVersion
title - Download/Track name, for display,
image - URL to art (for display),
trackToken - Track Token for Hi-Fi download,
streamTrackId - Track ID for the stream (differs from track ID when using FALLBACK stream)
*/
db.execSQL("CREATE TABLE Downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT, " +
"private INTEGER, quality INTEGER, state INTEGER, trackId TEXT, md5origin TEXT, " +
"mediaVersion TEXT, title TEXT, image TEXT, trackToken TEXT, streamTrackId TEXT);");
}
//TODO: Currently does nothing
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
onCreate(db);
}
public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
onCreate(db);
}
}

View File

@@ -0,0 +1,411 @@
package r.r.refreezer;
import android.content.ComponentName;
import android.content.ContentValues;
import android.content.Intent;
import android.content.ServiceConnection;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.Messenger;
import android.os.Parcelable;
import android.os.RemoteException;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.ryanheise.audioservice.AudioServiceActivity;
import java.lang.ref.WeakReference;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.HashMap;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.MethodChannel;
public class MainActivity extends AudioServiceActivity {
private static final String CHANNEL = "r.r.refreezer/native";
private static final String EVENT_CHANNEL = "r.r.refreezer/downloads";
EventChannel.EventSink eventSink;
boolean serviceBound = false;
Messenger serviceMessenger;
Messenger activityMessenger;
SQLiteDatabase db;
StreamServer streamServer;
//Data if started from intent
String intentPreload;
@Override
public void onCreate(Bundle savedInstanceState) {
Intent intent = getIntent();
intentPreload = intent.getStringExtra("preload");
super.onCreate(savedInstanceState);
}
@Override
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
//Flutter method channel
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL).setMethodCallHandler(((call, result) -> {
//Add downloads to DB, then refresh service
if (call.method.equals("addDownloads")) {
ArrayList<HashMap<?,?>> downloads = call.arguments();
if (downloads != null) {
//TX
db.beginTransaction();
for (int i = 0; i < downloads.size(); i++) {
//Check if exists
Cursor cursor = db.rawQuery("SELECT id, state, quality FROM Downloads WHERE trackId == ? AND path == ?",
new String[]{(String) downloads.get(i).get("trackId"), (String) downloads.get(i).get("path")});
if (cursor.getCount() > 0) {
//If done or error, set state to NONE - they should be skipped because file exists
cursor.moveToNext();
if (cursor.getInt(1) >= 3) {
ContentValues values = new ContentValues();
values.put("state", 0);
values.put("quality", cursor.getInt(2));
db.update("Downloads", values, "id == ?", new String[]{Integer.toString(cursor.getInt(0))});
Log.d("INFO", "Already exists in DB, updating to none state!");
} else {
Log.d("INFO", "Already exits in DB!");
}
cursor.close();
continue;
}
cursor.close();
//Insert
ContentValues row = Download.flutterToSQL(downloads.get(i));
db.insert("Downloads", null, row);
}
db.setTransactionSuccessful();
db.endTransaction();
//Update service
sendMessage(DownloadService.SERVICE_LOAD_DOWNLOADS, null);
result.success(null);
return;
}
}
//Get all downloads from DB
if (call.method.equals("getDownloads")) {
Cursor cursor = db.query("Downloads", null, null, null, null, null, null);
ArrayList<HashMap<?,?>> downloads = new ArrayList<>();
//Parse downloads
while (cursor.moveToNext()) {
Download download = Download.fromSQL(cursor);
downloads.add(download.toHashMap());
}
cursor.close();
result.success(downloads);
return;
}
//Update settings from UI
if (call.method.equals("updateSettings")) {
Bundle bundle = new Bundle();
bundle.putString("json", call.argument("json").toString());
sendMessage(DownloadService.SERVICE_SETTINGS_UPDATE, bundle);
result.success(null);
return;
}
//Load downloads from DB in service
if (call.method.equals("loadDownloads")) {
sendMessage(DownloadService.SERVICE_LOAD_DOWNLOADS, null);
result.success(null);
return;
}
//Start/Resume downloading
if (call.method.equals("start")) {
//Connected
sendMessage(DownloadService.SERVICE_START_DOWNLOAD, null);
result.success(serviceBound);
return;
}
//Stop downloading
if (call.method.equals("stop")) {
sendMessage(DownloadService.SERVICE_STOP_DOWNLOADS, null);
result.success(null);
return;
}
//Remove download
if (call.method.equals("removeDownload")) {
Bundle bundle = new Bundle();
bundle.putInt("id", (int)call.argument("id"));
sendMessage(DownloadService.SERVICE_REMOVE_DOWNLOAD, bundle);
result.success(null);
return;
}
//Retry download
if (call.method.equals("retryDownloads")) {
sendMessage(DownloadService.SERVICE_RETRY_DOWNLOADS, null);
result.success(null);
return;
}
//Remove downloads by state
if (call.method.equals("removeDownloads")) {
Bundle bundle = new Bundle();
bundle.putInt("state", (int)call.argument("state"));
sendMessage(DownloadService.SERVICE_REMOVE_DOWNLOADS, bundle);
result.success(null);
return;
}
//If app was started with preload info (Android Auto)
if (call.method.equals("getPreloadInfo")) {
result.success(intentPreload);
intentPreload = null;
return;
}
//Get architecture
if (call.method.equals("arch")) {
result.success(System.getProperty("os.arch"));
return;
}
//Start streaming server
if (call.method.equals("startServer")) {
if (streamServer == null) {
//Get offline path
String offlinePath = getExternalFilesDir("offline").getAbsolutePath();
//Start server
streamServer = new StreamServer(call.argument("arl"), offlinePath);
streamServer.start();
}
result.success(null);
return;
}
//Get quality info from stream
if (call.method.equals("getStreamInfo")) {
if (streamServer == null) {
result.success(null);
return;
}
StreamServer.StreamInfo info = streamServer.streams.get(call.argument("id").toString());
if (info != null)
result.success(info.toJSON());
else
result.success(null);
return;
}
//Stop services
if (call.method.equals("kill")) {
Intent intent = new Intent(this, DownloadService.class);
stopService(intent);
if (streamServer != null) {
streamServer.stop();
streamServer = null;
}
//System.exit(0);
result.success(null);
return;
}
result.error("0", "Not implemented!", "Not implemented!");
}));
//Event channel (for download updates)
EventChannel eventChannel = new EventChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), EVENT_CHANNEL);
eventChannel.setStreamHandler((new EventChannel.StreamHandler() {
@Override
public void onListen(Object arguments, EventChannel.EventSink events) {
eventSink = events;
}
@Override
public void onCancel(Object arguments) {
eventSink = null;
}
}));
}
//Start/Bind/Reconnect to download service
private void connectService() {
if (serviceBound)
return;
//Create messenger
activityMessenger = new Messenger(new IncomingHandler(this));
//Start
Intent intent = new Intent(this, DownloadService.class);
intent.putExtra("activityMessenger", activityMessenger);
startService(intent);
bindService(intent, connection, BIND_AUTO_CREATE);
}
@Override
protected void onStart() {
super.onStart();
connectService();
//Get DB (and leave open!)
DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext());
db = dbHelper.getWritableDatabase();
//Trust all SSL Certs - Credits to Kilowatt36
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
return null;
}
public void checkClientTrusted(X509Certificate[] certs, String authType) {
}
public void checkServerTrusted(X509Certificate[] certs, String authType) {
}
}
};
SSLContext sc;
try {
sc = SSLContext.getInstance("SSL");
sc.init(null, trustAllCerts, new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
} catch (NoSuchAlgorithmException | KeyManagementException e) {
Log.e(this.getLocalClassName(), e.getMessage());
}
}
@Override
protected void onResume() {
super.onResume();
//Try reconnect
connectService();
}
@Override
protected void onStop() {
super.onStop();
db.close();
}
@Override
protected void onDestroy() {
super.onDestroy();
//Stop server
if (streamServer != null)
streamServer.stop();
//Unbind service on exit
if (serviceBound) {
unbindService(connection);
serviceBound = false;
}
}
//Connection to download service
private final ServiceConnection connection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
serviceMessenger = new Messenger(iBinder);
serviceBound = true;
Log.d("DD", "Service Bound!");
}
@Override
public void onServiceDisconnected(ComponentName componentName) {
serviceMessenger = null;
serviceBound = false;
Log.d("DD", "Service UnBound!");
}
};
//Handler for incoming messages from service
private static class IncomingHandler extends Handler {
private final WeakReference<MainActivity> weakReference;
IncomingHandler(MainActivity activity) {
super(Looper.getMainLooper());
this.weakReference = new WeakReference<>(activity);
}
@Override
public void handleMessage(@NonNull Message msg) {
MainActivity activity = weakReference.get();
if (activity != null) {
EventChannel.EventSink eventSink = activity.eventSink;
switch (msg.what) {
//Forward to flutter.
case DownloadService.SERVICE_ON_PROGRESS:
if (eventSink == null) break;
ArrayList<Bundle> downloads = getParcelableArrayList(msg.getData(), "downloads", Bundle.class);
if (downloads != null && downloads.size() > 0) {
//Generate HashMap ArrayList for sending to flutter
ArrayList<HashMap<String, Number>> data = new ArrayList<>();
for (Bundle bundle : downloads) {
HashMap<String, Number> out = new HashMap<>();
out.put("id", bundle.getInt("id"));
out.put("state", bundle.getInt("state"));
out.put("received", bundle.getLong("received"));
out.put("filesize", bundle.getLong("filesize"));
out.put("quality", bundle.getInt("quality"));
data.add(out);
}
//Wrapper
HashMap<String, Object> out = new HashMap<>();
out.put("action", "onProgress");
out.put("data", data);
eventSink.success(out);
}
break;
//State change, forward to flutter
case DownloadService.SERVICE_ON_STATE_CHANGE:
if (eventSink == null) break;
Bundle b = msg.getData();
HashMap<String, Object> out = new HashMap<>();
out.put("running", b.getBoolean("running"));
out.put("queueSize", b.getInt("queueSize"));
//Wrapper info
out.put("action", "onStateChange");
eventSink.success(out);
break;
default:
super.handleMessage(msg);
}
}
}
}
//Send message to service
void sendMessage(int type, Bundle data) {
if (serviceBound && serviceMessenger != null) {
Message msg = Message.obtain(null, type);
msg.setData(data);
try {
serviceMessenger.send(msg);
} catch (RemoteException e) {
e.printStackTrace();
}
}
}
@Nullable
public static <T extends Parcelable> ArrayList<T> getParcelableArrayList(@Nullable Bundle bundle, @Nullable String key, @NonNull Class<T> clazz) {
if (bundle != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return bundle.getParcelableArrayList(key, clazz);
} else {
return bundle.getParcelableArrayList(key);
}
}
return null;
}
}

View File

@@ -0,0 +1,316 @@
package r.r.refreezer;
import android.content.pm.PackageManager;
import android.util.Log;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.URL;
import java.util.HashMap;
import java.util.Objects;
import javax.net.ssl.HttpsURLConnection;
import fi.iki.elonen.NanoHTTPD;
public class StreamServer {
public HashMap<String, StreamInfo> streams = new HashMap<>();
private WebServer server;
private final String offlinePath;
//Shared log & API
private final DownloadLog logger;
private final Deezer deezer;
private boolean authorized = false;
StreamServer(String arl, String offlinePath) {
//Initialize shared variables
logger = new DownloadLog();
deezer = new Deezer();
deezer.init(logger, arl);
this.offlinePath = offlinePath;
}
//Create server
void start() {
try {
String host = "127.0.0.1";
int port = 36958;
server = new WebServer(host, port);
server.start();
} catch (Exception e) {
e.printStackTrace();
}
}
void stop() {
if (server != null)
server.stop();
}
//Information about streamed audio - for showing in UI
public static class StreamInfo {
String format;
long size;
//"Stream" or "Offline"
String source;
StreamInfo(String format, long size, String source) {
this.format = format;
this.size = size;
this.source = source;
}
//For passing into UI
public HashMap<String, Object> toJSON() {
HashMap<String, Object> out = new HashMap<>();
out.put("format", format);
out.put("size", size);
out.put("source", source);
return out;
}
}
private class WebServer extends NanoHTTPD {
public WebServer(String hostname, int port) {
super(hostname, port);
}
@Override
public Response serve(IHTTPSession session) {
//Must be only GET
if (session.getMethod() != Method.GET)
return newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, MIME_PLAINTEXT, "Only GET request supported!");
try {
//Parse range header
String rangeHeader = session.getHeaders().get("range");
int startBytes = 0;
boolean isRanged = false;
int end = -1;
if (rangeHeader != null && rangeHeader.startsWith("bytes")) {
isRanged = true;
String[] ranges = rangeHeader.split("=")[1].split("-");
startBytes = Integer.parseInt(ranges[0]);
if (ranges.length > 1 && !ranges[1].equals(" ")) {
end = Integer.parseInt(ranges[1]);
}
}
//Check query parameters
if (session.getParameters().keySet().size() < 6) {
//Play offline
if (session.getParameters().get("id") != null) {
return offlineStream(session, startBytes, end, isRanged);
}
//Missing QP
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Invalid / Missing QP");
}
//Stream
return deezerStream(session, startBytes, end, isRanged);
} catch (Exception e) {
e.printStackTrace();
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "An error occurred while serving the request.");
}
}
private Response offlineStream(IHTTPSession session, int startBytes, int end, boolean isRanged) {
//Get path
String trackId = Objects.requireNonNull(session.getParameters().get("id")).get(0);
File file = new File(offlinePath, trackId);
long size = file.length();
//Read header
boolean isFlac = false;
try {
InputStream inputStream = new FileInputStream(file);
byte[] buffer = new byte[4];
inputStream.read(buffer, 0, 4);
inputStream.close();
if (new String(buffer).equals("fLaC"))
isFlac = true;
} catch (Exception e) {
Log.d("StreamServer", "Invalid offline file: " + e.getMessage());
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Invalid offline file!");
}
//Open file
RandomAccessFile randomAccessFile;
try {
randomAccessFile = new RandomAccessFile(file, "r");
randomAccessFile.seek(startBytes);
} catch (Exception e) {
Log.d("StreamServer", "Failed getting offline data: " + e.getMessage());
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Failed getting data!");
}
//Generate response
Response response = newFixedLengthResponse(
isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK,
isFlac ? "audio/flac" : "audio/mpeg",
new InputStream() {
@Override
public int read() throws IOException {
return 0;
}
//Pass thru
@Override
public int read(byte[] b, int off, int len) throws IOException {
return randomAccessFile.read(b, off, len);
}
},
((end == -1) ? size : end) - startBytes
);
//Ranged header
if (isRanged) {
String range = "bytes " + Integer.toString(startBytes) + "-" + Long.toString((end == -1) ? size - 1 : end);
range += "/" + Long.toString(size);
response.addHeader("Content-Range", range);
}
response.addHeader("Accept-Ranges", "bytes");
//Save stream info
streams.put(trackId, new StreamInfo((isFlac ? "FLAC" : "MP3"), size, "Offline"));
return response;
}
private Response deezerStream(IHTTPSession session, int startBytes, int end, boolean isRanged) {
// Authorize
if (!authorized) {
deezer.authorize();
authorized = true;
}
//Get QP into Quality Info
Deezer.QualityInfo qualityInfo = new Deezer.QualityInfo(
Integer.parseInt(Objects.requireNonNull(session.getParameters().get("q")).get(0)),
Objects.requireNonNull(session.getParameters().get("streamTrackId")).get(0),
Objects.requireNonNull(session.getParameters().get("trackToken")).get(0),
Objects.requireNonNull(session.getParameters().get("md5origin")).get(0),
Objects.requireNonNull(session.getParameters().get("mv")).get(0),
logger
);
//Fallback
String sURL;
try {
sURL = qualityInfo.fallback(deezer);
if (sURL == null)
throw new Exception("No more to fallback!");
} catch (Exception e) {
return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Fallback failed!");
}
//Calculate Deezer offsets
int _deezerStart = startBytes;
if (qualityInfo.encrypted)
_deezerStart -= startBytes % 2048;
final int deezerStart = _deezerStart;
int dropBytes = startBytes % 2048;
//Start download
try {
URL url = new URL(sURL);
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
//Set headers
connection.setConnectTimeout(10000);
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36");
connection.setRequestProperty("Accept-Language", "*");
connection.setRequestProperty("Accept", "*/*");
connection.setRequestProperty("Range", "bytes=" + Integer.toString(deezerStart) + "-" + ((end == -1) ? "" : Integer.toString(end)));
connection.connect();
Response outResponse;
// Encrypted response
if (qualityInfo.encrypted) {
//Get decryption key
final byte[] key = DeezerDecryptor.getKey(qualityInfo.trackId);
outResponse = newFixedLengthResponse(
isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK,
(qualityInfo.quality == 9) ? "audio/flac" : "audio/mpeg",
new BufferedInputStream(new FilterInputStream(connection.getInputStream()) {
int counter = deezerStart / 2048;
int drop = dropBytes;
//Decryption stream
@Override
public int read(byte[] b, int off, int len) throws IOException {
//Read 2048b or EOF
byte[] buffer = new byte[2048];
int read = 0;
int totalRead = 0;
while (read != -1 && totalRead != 2048) {
read = in.read(buffer, totalRead, 2048 - totalRead);
if (read != -1)
totalRead += read;
}
if (totalRead == 0)
return -1;
//Not full chunk return unencrypted
if (totalRead != 2048) {
System.arraycopy(buffer, 0, b, off, totalRead);
return totalRead;
}
//Decrypt every 3rd full chunk
if ((counter % 3) == 0) {
buffer = DeezerDecryptor.decryptChunk(key, buffer);
}
//Drop bytes from rounding to 2048
if (drop > 0) {
int output = 2048 - drop;
System.arraycopy(buffer, drop, b, off, output);
drop = 0;
counter++;
return output;
}
//Copy
System.arraycopy(buffer, 0, b, off, 2048);
counter++;
return 2048;
}
}, 2048),
connection.getContentLength() - dropBytes
);
} else {
// Decrypted
outResponse = newFixedLengthResponse(
isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK,
(qualityInfo.quality == 9) ? "audio/flac" : "audio/mpeg",
connection.getInputStream(),
connection.getContentLength()
);
}
//Ranged header
if (isRanged) {
String range = "bytes " + Integer.toString(startBytes) + "-" + Integer.toString((end == -1) ? (connection.getContentLength() + deezerStart) - 1 : end);
range += "/" + Integer.toString(connection.getContentLength() + deezerStart);
outResponse.addHeader("Content-Range", range);
}
outResponse.addHeader("Accept-Ranges", "bytes");
//Save stream info, use original track id since this is used to communicate with Flutter UI
streams.put(Objects.requireNonNull(session.getParameters().get("id")).get(0), new StreamInfo(
((qualityInfo.quality == 9) ? "FLAC" : "MP3"),
deezerStart + connection.getContentLength(),
"Stream"
));
return outResponse;
} catch (Exception e) {
e.printStackTrace();
}
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Failed getting data!");
}
}
}

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="23.686956"
android:viewportHeight="23.686956"
android:tint="#FFFFFF">
<group android:translateX="-0.15652174"
android:translateY="-0.15652174">
<path
android:fillColor="#FF000000"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

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="@color/ic_favorites_background"/>
<foreground android:drawable="@mipmap/ic_favorites_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="@color/ic_favorites_background"/>
<foreground android:drawable="@mipmap/ic_favorites_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_flow_background"/>
<foreground android:drawable="@mipmap/ic_flow_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_flow_background"/>
<foreground android:drawable="@mipmap/ic_flow_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="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/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="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_favorites_background">#3DDC84</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1C1C14</color>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@android:color/white</item>
</style>
</resources>

View File

@@ -0,0 +1,3 @@
<automotiveApp>
<uses name="media"/>
</automotiveApp>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

56
android/build.gradle Normal file
View File

@@ -0,0 +1,56 @@
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
// This will make sure all the sub projects have an android namespace,
// if not it will read it from AndroidManifest.xml
// Also sets the compileSdk to 34 and the target javaVersion to 17
// (needs to be changed manually to the latest in the future)
afterEvaluate {
// check if android block is available
if (it.hasProperty('android')) {
if (it.android.namespace == null) {
def manifest = groovy.util.XmlSlurper.parse(file(it.android.sourceSets.main.manifest.srcFile))
def packageName = manifest.@package.text()
println("Setting ${packageName} as android namespace")
android.namespace = packageName
}
def javaVersion = JavaVersion.VERSION_1_8
android {
def androidApiVersion = 34
compileSdk androidApiVersion
defaultConfig {
targetSdk androidApiVersion
}
compileOptions {
sourceCompatibility javaVersion
targetCompatibility javaVersion
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
jvmTarget = javaVersion.toString()
}
}
println("Setting java version to ${javaVersion.toString()} which is $javaVersion")
println("Setting compileSdk and targetSdk to $androidApiVersion")
}
}
}
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=false
android.nonTransitiveRClass=false
android.nonFinalResIds=false

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip

26
android/settings.gradle Normal file
View File

@@ -0,0 +1,26 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}
settings.ext.flutterSdkPath = flutterSdkPath()
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.5.1' apply false
id "org.jetbrains.kotlin.android" version "1.9.24" apply false
}
include ":app"

BIN
assets/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
assets/browse_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/cover.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/cover_thumb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/favorites_thumb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/fonts/MabryPro.otf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Some files were not shown because too many files have changed in this diff Show More