0.5.0 - Rewritten downloads, many bugfixes

This commit is contained in:
exttex 2020-10-09 20:52:45 +02:00
parent f7cbb09bc1
commit f2f6b202d1
38 changed files with 5176 additions and 1365 deletions

View file

@ -1,72 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="f.f.freezer">
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
<!--
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. -->
FlutterApplication and put your custom class here.
-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:name="io.flutter.app.FlutterApplication"
android:icon="@mipmap/ic_launcher"
android:label="Freezer"
android:icon="@mipmap/ic_launcher">
android:usesCleartextTraffic="true">
<service
android:name=".DownloadService"
android:enabled="true"
android:exported="true">
</service>
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
<!--
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. -->
to determine the Window background behind the Flutter UI.
-->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<!-- Displays an Android View that continues showing the launch screen
<!--
Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
Flutter's first frame.
-->
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"
/>
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<!--
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">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver" >
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver">
<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"/>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
</application>
</manifest>
</manifest>

View file

@ -0,0 +1,352 @@
package f.f.freezer;
import android.util.Log;
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.datatype.Artwork;
import org.jaudiotagger.tag.flac.FlacTag;
import org.jaudiotagger.tag.id3.ID3v23Tag;
import org.jaudiotagger.tag.id3.valuepair.ImageFormats;
import org.jaudiotagger.tag.reference.PictureTypes;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Map;
import java.util.Scanner;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import javax.net.ssl.HttpsURLConnection;
public class Deezer {
//Get guest SID cookie from deezer.com
public static String getSidCookie() throws Exception {
URL url = new URL("https://deezer.com/");
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setConnectTimeout(20000);
connection.setRequestMethod("HEAD");
String sid = "";
for (String cookie : connection.getHeaderFields().get("Set-Cookie")) {
if (cookie.startsWith("sid=")) {
sid = cookie.split(";")[0].split("=")[1];
}
}
return sid;
}
//Same as gw_light API, but doesn't need authentication
public static JSONObject callMobileAPI(String method, String params) throws Exception{
String sid = Deezer.getSidCookie();
URL url = new URL("https://api.deezer.com/1.0/gateway.php?api_key=4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE&sid=" + sid + "&input=3&output=3&method=" + method);
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setConnectTimeout(20000);
connection.setDoOutput(true);
connection.setRequestMethod("POST");
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("Content-Type", "application/json");
connection.setRequestProperty("Accept", "*/*");
connection.setRequestProperty("Content-Length", Integer.toString(params.getBytes(StandardCharsets.UTF_8).length));
//Write body
DataOutputStream wr = new DataOutputStream(connection.getOutputStream());
wr.writeBytes(params);
wr.close();
//Get response
String data = "";
Scanner scanner = new Scanner(connection.getInputStream());
while (scanner.hasNext()) {
data += scanner.nextLine();
}
//Parse JSON
JSONObject out = new JSONObject(data);
return out;
}
//api.deezer.com/$method/$param
public static 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.setConnectTimeout(20000);
connection.connect();
//Get string data
String data = "";
Scanner scanner = new Scanner(url.openStream());
while (scanner.hasNext()) {
data += scanner.nextLine();
}
//Parse JSON
JSONObject out = new JSONObject(data);
return out;
}
public static int qualityFallback(String trackId, String md5origin, String mediaVersion, int originalQuality) throws Exception {
//Create HEAD requests to check if exists
URL url = new URL(getTrackUrl(trackId, md5origin, mediaVersion, originalQuality));
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setRequestMethod("HEAD");
int rc = connection.getResponseCode();
//Track not available
if (rc > 400) {
//Returns -1 if no quality available
if (originalQuality == 1) return -1;
if (originalQuality == 3) return qualityFallback(trackId, md5origin, mediaVersion, 1);
if (originalQuality == 9) return qualityFallback(trackId, md5origin, mediaVersion, 3);
}
return originalQuality;
}
//Generate track download URL
public static String getTrackUrl(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 = 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(bytesToHex(cipher.doFinal(b)).toLowerCase());
}
//Join to URL
return "https://e-cdns-proxy-" + md5origin.charAt(0) + ".dzcdn.net/mobile/1/" + step3.toString();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
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);
}
//Calculate decryption key from track id
private static byte[] getKey(String id) {
String secret = "g4el58wc0zvf9na1";
String key = "";
try {
MessageDigest md5 = MessageDigest.getInstance("MD5");
//md5.update(id.getBytes());
byte[] md5id = md5.digest(id.getBytes());
String idmd5 = bytesToHex(md5id).toLowerCase();
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);
}
} catch (Exception e) {}
return key.getBytes();
}
//Decrypt 2048b chunk
private static byte[] decryptChunk(byte[] key, byte[] data) throws Exception{
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);
}
public static void decryptTrack(String path, String tid) throws Exception {
//Load file
File inputFile = new File(path);
BufferedInputStream buffin = new BufferedInputStream(new FileInputStream(inputFile));
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] key = getKey(tid);
for (int i=0; i<(inputFile.length()/2048)+1; i++) {
byte[] tmp = new byte[2048];
int read = buffin.read(tmp, 0, tmp.length);
if ((i%3) == 0 && read == 2048) {
tmp = decryptChunk(key, tmp);
}
buf.write(tmp, 0, read);
}
//Save
FileOutputStream outputStream = new FileOutputStream(new File(path));
outputStream.write(buf.toByteArray());
outputStream.close();
}
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")));
//Artists
String artists = "";
String feats = "";
for (int i=0; i<publicTrack.getJSONArray("contributors").length(); i++) {
artists += ", " + publicTrack.getJSONArray("contributors").getJSONObject(i).getString("name");
if (i > 0)
feats += ", " + publicTrack.getJSONArray("contributors").getJSONObject(i).getString("name");
}
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));
if (newQuality == 9) return original + ".flac";
return original + ".mp3";
}
//Tag track with data from API
public static void tagTrack(String path, JSONObject publicTrack, JSONObject publicAlbum, String cover) 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();
tag.setField(FieldKey.TITLE, publicTrack.getString("title"));
tag.setField(FieldKey.ALBUM, publicTrack.getJSONObject("album").getString("title"));
//Artist
for (int i=0; i<publicTrack.getJSONArray("contributors").length(); i++) {
tag.addField(FieldKey.ARTIST, publicTrack.getJSONArray("contributors").getJSONObject(i).getString("name"));
}
tag.setField(FieldKey.TRACK, Integer.toString(publicTrack.getInt("track_position")));
tag.setField(FieldKey.DISC_NO, Integer.toString(publicTrack.getInt("disk_number")));
tag.setField(FieldKey.ALBUM_ARTIST, publicAlbum.getJSONObject("artist").getString("name"));
tag.setField(FieldKey.YEAR, publicTrack.getString("release_date").substring(0, 4));
tag.setField(FieldKey.BPM, Integer.toString((int)publicTrack.getDouble("bpm")));
tag.setField(FieldKey.RECORD_LABEL, publicAlbum.getString("label"));
//Genres
for (int i=0; i<publicAlbum.getJSONObject("genres").getJSONArray("data").length(); i++) {
String genre = publicAlbum.getJSONObject("genres").getJSONArray("data").getJSONObject(0).getString("name");
tag.addField(FieldKey.GENRE, genre);
}
File coverFile = new File(cover);
boolean addCover = (coverFile.exists() && coverFile.length() > 0);
if (isFlac) {
//FLAC Specific tags
((FlacTag)tag).setField("DATE", publicTrack.getString("release_date"));
((FlacTag)tag).setField("TRACKTOTAL", Integer.toString(publicAlbum.getInt("nb_tracks")));
//Cover
if (addCover) {
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",
1400,
1400,
24,
0
));
}
} else {
if (addCover) {
Artwork art = Artwork.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.getJSONObject("results").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;
}
}

