0.5.0 - Rewritten downloads, many bugfixes
This commit is contained in:
parent
f7cbb09bc1
commit
f2f6b202d1
38 changed files with 5176 additions and 1365 deletions
|
@ -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>
|
352
android/app/src/main/java/f/f/freezer/Deezer.java
Normal file
352
android/app/src/main/java/f/f/freezer/Deezer.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
92
android/app/src/main/java/f/f/freezer/Download.java
Normal file
92
android/app/src/main/java/f/f/freezer/Download.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
709
android/app/src/main/java/f/f/freezer/DownloadService.java
Normal file
709
android/app/src/main/java/f/f/freezer/DownloadService.java
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
43
android/app/src/main/java/f/f/freezer/DownloadsDatabase.java
Normal file
43
android/app/src/main/java/f/f/freezer/DownloadsDatabase.java
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue