initial commit
71
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,72 @@
|
||||

|
||||
|
||||
[](../../releases/latest)
|
||||
[](../../releases/latest)
|
||||
[](../../releases)
|
||||
[](../../releases)
|
||||
[](https://dart.dev/)
|
||||
[](https://flutter.dev/)
|
||||
[](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
@@ -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
@@ -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
@@ -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 '../..'
|
||||
}
|
||||
BIN
android/app/libs/extension-flac.aar
Normal file
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
150
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
BIN
android/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
android/app/src/main/ic_launcher_new-playstore.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
639
android/app/src/main/java/r/r/refreezer/Deezer.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
94
android/app/src/main/java/r/r/refreezer/DeezerDecryptor.java
Normal 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];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
113
android/app/src/main/java/r/r/refreezer/Download.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
101
android/app/src/main/java/r/r/refreezer/DownloadLog.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
950
android/app/src/main/java/r/r/refreezer/DownloadService.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
411
android/app/src/main/java/r/r/refreezer/MainActivity.java
Normal 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;
|
||||
}
|
||||
}
|
||||
316
android/app/src/main/java/r/r/refreezer/StreamServer.java
Normal 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!");
|
||||
}
|
||||
}
|
||||
}
|
||||
13
android/app/src/main/res/drawable-anydpi-v24/ic_favorite.xml
Normal 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>
|
||||
BIN
android/app/src/main/res/drawable-hdpi/ic_action_stop.png
Normal file
|
After Width: | Height: | Size: 470 B |
BIN
android/app/src/main/res/drawable-hdpi/ic_favorite.png
Normal file
|
After Width: | Height: | Size: 418 B |
BIN
android/app/src/main/res/drawable-hdpi/ic_logo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
android/app/src/main/res/drawable-mdpi/ic_action_stop.png
Normal file
|
After Width: | Height: | Size: 439 B |
BIN
android/app/src/main/res/drawable-mdpi/ic_favorite.png
Normal file
|
After Width: | Height: | Size: 293 B |
BIN
android/app/src/main/res/drawable-mdpi/ic_logo.png
Normal file
|
After Width: | Height: | Size: 997 B |
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal 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>
|
||||
BIN
android/app/src/main/res/drawable-xhdpi/ic_action_stop.png
Normal file
|
After Width: | Height: | Size: 778 B |
BIN
android/app/src/main/res/drawable-xhdpi/ic_favorite.png
Normal file
|
After Width: | Height: | Size: 546 B |
BIN
android/app/src/main/res/drawable-xhdpi/ic_logo.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/ic_action_stop.png
Normal file
|
After Width: | Height: | Size: 810 B |
BIN
android/app/src/main/res/drawable-xxhdpi/ic_favorite.png
Normal file
|
After Width: | Height: | Size: 789 B |
BIN
android/app/src/main/res/drawable-xxhdpi/ic_logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/ic_action_stop.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/ic_logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
74
android/app/src/main/res/drawable/ic_flow_background.xml
Normal 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>
|
||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
5
android/app/src/main/res/mipmap-anydpi-v26/ic_flow.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_favorites_foreground.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_favorites_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_flow_foreground.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_flow_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_favorites_foreground.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_favorites_round.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_flow_foreground.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_flow_round.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_favorites_round.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_flow_foreground.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_flow_round.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_favorites_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_flow_foreground.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_flow_round.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 23 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_favorites_round.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_flow_foreground.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_flow_round.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_favorites_background">#3DDC84</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1C1C14</color>
|
||||
</resources>
|
||||
18
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
3
android/app/src/main/res/xml/automotive_app_desc.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<automotiveApp>
|
||||
<uses name="media"/>
|
||||
</automotiveApp>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal 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
@@ -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
|
||||
}
|
||||
5
android/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=false
|
||||
android.nonTransitiveRClass=false
|
||||
android.nonFinalResIds=false
|
||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
|
After Width: | Height: | Size: 49 KiB |
BIN
assets/browse_icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/cover.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/cover_thumb.jpg
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/favorites_thumb.jpg
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/fonts/MabryPro.otf
Normal file
BIN
assets/fonts/MabryProBlack.otf
Normal file
BIN
assets/fonts/MabryProBold.otf
Normal file
BIN
assets/fonts/MabryProItalic.otf
Normal file
BIN
assets/fonts/ReFreezerIcons.ttf
Normal file
BIN
assets/icon.png
Normal file
|
After Width: | Height: | Size: 47 KiB |