View file

@ -0,0 +1,92 @@
package f.f.freezer;
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 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) {
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;
}
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;
}
}
//Get download from SQLite cursor, HAS TO ALIGN
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)
);
}
//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"));
return values;
}
//Used to send data to Flutter
HashMap toHashMap() {
HashMap map = new HashMap();
map.put("id", id);
map.put("path", path);
map.put("private", priv);
map.put("quality", quality);
map.put("trackId", trackId);
map.put("state", state.getValue());
map.put("title", title);
map.put("image", image);
//Only useful data, some are passed in updates
return map;
}
}

View file

@ -0,0 +1,709 @@
package f.f.freezer;
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.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.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
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 = "freezerdownloads";
static final int NOTIFICATION_ID_START = 6969;
boolean running = false;
DownloadSettings settings;
Context context;
SQLiteDatabase db;
Messenger serviceMessenger;
Messenger activityMessenger;
NotificationManagerCompat notificationManager;
ArrayList<Download> downloads = new ArrayList<>();
ArrayList<DownloadThread> threads = new ArrayList<>();
ArrayList<Boolean> updateRequests = new ArrayList<>();
ArrayList<String> pendingCovers = new ArrayList<>();
boolean updating = false;
Handler progressUpdateHandler = new Handler();
public DownloadService() {
}
@Override
public void onCreate() {
super.onCreate();
//Setup notifications
context = this;
notificationManager = NotificationManagerCompat.from(context);
createNotificationChannel();
createProgressUpdateHandler();
//Get DB
DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext());
db = dbHelper.getWritableDatabase();
}
@Override
public void onDestroy() {
//Cancel notifications
notificationManager.cancelAll();
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);
}
//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.size() == 0) {
running = false;
updateState();
return;
}
}
}
//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.size() > 0) {
updateQueue();
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;
boolean stopDownload = false;
DownloadThread(Download download) {
this.download = download;
}
@Override
public void run() {
//Set state
download.state = Download.DownloadState.DOWNLOADING;
//Quality fallback
int newQuality;
try {
newQuality = Deezer.qualityFallback(download.trackId, download.md5origin, download.mediaVersion, download.quality);
} catch (Exception e) {
Log.e("QF", "Quality fallback failed: " + e.toString());
download.state = Download.DownloadState.ERROR;
exit();
return;
}
//No quality available
if (newQuality == -1) {
download.state = Download.DownloadState.DEEZER_ERROR;
exit();
return;
}
download.quality = newQuality;
if (!download.priv) {
//Fetch metadata
try {
trackJson = Deezer.callPublicAPI("track", download.trackId);
albumJson = Deezer.callPublicAPI("album", Integer.toString(trackJson.getJSONObject("album").getInt("id")));
} catch (Exception e) {
Log.e("ERR", "Unable to fetch track metadata.");
e.printStackTrace();
download.state = Download.DownloadState.ERROR;
exit();
return;
}
//Check file
try {
outFile = new File(Deezer.generateFilename(download.path, trackJson, albumJson, newQuality));
parentDir = new File(outFile.getParent());
parentDir.mkdirs();
} catch (Exception e) {
Log.e("ERR", "Error creating directories! TrackID: " + download.trackId);
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
String sURL = Deezer.getTrackUrl(download.trackId, download.md5origin, download.mediaVersion, newQuality);
try {
URL url = new URL(sURL);
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
//Set headers
connection.setConnectTimeout(30000);
connection.setRequestMethod("GET");
connection.setRequestProperty("Range", "bytes=" + start + "-");
connection.connect();
//Open streams
BufferedInputStream inputStream = new BufferedInputStream(connection.getInputStream());
OutputStream outputStream = new FileOutputStream(tmpFile.getPath());
//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
Log.e("DOWNLOAD", "Download error!");
e.printStackTrace();
download.state = Download.DownloadState.ERROR;
exit();
return;
}
//Post processing
//Decrypt
try {
Deezer.decryptTrack(tmpFile.getPath(), download.trackId);
} catch (Exception e) {
Log.e("DEC", "Decryption failed!");
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;
}
//Copy to destination directory
if (!tmpFile.renameTo(outFile)) {
download.state = Download.DownloadState.ERROR;
exit();
return;
}
if (!download.priv) {
//Download cover
File coverFile = new File(parentDir, "cover.jpg");
//Wait for another thread to download it
while (pendingCovers.contains(coverFile.getPath())) {
try { Thread.sleep(100); } catch (Exception ignored) {}
}
if (!coverFile.exists()) {
try {
//Create fake file so other threads don't start downloading covers
coverFile.createNewFile();
pendingCovers.add(coverFile.getPath());
URL url = new URL("http://e-cdn-images.deezer.com/images/cover/" + trackJson.getString("md5_image") + "/1400x1400-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
inputStream.close();
outputStream.close();
connection.disconnect();
} catch (Exception e) {
Log.e("ERR", "Error downloading cover!");
e.printStackTrace();
coverFile.delete();
}
//Remove lock
pendingCovers.remove(coverFile.getPath());
}
//Tag
try {
Deezer.tagTrack(outFile.getPath(), trackJson, albumJson, coverFile.getPath());
} catch (Exception e) {
Log.e("ERR", "Tagging error!");
e.printStackTrace();
}
//Lyrics
if (settings.downloadLyrics) {
try {
JSONObject lyricsData = Deezer.callMobileAPI("song.getLyrics", "{\"sng_id\": " + download.trackId + "}");
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) {
Log.w("WAR", "Missing lyrics! " + e.toString());
}
}
}
download.state = Download.DownloadState.DONE;
//Queue update
updateQueueWrapper();
stopSelf();
}
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);
}
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;
updateQueue();
updateState();
break;
//Load settings
case SERVICE_SETTINGS_UPDATE:
settings = DownloadSettings.fromBundle(msg.getData());
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;
private DownloadSettings(int downloadThreads, boolean overwriteDownload, boolean downloadLyrics) {
this.downloadThreads = downloadThreads;
this.overwriteDownload = overwriteDownload;
this.downloadLyrics = downloadLyrics;
}
//Parse settings from bundle sent from UI
static DownloadSettings fromBundle(Bundle b) {
return new DownloadSettings(b.getInt("downloadThreads"), b.getBoolean("overwriteDownload"), b.getBoolean("downloadLyrics"));
}
}
}

View file

@ -0,0 +1,43 @@
package f.f.freezer;
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)
*/
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);");
}
//TODO: Currently does nothing
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
onCreate(db);
}
public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
onCreate(db);
}
}

View file

@ -1,43 +1,295 @@
package f.f.freezer;
import android.content.ComponentName;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
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.annotation.NonNull;
import androidx.annotation.Nullable;
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.datatype.Artwork;
import org.jaudiotagger.tag.flac.FlacTag;
import org.jaudiotagger.tag.id3.ID3v23Tag;
import org.jaudiotagger.tag.id3.valuepair.ImageFormats;
import org.jaudiotagger.tag.reference.PictureTypes;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.RandomAccessFile;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.function.Function;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import io.flutter.embedding.android.FlutterActivity;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugins.GeneratedPluginRegistrant;
import static f.f.freezer.Deezer.bytesToHex;
public class MainActivity extends FlutterActivity {
private static final String CHANNEL = "f.f.freezer/native";
private static final String EVENT_CHANNEL = "f.f.freezer/downloads";
EventChannel.EventSink eventSink;
boolean serviceBound = false;
Messenger serviceMessenger;
Messenger activityMessenger;
SQLiteDatabase db;
private static final int SD_PERMISSION_REQUEST_CODE = 42;
@Override
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
GeneratedPluginRegistrant.registerWith(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")) {
//TX
db.beginTransaction();
ArrayList<HashMap> downloads = call.arguments();
for (int i=0; i<downloads.size(); i++) {
//Check if exists
Cursor cursor = db.rawQuery("SELECT id, state 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);
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 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.putInt("downloadThreads", (int)call.argument("downloadThreads"));
bundle.putBoolean("overwriteDownload", (boolean)call.argument("overwriteDownload"));
bundle.putBoolean("downloadLyrics", (boolean)call.argument("downloadLyrics"));
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")) {
sendMessage(DownloadService.SERVICE_START_DOWNLOAD, null);
result.success(null);
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;
}
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;
}
}));
}
@Override
protected void onStart() {
super.onStart();
//Bind downloader service
activityMessenger = new Messenger(new IncomingHandler(this));
Intent intent = new Intent(this, DownloadService.class);
intent.putExtra("activityMessenger", activityMessenger);
bindService(intent, connection, Context.BIND_AUTO_CREATE);
//Get DB
DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext());
db = dbHelper.getWritableDatabase();
}
@Override
protected void onStop() {
super.onStop();
//Unbind service on exit
if (serviceBound) {
unbindService(connection);
serviceBound = false;
}
db.close();
}
//Connection to download service
private ServiceConnection connection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
serviceMessenger = new Messenger(iBinder);
serviceBound = true;
}
@Override
public void onServiceDisconnected(ComponentName componentName) {
serviceMessenger = null;
serviceBound = false;
}
};
//Handler for incoming messages from service
class IncomingHandler extends Handler {
IncomingHandler(Context context) {
Context applicationContext = context.getApplicationContext();
}
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
//Forward to flutter.
case DownloadService.SERVICE_ON_PROGRESS:
if (eventSink == null) break;
if (msg.getData().getParcelableArrayList("downloads").size() > 0) {
//Generate HashMap ArrayList for sending to flutter
ArrayList<HashMap> data = new ArrayList<>();
for (int i=0; i<msg.getData().getParcelableArrayList("downloads").size(); i++) {
Bundle bundle = (Bundle) msg.getData().getParcelableArrayList("downloads").get(i);
HashMap 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 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 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();
}
}
}
/*
@Override
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
super.configureFlutterEngine(flutterEngine);
@ -132,69 +384,5 @@ public class MainActivity extends FlutterActivity {
}));
}
public static void decryptTrack(String path, String tid) {
try {
//Load file
File inputFile = new File(path + ".ENC");
BufferedInputStream buffin = new BufferedInputStream(new FileInputStream(inputFile));
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] key = getKey(tid);
for (int i=0; i<inputFile.length()/2048; i++) {
byte[] tmp = new byte[2048];
buffin.read(tmp, 0, tmp.length);
if ((i%3) == 0) {
tmp = decryptChunk(key, tmp);
}
buf.write(tmp);
}
//Save
FileOutputStream outputStream = new FileOutputStream(new File(path));
outputStream.write(buf.toByteArray());
outputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
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);
}
//Calculate decryption key from track id
public static byte[] getKey(String id) {
String secret = "g4el58wc0zvf9na1";
String key = "";
try {
MessageDigest md5 = MessageDigest.getInstance("MD5");
//md5.update(id.getBytes());
byte[] md5id = md5.digest(id.getBytes());
String idmd5 = bytesToHex(md5id).toLowerCase();
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);
}
} catch (Exception e) {
}
return key.getBytes();
}
//Decrypt 2048b chunk
public static byte[] decryptChunk(byte[] key, byte[] data) throws Exception{
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);
}
*/
}