diff --git a/README.md b/README.md
index 67802d9..35c526e 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,8 @@ Compile:
```
flutter pub get
flutter build apk
-```
+```
+NOTE: You have to use own keys, or build debug using `flutter build apk --debug`
## Telegram group
https://t.me/freezerandroid
@@ -45,7 +46,12 @@ Andrea: Italian
Diego Hiro: Portuguese
Annexhack: Russian
Chino Pacia: Filipino
-ArcherDelta & PetFix: Spanish
+ArcherDelta & PetFix: Spanish
+Shazzaam: Croatian
+VIRGIN_KLM: Greek
+koreezzz: Korean
+Fwwwwwwwwwweze: French
+kobyrevah: Hebrew
### just_audio, audio_service
This app depends on modified just_audio and audio_service plugins with Deezer support.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e7094bd..68dec5a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,72 +1,90 @@
+
-
-
+ FlutterApplication and put your custom class here.
+ -->
-
-
+
+
+ android:usesCleartextTraffic="true">
+
+
+
+
-
+ to determine the Window background behind the Flutter UI.
+ -->
+ android:name="io.flutter.embedding.android.NormalTheme"
+ android:resource="@style/NormalTheme" />
-
+ Flutter's first frame.
+ -->
+ android:name="io.flutter.embedding.android.SplashScreenDrawable"
+ android:resource="@drawable/launch_background" />
+
-
-
+
+
+
-
+
-
-
+
-
-
+
-
+
+
\ No newline at end of file
diff --git a/android/app/src/main/java/f/f/freezer/Deezer.java b/android/app/src/main/java/f/f/freezer/Deezer.java
new file mode 100644
index 0000000..d4c8253
--- /dev/null
+++ b/android/app/src/main/java/f/f/freezer/Deezer.java
@@ -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>> 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 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 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 downloads = new ArrayList<>();
+ ArrayList threads = new ArrayList<>();
+ ArrayList updateRequests = new ArrayList<>();
+ ArrayList 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 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= 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 {
+ 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 down = new ArrayList<>();
+ for (int i=0; i= 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= 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"));
+ }
+ }
+
+
+}
+
diff --git a/android/app/src/main/java/f/f/freezer/DownloadsDatabase.java b/android/app/src/main/java/f/f/freezer/DownloadsDatabase.java
new file mode 100644
index 0000000..5d2f06a
--- /dev/null
+++ b/android/app/src/main/java/f/f/freezer/DownloadsDatabase.java
@@ -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);
+ }
+}
diff --git a/android/app/src/main/java/f/f/freezer/MainActivity.java b/android/app/src/main/java/f/f/freezer/MainActivity.java
index a20935a..2b1c32f 100644
--- a/android/app/src/main/java/f/f/freezer/MainActivity.java
+++ b/android/app/src/main/java/f/f/freezer/MainActivity.java
@@ -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 downloads = call.arguments();
+ for (int i=0; i 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 data = new ArrayList<>();
+ for (int i=0; i>> 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);
- }
+ */
}
diff --git a/audio_service b/audio_service
index 73fce99..b268066 160000
--- a/audio_service
+++ b/audio_service
@@ -1 +1 @@
-Subproject commit 73fce9905f9ffeec0270f7c89b70cd0eaa762fb6
+Subproject commit b268066d26c1cc28183bfc3f0f4ab60d31ebf1f7
diff --git a/just_audio b/just_audio
index 884bc7a..ae319b9 160000
--- a/just_audio
+++ b/just_audio
@@ -1 +1 @@
-Subproject commit 884bc7a26960973f8690aacb14a45f2d303fc676
+Subproject commit ae319b96899fe42a69c57c2f4a17691d90596f98
diff --git a/lib/api/cache.dart b/lib/api/cache.dart
new file mode 100644
index 0000000..18cb132
--- /dev/null
+++ b/lib/api/cache.dart
@@ -0,0 +1,67 @@
+import 'package:freezer/api/deezer.dart';
+import 'package:freezer/api/definitions.dart';
+import 'package:freezer/ui/details_screens.dart';
+import 'package:json_annotation/json_annotation.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:path/path.dart' as p;
+
+import 'dart:io';
+import 'dart:convert';
+
+part 'cache.g.dart';
+
+Cache cache;
+
+//Cache for miscellaneous things
+@JsonSerializable()
+class Cache {
+
+ //ID's of tracks that are in library
+ List libraryTracks = [];
+
+ //Track ID of logged track, to prevent duplicates
+ @JsonKey(ignore: true)
+ String loggedTrackId;
+
+ @JsonKey(defaultValue: [])
+ List