diff --git a/.gitignore b/.gitignore index aec5bdaf..b071bd2e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,18 @@ version .pub-cache/ .pub/ build/ +.idea/ + +android/.classpath +android/.project +android/.settings +android/.settings/ +android/example/.classpath +android/example/.project +android/example/.settings +android/example/.settings/ +example/ios/Flutter/flutter_export_environment.sh +.vscode/ flutter_*.png linked_*.ds unlinked.ds @@ -95,4 +107,4 @@ coverage/ !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 -!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages \ No newline at end of file +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/CHANGELOG.md b/CHANGELOG.md index 94559a48..cc6cc24d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ +## 1.7.2 - 24-01-2022 + +* fix bug on Android +* upgrade Android dependencies (WorkerManager 2.7.1) + ## 1.7.1 - 08-10-2021 * fix bug resume download on Android diff --git a/android/.gitignore b/android/.gitignore index c6cbe562..55ac714b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -6,3 +6,6 @@ .DS_Store /build /captures +/.classpath +/.project +/.settings \ No newline at end of file diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml index a1757ae5..3058433f 100644 --- a/android/.idea/compiler.xml +++ b/android/.idea/compiler.xml @@ -4,5 +4,6 @@ + \ No newline at end of file diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml index e22b5728..f894050f 100644 --- a/android/.idea/gradle.xml +++ b/android/.idea/gradle.xml @@ -1,11 +1,10 @@ + diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml index 299e423c..e3f3f991 100644 --- a/android/.idea/misc.xml +++ b/android/.idea/misc.xml @@ -39,7 +39,7 @@ - + diff --git a/android/.idea/modules.xml b/android/.idea/modules.xml deleted file mode 100644 index 2fcf3d6b..00000000 --- a/android/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml deleted file mode 100644 index 7f68460d..00000000 --- a/android/.idea/runConfigurations.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 7374e29c..bbf46e1d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,7 +8,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.6.4' + classpath 'com.android.tools.build:gradle:4.1.0' } } @@ -38,8 +38,12 @@ android { } dependencies { - implementation 'androidx.work:work-runtime:2.7.0-rc01' + implementation 'androidx.work:work-runtime:2.7.1' implementation 'androidx.annotation:annotation:1.2.0' implementation 'androidx.core:core:1.6.0' implementation 'androidx.fragment:fragment:1.3.6' + implementation 'com.mpatric:mp3agic:0.9.0' +// implementation files('/Users/trope/flutter/bin/cache/artifacts/engine/android-x64/flutter.jar') + + } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index da9702f9..14e30f74 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/src/main/java/vn/hunghd/flutterdownloader/DownloadQueue.java b/android/src/main/java/vn/hunghd/flutterdownloader/DownloadQueue.java new file mode 100644 index 00000000..0c7d6a03 --- /dev/null +++ b/android/src/main/java/vn/hunghd/flutterdownloader/DownloadQueue.java @@ -0,0 +1,73 @@ +package vn.hunghd.flutterdownloader; + +import android.content.Context; +import android.util.Log; + +import androidx.work.WorkManager; +import androidx.work.WorkRequest; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.Map; +import java.util.Queue; +import java.util.UUID; + +public final class DownloadQueue { + private static DownloadQueue instance; + private final Queue queue = new LinkedList<>(); + private final Map queueMap = new HashMap<>(); + + private Context context; + private boolean nothingOnWorker = true; + + private DownloadQueue(Context context) { + this.context = context; + } + + public static DownloadQueue getInstance(Context context) { + if (instance == null) { + instance = new DownloadQueue(context); + } + return instance; + } + + public void doQueue() { + final UUID id = queue.poll(); + if (id == null) { + reset(); + } else { + if (queueMap.containsKey(id)){ + final WorkRequest request = queueMap.get(id); + startWork(request); + } else { + doQueue(); + } + } + } + private void startWork(WorkRequest e){ + nothingOnWorker = false; + WorkManager.getInstance(context).enqueue(e); + } + public void reset() { + queue.clear(); + queueMap.clear(); + nothingOnWorker = true; + } + + public void removeTask(UUID e) { + if(queue.remove(e)){ + queueMap.remove(e); + if(queue.isEmpty()){ + nothingOnWorker = true; + } + } + } + public void add(WorkRequest e) { + if (queue.isEmpty() && nothingOnWorker) { + startWork(e); + } else { + queue.add(e.getId()); + queueMap.put(e.getId(),e); + } + } +} diff --git a/android/src/main/java/vn/hunghd/flutterdownloader/DownloadTask.java b/android/src/main/java/vn/hunghd/flutterdownloader/DownloadTask.java index 2cd6162e..d6ac51b0 100644 --- a/android/src/main/java/vn/hunghd/flutterdownloader/DownloadTask.java +++ b/android/src/main/java/vn/hunghd/flutterdownloader/DownloadTask.java @@ -15,9 +15,16 @@ public class DownloadTask { boolean openFileFromNotification; long timeCreated; boolean saveInPublicStorage; + String albumName; + String artistName; + String artistId; + String playlistId; + String albumId; + String musicId; DownloadTask(int primaryId, String taskId, int status, int progress, String url, String filename, String savedDir, - String headers, String mimeType, boolean resumable, boolean showNotification, boolean openFileFromNotification, long timeCreated, boolean saveInPublicStorage) { + String headers, String mimeType, boolean resumable, boolean showNotification, boolean openFileFromNotification, + long timeCreated, boolean saveInPublicStorage, String albumName, String artistName, String artistId, String playlistId, String albumId, String musicId) { this.primaryId = primaryId; this.taskId = taskId; this.status = status; @@ -31,11 +38,23 @@ public class DownloadTask { this.showNotification = showNotification; this.openFileFromNotification = openFileFromNotification; this.timeCreated = timeCreated; + this.albumName = albumName; + this.artistName = artistName; + this.artistId = artistId; + this.playlistId = playlistId; + this.albumId = albumId; + this.musicId = musicId; this.saveInPublicStorage = saveInPublicStorage; } @Override public String toString() { - return "DownloadTask{taskId=" + taskId + ",status=" + status + ",progress=" + progress + ",url=" + url + ",filename=" + filename + ",savedDir=" + savedDir + ",headers=" + headers + ", saveInPublicStorage= " + saveInPublicStorage + "}"; + return "DownloadTask{taskId=" + taskId + ",status=" + status + ",progress=" + progress + + ",url=" + url + ",filename=" + filename + ",savedDir=" + savedDir + ",headers=" + + headers + "saveInPublicStorage= " + saveInPublicStorage + ",albumName=" + albumName + ",artistName=" + artistName + + ",artistId=" + artistId + + ",playlistId=" + playlistId + + ",albumId=" + albumId + + ",musicId=" + musicId + "}"; } } diff --git a/android/src/main/java/vn/hunghd/flutterdownloader/DownloadWorker.java b/android/src/main/java/vn/hunghd/flutterdownloader/DownloadWorker.java index ccecdf63..e9c970c9 100644 --- a/android/src/main/java/vn/hunghd/flutterdownloader/DownloadWorker.java +++ b/android/src/main/java/vn/hunghd/flutterdownloader/DownloadWorker.java @@ -16,19 +16,26 @@ import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.content.ContextCompat; - import android.os.Environment; import android.os.Handler; import android.provider.MediaStore; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import com.mpatric.mp3agic.AbstractID3v2Tag; +import com.mpatric.mp3agic.ID3v1Tag; +import com.mpatric.mp3agic.ID3v22Tag; +import com.mpatric.mp3agic.ID3v23Tag; +import com.mpatric.mp3agic.ID3v24Tag; +import com.mpatric.mp3agic.Mp3File; import org.json.JSONException; import org.json.JSONObject; @@ -47,20 +54,16 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Matcher; import java.util.regex.Pattern; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - import io.flutter.FlutterInjector; import io.flutter.embedding.engine.FlutterEngine; import io.flutter.embedding.engine.dart.DartExecutor; -import io.flutter.embedding.engine.plugins.shim.ShimPluginRegistry; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.PluginRegistry; import io.flutter.view.FlutterCallbackInformation; public class DownloadWorker extends Worker implements MethodChannel.MethodCallHandler { @@ -73,6 +76,18 @@ public class DownloadWorker extends Worker implements MethodChannel.MethodCallHa public static final String ARG_OPEN_FILE_FROM_NOTIFICATION = "open_file_from_notification"; public static final String ARG_CALLBACK_HANDLE = "callback_handle"; public static final String ARG_DEBUG = "debug"; + public static final String ARG_MUSIC_ARTIST = "music_artist"; + public static final String ARG_MUSIC_ALBUM = "music_album"; + public static final String ARG_SM_EXTRAS = "sm_extras"; + public static final String ARG_ARTIST_ID = "artist_id"; + public static final String ARG_PLAYLIST_ID = "playlist_id"; + public static final String ARG_ALBUM_ID = "album_id"; + public static final String ARG_MUSIC_ID = "music_id"; + public static final String ARG_QUEUE_ENABLED = "queue_enabled"; + + public static final String IS_PENDING = "is_pending"; + public static final String USER_AGENT = "SuaMusica/downloader (Linux; Android " + + Build.VERSION.SDK_INT + "; " + Build.BRAND + "/" + Build.MODEL + ")"; public static final String ARG_SAVE_IN_PUBLIC_STORAGE = "save_in_public_storage"; private static final String TAG = DownloadWorker.class.getSimpleName(); @@ -87,21 +102,32 @@ public class DownloadWorker extends Worker implements MethodChannel.MethodCallHa private final Pattern charsetPattern = Pattern.compile("(?i)\\bcharset=\\s*\"?([^\\s;\"]*)"); private final Pattern filenameStarPattern = Pattern.compile("(?i)\\bfilename\\*=([^']+)'([^']*)'\"?([^\"]+)\"?"); private final Pattern filenamePattern = Pattern.compile("(?i)\\bfilename=\"?([^\"]+)\"?"); - private MethodChannel backgroundChannel; private TaskDbHelper dbHelper; private TaskDao taskDao; private boolean showNotification; private boolean clickToOpenDownloadedFile; private boolean debug; + private boolean queueEnabled; private int lastProgress = 0; private int primaryId; - private String msgStarted, msgInProgress, msgCanceled, msgFailed, msgPaused, msgComplete; + private String msgStarted; + private String msgInProgress; + private String msgCanceled; + private String msgFailed; + private String msgPaused; + private String msgComplete; + private String argMusicArtist; + private String argMusicAlbum; + private String argArtistId; + private String argPlaylistId; + private String argAlbumId; + private String argMusicId; + private String argSMExtras; private long lastCallUpdateNotification = 0; private boolean saveInPublicStorage; - public DownloadWorker(@NonNull final Context context, - @NonNull WorkerParameters params) { + public DownloadWorker(@NonNull final Context context, @NonNull WorkerParameters params) { super(context, params); new Handler(context.getMainLooper()).post(new Runnable() { @@ -118,31 +144,24 @@ private void startBackgroundIsolate(Context context) { SharedPreferences pref = context.getSharedPreferences(FlutterDownloaderPlugin.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE); long callbackHandle = pref.getLong(FlutterDownloaderPlugin.CALLBACK_DISPATCHER_HANDLE_KEY, 0); - String appBundlePath = FlutterInjector.instance().flutterLoader().findAppBundlePath();; - AssetManager assets = context.getAssets(); + backgroundFlutterEngine = new FlutterEngine(getApplicationContext(), null, false); // We need to create an instance of `FlutterEngine` before looking up the // callback. If we don't, the callback cache won't be initialized and the // lookup will fail. FlutterCallbackInformation flutterCallback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle); - - backgroundFlutterEngine = new FlutterEngine(context); - - DartExecutor executor = backgroundFlutterEngine.getDartExecutor(); - DartExecutor.DartCallback dartCallback = new DartExecutor.DartCallback(assets, appBundlePath, flutterCallback); - executor.executeDartCallback(dartCallback); - - /// backward compatibility with V1 embedding - if (getApplicationContext() instanceof PluginRegistry.PluginRegistrantCallback) { - PluginRegistry.PluginRegistrantCallback pluginRegistrantCallback = (PluginRegistry.PluginRegistrantCallback) getApplicationContext(); - pluginRegistrantCallback.registerWith(new ShimPluginRegistry(backgroundFlutterEngine)); + if (flutterCallback == null) { + log("Fatal: failed to find callback"); + return; } + + final String appBundlePath = FlutterInjector.instance().flutterLoader().findAppBundlePath(); + final AssetManager assets = getApplicationContext().getAssets(); + backgroundFlutterEngine.getDartExecutor().executeDartCallback(new DartExecutor.DartCallback(assets, appBundlePath, flutterCallback)); } } - - DartExecutor executor = backgroundFlutterEngine.getDartExecutor(); - backgroundChannel = new MethodChannel(executor, "vn.hunghd/downloader_background"); + backgroundChannel = new MethodChannel(backgroundFlutterEngine.getDartExecutor(), "vn.hunghd/downloader_background"); backgroundChannel.setMethodCallHandler(this); } @@ -161,25 +180,26 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { } } -// @Override -// public void onStopped() { -// Context context = getApplicationContext(); -// dbHelper = TaskDbHelper.getInstance(context); -// taskDao = new TaskDao(dbHelper); + @Override + public void onStopped() { + Context context = getApplicationContext(); + dbHelper = TaskDbHelper.getInstance(context); + taskDao = new TaskDao(dbHelper); -// String url = getInputData().getString(ARG_URL); -// String filename = getInputData().getString(ARG_FILE_NAME); + String url = getInputData().getString(ARG_URL); + String filename = getInputData().getString(ARG_FILE_NAME); -// DownloadTask task = taskDao.loadTask(getId().toString()); -// if (task.status == DownloadStatus.ENQUEUED) { -// updateNotification(context, filename == null ? url : filename, DownloadStatus.CANCELED, -1, null, true); -// taskDao.updateTask(getId().toString(), DownloadStatus.CANCELED, lastProgress); -// } -// } + DownloadTask task = taskDao.loadTask(getId().toString()); + if (task != null && task.status == DownloadStatus.ENQUEUED) { + updateNotification(context, filename == null ? url : filename, DownloadStatus.CANCELED, -1, null, true,""); + taskDao.updateTask(getId().toString(), DownloadStatus.CANCELED, lastProgress); + } + } @NonNull @Override public Result doWork() { + Context context = getApplicationContext(); dbHelper = TaskDbHelper.getInstance(context); taskDao = new TaskDao(dbHelper); @@ -190,7 +210,13 @@ public Result doWork() { String headers = getInputData().getString(ARG_HEADERS); boolean isResume = getInputData().getBoolean(ARG_IS_RESUME, false); debug = getInputData().getBoolean(ARG_DEBUG, false); - + argMusicArtist = getInputData().getString(ARG_MUSIC_ARTIST); + argMusicAlbum = getInputData().getString(ARG_MUSIC_ALBUM); + argArtistId = getInputData().getString(ARG_ARTIST_ID); + argPlaylistId = getInputData().getString(ARG_PLAYLIST_ID); + argAlbumId = getInputData().getString(ARG_ALBUM_ID); + argMusicId = getInputData().getString(ARG_MUSIC_ID); + queueEnabled = getInputData().getBoolean(ARG_QUEUE_ENABLED, false); Resources res = getApplicationContext().getResources(); msgStarted = res.getString(R.string.flutter_downloader_notification_started); msgInProgress = res.getString(R.string.flutter_downloader_notification_in_progress); @@ -199,6 +225,13 @@ public Result doWork() { msgPaused = res.getString(R.string.flutter_downloader_notification_paused); msgComplete = res.getString(R.string.flutter_downloader_notification_complete); + Log.i(TAG, "DownloadWorker{url=" + url + ",filename=" + filename + ",savedDir=" + savedDir + + ",header=" + headers + ",isResume=" + isResume + ",argMusicArtist=" + argMusicArtist + + ",argMusicAlbum=" + argMusicAlbum + ",argArtistId=" + argArtistId + + ",argArtistId=" + argArtistId + ",argPlaylistId=" + argPlaylistId + + ",argAlbumId=" + argAlbumId + ",argMusicId=" + argMusicId + ",argSMExtras=" + + argSMExtras + ", queueEnabled = " + queueEnabled); + DownloadTask task = taskDao.loadTask(getId().toString()); log("DownloadWorker{url=" + url + ",filename=" + filename + ",savedDir=" + savedDir + ",header=" + headers + ",isResume=" + isResume + ",status=" + (task != null ? task.status : "GONE")); @@ -216,17 +249,18 @@ public Result doWork() { setupNotification(context); - updateNotification(context, filename == null ? url : filename, DownloadStatus.RUNNING, task.progress, null, false); + updateNotification(context, filename == null ? url : filename, DownloadStatus.RUNNING, + task.progress, null, false, ""); taskDao.updateTask(getId().toString(), DownloadStatus.RUNNING, task.progress); //automatic resume for partial files. (if the workmanager unexpectedly quited in background) String saveFilePath = savedDir + File.separator + filename; - File partialFile = new File(saveFilePath); - if (partialFile.exists()) { - isResume = true; - log("exists file for "+ filename + "automatic resuming..."); - } - + // File partialFile = new File(saveFilePath); + // if (partialFile.exists()) { + // log("exists file for " + filename + "automatic resuming..."); + // } + //Disabling resume because our server does not accepts it. + isResume = false; try { downloadFile(context, url, savedDir, filename, headers, isResume); cleanUp(); @@ -234,7 +268,9 @@ public Result doWork() { taskDao = null; return Result.success(); } catch (Exception e) { - updateNotification(context, filename == null ? url : filename, DownloadStatus.FAILED, -1, null, true); + String errorMessage = e.getMessage(); + updateNotification(context, filename == null ? url : filename, DownloadStatus.FAILED, + -1, null, true, (errorMessage != null) ? errorMessage : "No Message"); taskDao.updateTask(getId().toString(), DownloadStatus.FAILED, lastProgress); e.printStackTrace(); dbHelper = null; @@ -259,7 +295,8 @@ private void setupHeaders(HttpURLConnection conn, String headers) { } } - private long setupPartialDownloadedDataHeader(HttpURLConnection conn, String filename, String savedDir) { + private long setupPartialDownloadedDataHeader(HttpURLConnection conn, String filename, + String savedDir) { String saveFilePath = savedDir + File.separator + filename; File partialFile = new File(saveFilePath); long downloadedBytes = partialFile.length(); @@ -271,7 +308,8 @@ private long setupPartialDownloadedDataHeader(HttpURLConnection conn, String fil return downloadedBytes; } - private void downloadFile(Context context, String fileURL, String savedDir, String filename, String headers, boolean isResume) throws IOException { + private void downloadFile(Context context, String fileURL, String savedDir, String filename, + String headers, boolean isResume) throws IOException { String url = fileURL; URL resourceUrl, base, next; Map visited; @@ -304,14 +342,17 @@ private void downloadFile(Context context, String fileURL, String savedDir, Stri httpConn.setConnectTimeout(15000); httpConn.setReadTimeout(15000); - httpConn.setInstanceFollowRedirects(false); // Make the logic below easier to detect redirections - httpConn.setRequestProperty("User-Agent", "Mozilla/5.0..."); + httpConn.setInstanceFollowRedirects(false); // Make the logic below easier to detect + // redirections + log("Using Agent " + USER_AGENT); + httpConn.setRequestProperty("User-Agent", USER_AGENT); // setup request headers if it is set setupHeaders(httpConn, headers); // try to continue downloading a file from its partial downloaded data. if (isResume) { - downloadedBytes = setupPartialDownloadedDataHeader(httpConn, filename, savedDir); + downloadedBytes = + setupPartialDownloadedDataHeader(httpConn, filename, savedDir); } responseCode = httpConn.getResponseCode(); @@ -339,6 +380,10 @@ private void downloadFile(Context context, String fileURL, String savedDir, Stri if ((responseCode == HttpURLConnection.HTTP_OK || (isResume && responseCode == HttpURLConnection.HTTP_PARTIAL)) && !isStopped()) { contentType = httpConn.getContentType(); long contentLength = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N ? httpConn.getContentLengthLong() : httpConn.getContentLength(); + + if (contentType.contains("multipart/")) { + contentType = "application/octet-stream"; + } log("Content-Type = " + contentType); log("Content-Length = " + contentLength); @@ -385,12 +430,15 @@ private void downloadFile(Context context, String fileURL, String savedDir, Stri // From Android 11 onwards, file is only downloaded to app-specific directory (internal storage) // or public shared download directory (external storage). // The second option will ignore `savedDir` parameter. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && saveInPublicStorage) { - Uri uri = createFileInPublicDownloadsDir(filename, contentType); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && saveInPublicStorage) { + Set volumeNames = MediaStore.getExternalVolumeNames(context); + String firstVolumeName = volumeNames.iterator().next(); + Uri uri = createFileInPublicMusicDir(filename, savedDir); savedFilePath = getMediaStoreEntryPathApi29(uri); outputStream = context.getContentResolver().openOutputStream(uri, "w"); } else { File file = createFileInAppSpecificDir(filename, savedDir); + assert file != null; savedFilePath = file.getPath(); outputStream = new FileOutputStream(file, false); } @@ -405,34 +453,36 @@ private void downloadFile(Context context, String fileURL, String savedDir, Stri int progress = (int) ((count * 100) / (contentLength + downloadedBytes)); outputStream.write(buffer, 0, bytesRead); - if ((lastProgress == 0 || progress > lastProgress + STEP_UPDATE || progress == 100) - && progress != lastProgress) { + if ((lastProgress == 0 || progress > lastProgress + STEP_UPDATE + || progress == 100) && progress != lastProgress) { lastProgress = progress; - + updateNotification(context, filename, DownloadStatus.RUNNING, progress, null, false, ""); // This line possibly causes system overloaded because of accessing to DB too many ?!!! // but commenting this line causes tasks loaded from DB missing current downloading progress, // however, this missing data should be temporary and it will be updated as soon as // a new bunch of data fetched and a notification sent taskDao.updateTask(getId().toString(), DownloadStatus.RUNNING, progress); - updateNotification(context, filename, DownloadStatus.RUNNING, progress, null, false); } } DownloadTask task = taskDao.loadTask(getId().toString()); int progress = isStopped() && task.resumable ? lastProgress : 100; - int status = isStopped() ? (task.resumable ? DownloadStatus.PAUSED : DownloadStatus.CANCELED) : DownloadStatus.COMPLETE; - int storage = ContextCompat.checkSelfPermission(getApplicationContext(), android.Manifest.permission.WRITE_EXTERNAL_STORAGE); + int status = isStopped() + ? (task.resumable ? DownloadStatus.PAUSED : DownloadStatus.CANCELED) + : DownloadStatus.COMPLETE; + int storage = ContextCompat.checkSelfPermission(getApplicationContext(), + android.Manifest.permission.WRITE_EXTERNAL_STORAGE); PendingIntent pendingIntent = null; if (status == DownloadStatus.COMPLETE) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - if (isImageOrVideoFile(contentType) && isExternalStoragePath(savedFilePath)) { - addImageOrVideoToGallery(filename, savedFilePath, getContentTypeWithoutCharset(contentType)); + if (isMediaFile(contentType) && isExternalStoragePath(savedFilePath)) { + addMediaToGallery(filename, savedFilePath, getContentTypeWithoutCharset(contentType)); } } if (clickToOpenDownloadedFile) { - if(android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && storage != PackageManager.PERMISSION_GRANTED) + if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && storage != PackageManager.PERMISSION_GRANTED) return; Intent intent = IntentUtils.validatedFileIntent(getApplicationContext(), savedFilePath, contentType); if (intent != null) { @@ -444,20 +494,30 @@ private void downloadFile(Context context, String fileURL, String savedDir, Stri } } } + Log.d(TAG, "===> updateNotification: (filename: " + filename + ", status: " + status + + ")"); + updateNotification(context, filename, status, progress, pendingIntent, true, + isStopped() ? "Download canceled" : ""); taskDao.updateTask(getId().toString(), status, progress); - updateNotification(context, filename, status, progress, pendingIntent, true); - log(isStopped() ? "Download canceled" : "File downloaded"); } else { DownloadTask task = taskDao.loadTask(getId().toString()); - int status = isStopped() ? (task.resumable ? DownloadStatus.PAUSED : DownloadStatus.CANCELED) : DownloadStatus.FAILED; + String errorMessage = isStopped() ? "Download canceled" + : "Server replied HTTP code: " + responseCode; + int status = isStopped() + ? (task.resumable ? DownloadStatus.PAUSED : DownloadStatus.CANCELED) + : DownloadStatus.FAILED; + updateNotification(context, filename == null ? fileURL : filename, status, -1, null, + true, errorMessage); taskDao.updateTask(getId().toString(), status, lastProgress); - updateNotification(context, filename == null ? fileURL : filename, status, -1, null, true); - log(isStopped() ? "Download canceled" : "Server replied HTTP code: " + responseCode); + log(errorMessage); } } catch (IOException e) { + String errorMessage = e.getMessage(); + updateNotification(context, filename == null ? fileURL : filename, + DownloadStatus.FAILED, -1, null, true, + (errorMessage != null) ? errorMessage : "No Message 2"); taskDao.updateTask(getId().toString(), DownloadStatus.FAILED, lastProgress); - updateNotification(context, filename == null ? fileURL : filename, DownloadStatus.FAILED, -1, null, true); e.printStackTrace(); } finally { if (outputStream != null) { @@ -487,7 +547,7 @@ private void downloadFile(Context context, String fileURL, String savedDir, Stri private File createFileInAppSpecificDir(String filename, String savedDir) { File newFile = new File(savedDir, filename); try { - boolean rs = newFile.createNewFile(); + boolean rs = newFile.exists() || newFile.createNewFile(); if(rs) { return newFile; } else { @@ -500,19 +560,59 @@ private File createFileInAppSpecificDir(String filename, String savedDir) { return null; } + @RequiresApi(api = Build.VERSION_CODES.Q) + private List getContentUris(@NonNull final Context context) { + + final List allVolumes = new ArrayList<>(); + + // Add the internal storage volumes as last resort. + // These will be kept at the bottom of the list if + // any SD-card volumes are found + allVolumes.add(MediaStore.VOLUME_EXTERNAL_PRIMARY); + allVolumes.add(MediaStore.VOLUME_EXTERNAL); + + // Obtain the list of volume name candidates + + final Set externalVolumeNames = MediaStore.getExternalVolumeNames(context); + + for (final String entry : externalVolumeNames) { + // If the volume is "not" already cached in the list, + // then is an SD-card, so prioritize it by adding it + // at the top of the list + if (!allVolumes.contains(entry)) + allVolumes.add(0, entry); + } + + // Finally resolve the target Image content Uris + + final List output = new ArrayList<>(); + + for (final String entry : allVolumes) { + output.add(MediaStore.Audio.Media.getContentUri(entry)); + } + + return output; + } + + /** * Create a file inside the Download folder using MediaStore API */ - @RequiresApi(Build.VERSION_CODES.Q) - private Uri createFileInPublicDownloadsDir(String filename, String mimeType) { - Uri collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI; + @RequiresApi(Build.VERSION_CODES.R) + private Uri createFileInPublicMusicDir(String filename, String savedDir) { + Set volumeNames = MediaStore.getExternalVolumeNames(getApplicationContext()); + List contentUris = getContentUris(getApplicationContext()); + boolean isInternal = volumeNames.size() == 1 || savedDir.toLowerCase().startsWith("/storage/emulated"); + int contentIndex = isInternal? contentUris.size()-1 : 0; // + Uri contentUriSelectToSaveOn =contentUris.get(contentIndex); + String path = savedDir.split("/Music")[1]; ContentValues values = new ContentValues(); values.put(MediaStore.Downloads.DISPLAY_NAME, filename); - values.put(MediaStore.Downloads.MIME_TYPE, mimeType); - values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); + values.put(MediaStore.Downloads.MIME_TYPE, "audio/mpeg"); + values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_MUSIC + path); ContentResolver contentResolver = getApplicationContext().getContentResolver(); try { - return contentResolver.insert(collection, values); + return contentResolver.insert(contentUriSelectToSaveOn, values); } catch (Exception e) { e.printStackTrace(); logError("Create a file using MediaStore API failed."); @@ -535,7 +635,8 @@ private String getMediaStoreEntryPathApi29(Uri uri) { return null; if (!cursor.moveToFirst()) return null; - return cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATA)); + + return cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)); } catch (IllegalArgumentException e) { e.printStackTrace(); logError("Get a path for a MediaStore failed"); @@ -570,9 +671,12 @@ private void cleanUp() { private int getNotificationIconRes() { try { - ApplicationInfo applicationInfo = getApplicationContext().getPackageManager().getApplicationInfo(getApplicationContext().getPackageName(), PackageManager.GET_META_DATA); + ApplicationInfo applicationInfo = + getApplicationContext().getPackageManager().getApplicationInfo( + getApplicationContext().getPackageName(), PackageManager.GET_META_DATA); int appIconResId = applicationInfo.icon; - return applicationInfo.metaData.getInt("vn.hunghd.flutterdownloader.NOTIFICATION_ICON", appIconResId); + return applicationInfo.metaData.getInt("vn.hunghd.flutterdownloader.NOTIFICATION_ICON", + appIconResId); } catch (PackageManager.NameNotFoundException e) { e.printStackTrace(); } @@ -580,7 +684,8 @@ private int getNotificationIconRes() { } private void setupNotification(Context context) { - if (!showNotification) return; + if (!showNotification) + return; // Make a channel if necessary if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Create the NotificationChannel, but only on API 26+ because @@ -588,10 +693,13 @@ private void setupNotification(Context context) { Resources res = getApplicationContext().getResources(); - String channelName = res.getString(R.string.flutter_downloader_notification_channel_name); - String channelDescription = res.getString(R.string.flutter_downloader_notification_channel_description); + String channelName = + res.getString(R.string.flutter_downloader_notification_channel_name); + String channelDescription = + res.getString(R.string.flutter_downloader_notification_channel_description); int importance = NotificationManager.IMPORTANCE_LOW; - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, channelName, importance); + NotificationChannel channel = + new NotificationChannel(CHANNEL_ID, channelName, importance); channel.setDescription(channelDescription); channel.setSound(null, null); @@ -599,53 +707,50 @@ private void setupNotification(Context context) { NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); notificationManager.createNotificationChannel(channel); } - } - private void updateNotification(Context context, String title, int status, int progress, PendingIntent intent, boolean finalize) { - sendUpdateProcessEvent(status, progress); + } + private void updateNotification(Context context, String title, int status, int progress, + PendingIntent intent, boolean finalize, String errorType) { + sendUpdateProcessEvent(status, progress, errorType); + if (queueEnabled && status != 2) { + DownloadQueue.getInstance(context).doQueue(); + } // Show the notification if (showNotification) { + Boolean cancelNotification = false; // Create the notification - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID). - setContentTitle(title) - .setContentIntent(intent) - .setOnlyAlertOnce(true) - .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_LOW); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(title).setContentIntent(intent).setOnlyAlertOnce(true) + .setAutoCancel(true).setPriority(NotificationCompat.PRIORITY_LOW); if (status == DownloadStatus.RUNNING) { if (progress <= 0) { - builder.setContentText(msgStarted) - .setProgress(0, 0, false); - builder.setOngoing(false) - .setSmallIcon(getNotificationIconRes()); + builder.setContentText(msgStarted).setProgress(0, 0, false); + builder.setOngoing(false).setSmallIcon(getNotificationIconRes()); } else if (progress < 100) { - builder.setContentText(msgInProgress) - .setProgress(100, progress, false); - builder.setOngoing(true) - .setSmallIcon(android.R.drawable.stat_sys_download); + builder.setContentText(msgInProgress).setProgress(100, progress, false); + builder.setOngoing(true).setSmallIcon(android.R.drawable.stat_sys_download); } else { - builder.setContentText(msgComplete).setProgress(0, 0, false); - builder.setOngoing(false) - .setSmallIcon(android.R.drawable.stat_sys_download_done); + // builder.setContentText(msgComplete).setProgress(0, 0, false); + // builder.setOngoing(false) + // .setSmallIcon(android.R.drawable.stat_sys_download_done); + cancelNotification = true; } } else if (status == DownloadStatus.CANCELED) { builder.setContentText(msgCanceled).setProgress(0, 0, false); - builder.setOngoing(false) - .setSmallIcon(android.R.drawable.stat_sys_download_done); + builder.setOngoing(false).setSmallIcon(android.R.drawable.stat_sys_download_done); } else if (status == DownloadStatus.FAILED) { builder.setContentText(msgFailed).setProgress(0, 0, false); - builder.setOngoing(false) - .setSmallIcon(android.R.drawable.stat_sys_download_done); + builder.setOngoing(false).setSmallIcon(android.R.drawable.stat_sys_download_done); } else if (status == DownloadStatus.PAUSED) { builder.setContentText(msgPaused).setProgress(0, 0, false); - builder.setOngoing(false) - .setSmallIcon(android.R.drawable.stat_sys_download_done); + builder.setOngoing(false).setSmallIcon(android.R.drawable.stat_sys_download_done); } else if (status == DownloadStatus.COMPLETE) { - builder.setContentText(msgComplete).setProgress(0, 0, false); - builder.setOngoing(false) - .setSmallIcon(android.R.drawable.stat_sys_download_done); + // builder.setContentText(msgComplete).setProgress(0, 0, false); + // builder.setOngoing(false) + // .setSmallIcon(android.R.drawable.stat_sys_download_done); + cancelNotification = true; } else { builder.setProgress(0, 0, false); builder.setOngoing(false).setSmallIcon(getNotificationIconRes()); @@ -653,10 +758,13 @@ private void updateNotification(Context context, String title, int status, int p // Note: Android applies a rate limit when updating a notification. // If you post updates to a notification too frequently (many in less than one second), - // the system might drop some updates. (https://developer.android.com/training/notify-user/build-notification#Updating) + // the system might drop some updates. + // (https://developer.android.com/training/notify-user/build-notification#Updating) // - // If this is progress update, it's not much important if it is dropped because there're still incoming updates later - // If this is the final update, it must be success otherwise the notification will be stuck at the processing state + // If this is progress update, it's not much important if it is dropped because there're + // still incoming updates later + // If this is the final update, it must be success otherwise the notification will be + // stuck at the processing state // In order to ensure the final one is success, we check and sleep a second if need. if (System.currentTimeMillis() - lastCallUpdateNotification < 1000) { if (finalize) { @@ -671,19 +779,26 @@ private void updateNotification(Context context, String title, int status, int p return; } } - log("Update notification: {notificationId: " + primaryId + ", title: " + title + ", status: " + status + ", progress: " + progress + "}"); - NotificationManagerCompat.from(context).notify(primaryId, builder.build()); + + if (cancelNotification) { + NotificationManagerCompat.from(context).cancel(primaryId); + } else { + log("Update notification: {notificationId: " + primaryId + ", title: " + title + + ", status: " + status + ", progress: " + progress + "}"); + NotificationManagerCompat.from(context).notify(primaryId, builder.build()); + } lastCallUpdateNotification = System.currentTimeMillis(); } } - private void sendUpdateProcessEvent(int status, int progress) { + private void sendUpdateProcessEvent(int status, int progress, String errorType) { final List args = new ArrayList<>(); long callbackHandle = getInputData().getLong(ARG_CALLBACK_HANDLE, 0); args.add(callbackHandle); args.add(getId().toString()); args.add(status); args.add(progress); + args.add(errorType); synchronized (isolateStarted) { if (!isolateStarted.get()) { @@ -741,17 +856,20 @@ private String getContentTypeWithoutCharset(String contentType) { return contentType.split(";")[0].trim(); } - private boolean isImageOrVideoFile(String contentType) { + private boolean isMediaFile(String contentType) { contentType = getContentTypeWithoutCharset(contentType); - return (contentType != null && (contentType.startsWith("image/") || contentType.startsWith("video"))); + return (contentType != null && (contentType.startsWith("image/") + || contentType.startsWith("video") || contentType.startsWith("audio") + || contentType.contains("octet-stream"))); } private boolean isExternalStoragePath(String filePath) { File externalStorageDir = Environment.getExternalStorageDirectory(); - return filePath != null && externalStorageDir != null && filePath.startsWith(externalStorageDir.getPath()); + return filePath != null && externalStorageDir != null + && filePath.startsWith(externalStorageDir.getPath()); } - private void addImageOrVideoToGallery(String fileName, String filePath, String contentType) { + private void addMediaToGallery(String fileName, String filePath, String contentType) { if (contentType != null && filePath != null && fileName != null) { if (contentType.startsWith("image/")) { ContentValues values = new ContentValues(); @@ -783,10 +901,89 @@ private void addImageOrVideoToGallery(String fileName, String filePath, String c ContentResolver contentResolver = getApplicationContext().getContentResolver(); contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values); + } else if (contentType.startsWith("audio") || contentType.contains("octet-stream")) { + File file = new File(filePath); + if (file.exists()) { + if (android.os.Build.VERSION.SDK_INT >= 23) { + ContentValues values = new ContentValues(); + values.put(MediaStore.Audio.Media.TITLE, fileName); + values.put(MediaStore.Audio.Media.DISPLAY_NAME, fileName); + values.put(MediaStore.Audio.Media.MIME_TYPE, "audio/mpeg"); + values.put(MediaStore.Audio.Media.DATE_ADDED, System.currentTimeMillis()); + values.put(MediaStore.Audio.Media.DATA, filePath); + values.put(MediaStore.Audio.Media.SIZE, file.getTotalSpace()); + + values.put(MediaStore.Audio.Media.ARTIST, argMusicArtist); + values.put(MediaStore.Audio.Media.ALBUM, argMusicAlbum); + + try { + Mp3File mp3File = new Mp3File(filePath); + ID3v1Tag id3v1Tag = new ID3v1Tag(); + id3v1Tag.setComment("Sua Música"); + mp3File.setId3v1Tag(id3v1Tag); + AbstractID3v2Tag id3v2Tag = new ID3v24Tag(); + if (mp3File.hasId3v2Tag()) { + if (mp3File.getId3v2Tag() instanceof ID3v24Tag) { + id3v2Tag = (ID3v24Tag) mp3File.getId3v2Tag(); + } else if (mp3File.getId3v2Tag() instanceof ID3v23Tag) { + id3v2Tag = (ID3v23Tag) mp3File.getId3v2Tag(); + } else { + id3v2Tag = (ID3v22Tag) mp3File.getId3v2Tag(); + } + } + id3v2Tag.setAlbum(argMusicAlbum); + id3v2Tag.setAlbumArtist(argMusicArtist); + id3v2Tag.setUrl(String.format("https://www.suamusica.com.br/perfil/%s?playlistId=%s&albumId=%s&musicId=%s", argArtistId, argPlaylistId, argAlbumId, argMusicId)); + mp3File.setId3v2Tag(id3v2Tag); + + String newFilename = filePath + ".tmp"; + mp3File.save(newFilename); + + File from = new File(newFilename); + from.renameTo(file); + + Log.i(TAG, "Successfully set ID3v1 tags"); + } catch (Exception e) { + Log.e(TAG, "Failed to set ID3v1 tags", e); + } + // For reasons I could not understand, Android SDK is failing to find the + // constant MediaStore.Audio.Media.ALBUM_ARTIST in pre-compilation time and + // obligated me to reference the column string value. + // However it's working just fine. + values.put("album_artist", argMusicArtist); + + values.put(IS_PENDING, 1); + log("insert " + values + " to MediaStore"); + try { + ContentResolver contentResolver = + getApplicationContext().getContentResolver(); + Uri uriSavedMusic = contentResolver + .insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, values); + if (uriSavedMusic != null) { + values.clear(); + values.put(IS_PENDING, 0); + contentResolver.update(uriSavedMusic, values, null, null); + } + + if (android.os.Build.VERSION.SDK_INT < 29) + callScanFileIntent(file); + } catch (Exception e) { + Log.e(TAG, "Failed to set ID3v1 tags", e); + } + } else { + callScanFileIntent(file); + } + } } } } + private void callScanFileIntent(File file) { + Intent scanFileIntent = + new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(file)); + getApplicationContext().sendBroadcast(scanFileIntent); + } + private void log(String message) { if (debug) { Log.d(TAG, message); diff --git a/android/src/main/java/vn/hunghd/flutterdownloader/FlutterDownloaderPlugin.java b/android/src/main/java/vn/hunghd/flutterdownloader/FlutterDownloaderPlugin.java index 21cadafa..c0002e1b 100644 --- a/android/src/main/java/vn/hunghd/flutterdownloader/FlutterDownloaderPlugin.java +++ b/android/src/main/java/vn/hunghd/flutterdownloader/FlutterDownloaderPlugin.java @@ -1,14 +1,15 @@ package vn.hunghd.flutterdownloader; -import android.annotation.SuppressLint; + import android.content.ContentResolver; import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; +import android.provider.MediaStore; +import android.text.TextUtils; import android.database.Cursor; import android.net.Uri; -import android.provider.MediaStore; import androidx.core.app.NotificationManagerCompat; @@ -16,6 +17,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Arrays; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -33,7 +35,6 @@ import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; -import io.flutter.plugin.common.PluginRegistry; public class FlutterDownloaderPlugin implements MethodCallHandler, FlutterPlugin { private static final String CHANNEL = "vn.hunghd/downloader"; @@ -42,23 +43,13 @@ public class FlutterDownloaderPlugin implements MethodCallHandler, FlutterPlugin public static final String SHARED_PREFERENCES_KEY = "vn.hunghd.downloader.pref"; public static final String CALLBACK_DISPATCHER_HANDLE_KEY = "callback_dispatcher_handle_key"; - private static FlutterDownloaderPlugin instance; private MethodChannel flutterChannel; - private TaskDbHelper dbHelper; private TaskDao taskDao; private Context context; + private boolean queueEnabled = false; private long callbackHandle; private int debugMode; private final Object initializationLock = new Object(); - - @SuppressLint("NewApi") - public static void registerWith(PluginRegistry.Registrar registrar) { - if (instance == null) { - instance = new FlutterDownloaderPlugin(); - } - instance.onAttachedToEngine(registrar.context(), registrar.messenger()); - } - public void onAttachedToEngine(Context applicationContext, BinaryMessenger messenger) { synchronized (initializationLock) { if (flutterChannel != null) { @@ -67,7 +58,7 @@ public void onAttachedToEngine(Context applicationContext, BinaryMessenger messe this.context = applicationContext; flutterChannel = new MethodChannel(messenger, CHANNEL); flutterChannel.setMethodCallHandler(this); - dbHelper = TaskDbHelper.getInstance(context); + TaskDbHelper dbHelper = TaskDbHelper.getInstance(context); taskDao = new TaskDao(dbHelper); } } @@ -80,6 +71,8 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { registerCallback(call, result); } else if (call.method.equals("enqueue")) { enqueue(call, result); + } else if (call.method.equals("enqueueItems")) { + enqueueItems(call, result); } else if (call.method.equals("loadTasks")) { loadTasks(call, result); } else if (call.method.equals("loadTasksWithRawQuery")) { @@ -98,7 +91,7 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { open(call, result); } else if (call.method.equals("remove")) { remove(call, result); - } else { + } else { result.notImplemented(); } } @@ -117,16 +110,25 @@ public void onDetachedFromEngine(FlutterPluginBinding binding) { } } + private void addToQueue(WorkRequest e){ + if (queueEnabled) { + DownloadQueue.getInstance(context).add(e); + } else { + WorkManager.getInstance(context).enqueue(e); + } + } + private WorkRequest buildRequest(String url, String savedDir, String filename, String headers, boolean showNotification, boolean openFileFromNotification, - boolean isResume, boolean requiresStorageNotLow, boolean saveInPublicStorage) { + boolean isResume, boolean requiresStorageNotLow, boolean saveInPublicStorage, String albumName, + String artistName, String artistId, String playlistId, String albumId, String musicId) { WorkRequest request = new OneTimeWorkRequest.Builder(DownloadWorker.class) .setConstraints(new Constraints.Builder() .setRequiresStorageNotLow(requiresStorageNotLow) .setRequiredNetworkType(NetworkType.CONNECTED) .build()) .addTag(TAG) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.SECONDS) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS) .setInputData(new Data.Builder() .putString(DownloadWorker.ARG_URL, url) .putString(DownloadWorker.ARG_SAVED_DIR, savedDir) @@ -137,6 +139,13 @@ private WorkRequest buildRequest(String url, String savedDir, String filename, S .putBoolean(DownloadWorker.ARG_IS_RESUME, isResume) .putLong(DownloadWorker.ARG_CALLBACK_HANDLE, callbackHandle) .putBoolean(DownloadWorker.ARG_DEBUG, debugMode == 1) + .putString(DownloadWorker.ARG_MUSIC_ALBUM, albumName) + .putString(DownloadWorker.ARG_MUSIC_ARTIST, artistName) + .putString(DownloadWorker.ARG_ARTIST_ID, artistId) + .putString(DownloadWorker.ARG_PLAYLIST_ID, playlistId) + .putString(DownloadWorker.ARG_ALBUM_ID, albumId) + .putString(DownloadWorker.ARG_MUSIC_ID, musicId) + .putBoolean(DownloadWorker.ARG_QUEUE_ENABLED, queueEnabled) .putBoolean(DownloadWorker.ARG_SAVE_IN_PUBLIC_STORAGE, saveInPublicStorage) .build() ) @@ -144,11 +153,12 @@ private WorkRequest buildRequest(String url, String savedDir, String filename, S return request; } - private void sendUpdateProgress(String id, int status, int progress) { + private void sendUpdateProgress(String id, int status, int progress, String obs) { Map args = new HashMap<>(); args.put("task_id", id); args.put("status", status); args.put("progress", progress); + args.put("obs", obs); flutterChannel.invokeMethod("updateProgress", args); } @@ -156,6 +166,7 @@ private void initialize(MethodCall call, MethodChannel.Result result) { List args = (List) call.arguments; long callbackHandle = Long.parseLong(args.get(0).toString()); debugMode = Integer.parseInt(args.get(1).toString()); + queueEnabled = Boolean.parseBoolean(args.get(2).toString()); SharedPreferences pref = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE); pref.edit().putLong(CALLBACK_DISPATCHER_HANDLE_KEY, callbackHandle).apply(); @@ -174,18 +185,61 @@ private void enqueue(MethodCall call, MethodChannel.Result result) { String savedDir = call.argument("saved_dir"); String filename = call.argument("file_name"); String headers = call.argument("headers"); + String albumName = call.argument("music_album"); + String artistName = call.argument("music_artist"); + String artistId = call.argument("artist_id"); + String playlistId = call.argument("playlist_id"); + String albumId = call.argument("album_id"); + String musicId = call.argument("music_id"); boolean showNotification = call.argument("show_notification"); boolean openFileFromNotification = call.argument("open_file_from_notification"); boolean requiresStorageNotLow = call.argument("requires_storage_not_low"); boolean saveInPublicStorage = call.argument("save_in_public_storage"); - WorkRequest request = buildRequest(url, savedDir, filename, headers, showNotification, - openFileFromNotification, false, requiresStorageNotLow, saveInPublicStorage); - WorkManager.getInstance(context).enqueue(request); + WorkRequest request = buildRequest(url, savedDir, filename, headers, showNotification, openFileFromNotification, + false, requiresStorageNotLow, saveInPublicStorage,albumName, artistName, artistId, playlistId, albumId, musicId); + addToQueue(request); String taskId = request.getId().toString(); result.success(taskId); - sendUpdateProgress(taskId, DownloadStatus.ENQUEUED, 0); - taskDao.insertOrUpdateNewTask(taskId, url, DownloadStatus.ENQUEUED, 0, filename, - savedDir, headers, showNotification, openFileFromNotification, saveInPublicStorage); + sendUpdateProgress(taskId, DownloadStatus.ENQUEUED, 0, ""); + taskDao.insertOrUpdateNewTask(taskId, url, DownloadStatus.ENQUEUED, 0, filename, savedDir, headers, + showNotification, openFileFromNotification, saveInPublicStorage, albumName, artistName, artistId, playlistId, albumId, musicId); + } + + private void enqueueItems(MethodCall call, MethodChannel.Result result) { + List> downloads = call.argument("downloads"); + + String headers = call.argument("headers"); + boolean showNotification = call.argument("show_notification"); + boolean openFileFromNotification = call.argument("open_file_from_notification"); + boolean requiresStorageNotLow = call.argument("requires_storage_not_low"); + boolean saveInPublicStorage = call.argument("save_in_public_storage"); + + List taskIds = new ArrayList<>(); + + for (int i = 0; i < downloads.size(); i++) { + String url = downloads.get(i).get("url"); + String savedDir = downloads.get(i).get("saved_dir"); + String filename = downloads.get(i).get("file_name"); + String albumName = downloads.get(i).get("music_album"); + String artistName = downloads.get(i).get("music_artist"); + String artistId = downloads.get(i).get("artist_id"); + String playlistId = downloads.get(i).get("playlist_id"); + String albumId = downloads.get(i).get("album_id"); + String musicId = downloads.get(i).get("music_id"); + + WorkRequest request = buildRequest(url, savedDir, filename, headers, showNotification, + openFileFromNotification, false, requiresStorageNotLow, saveInPublicStorage, albumName, artistName, + artistId, playlistId, albumId, musicId); + addToQueue(request); + String taskId = request.getId().toString(); + taskIds.add(taskId); + sendUpdateProgress(taskId, DownloadStatus.ENQUEUED, 0, ""); + taskDao.insertOrUpdateNewTask(taskId, url, DownloadStatus.ENQUEUED, 0, filename, savedDir, headers, + showNotification, openFileFromNotification, saveInPublicStorage, albumName, artistName, + artistId, playlistId, albumId, musicId); + } + + result.success(taskIds); } private void loadTasks(MethodCall call, MethodChannel.Result result) { @@ -200,6 +254,12 @@ private void loadTasks(MethodCall call, MethodChannel.Result result) { item.put("file_name", task.filename); item.put("saved_dir", task.savedDir); item.put("time_created", task.timeCreated); + item.put("music_album", task.albumName); + item.put("music_artist", task.artistName); + item.put("artist_id", task.artistId); + item.put("playlist_id", task.playlistId); + item.put("album_id", task.albumId); + item.put("music_id", task.musicId); array.add(item); } result.success(array); @@ -218,6 +278,12 @@ private void loadTasksWithRawQuery(MethodCall call, MethodChannel.Result result) item.put("file_name", task.filename); item.put("saved_dir", task.savedDir); item.put("time_created", task.timeCreated); + item.put("music_album", task.albumName); + item.put("music_artist", task.artistName); + item.put("artist_id", task.artistId); + item.put("playlist_id", task.playlistId); + item.put("album_id", task.albumId); + item.put("music_id", task.musicId); array.add(item); } result.success(array); @@ -225,12 +291,17 @@ private void loadTasksWithRawQuery(MethodCall call, MethodChannel.Result result) private void cancel(MethodCall call, MethodChannel.Result result) { String taskId = call.argument("task_id"); - WorkManager.getInstance(context).cancelWorkById(UUID.fromString(taskId)); + UUID uuid = UUID.fromString(taskId); + WorkManager.getInstance(context).cancelWorkById(uuid); + DownloadQueue.getInstance(context).removeTask(uuid); result.success(null); } private void cancelAll(MethodCall call, MethodChannel.Result result) { WorkManager.getInstance(context).cancelAllWorkByTag(TAG); + if (queueEnabled) { + DownloadQueue.getInstance(context).reset(); + } result.success(null); } @@ -248,7 +319,9 @@ private void resume(MethodCall call, MethodChannel.Result result) { String taskId = call.argument("task_id"); DownloadTask task = taskDao.loadTask(taskId); boolean requiresStorageNotLow = call.argument("requires_storage_not_low"); + String headers = call.argument("headers"); if (task != null) { + final String finalHeaders = TextUtils.isEmpty(headers) ? task.headers : headers; if (task.status == DownloadStatus.PAUSED) { String filename = task.filename; if (filename == null) { @@ -257,12 +330,12 @@ private void resume(MethodCall call, MethodChannel.Result result) { String partialFilePath = task.savedDir + File.separator + filename; File partialFile = new File(partialFilePath); if (partialFile.exists()) { - WorkRequest request = buildRequest(task.url, task.savedDir, task.filename, - task.headers, task.showNotification, task.openFileFromNotification, - true, requiresStorageNotLow, task.saveInPublicStorage); + WorkRequest request = buildRequest(task.url, task.savedDir, task.filename, finalHeaders, + task.showNotification, task.openFileFromNotification, true, requiresStorageNotLow, task.saveInPublicStorage, + task.albumName, task.artistName, task.artistId, task.playlistId, task.albumId, task.musicId); String newTaskId = request.getId().toString(); result.success(newTaskId); - sendUpdateProgress(newTaskId, DownloadStatus.RUNNING, task.progress); + sendUpdateProgress(newTaskId, DownloadStatus.RUNNING, task.progress, ""); taskDao.updateTask(taskId, newTaskId, DownloadStatus.RUNNING, task.progress, false); WorkManager.getInstance(context).enqueue(request); } else { @@ -281,14 +354,16 @@ private void retry(MethodCall call, MethodChannel.Result result) { String taskId = call.argument("task_id"); DownloadTask task = taskDao.loadTask(taskId); boolean requiresStorageNotLow = call.argument("requires_storage_not_low"); + String headers = call.argument("headers"); if (task != null) { - if (task.status == DownloadStatus.FAILED || task.status == DownloadStatus.CANCELED) { - WorkRequest request = buildRequest(task.url, task.savedDir, task.filename, - task.headers, task.showNotification, task.openFileFromNotification, - false, requiresStorageNotLow, task.saveInPublicStorage); + if (task.status == DownloadStatus.FAILED || task.status == DownloadStatus.CANCELED || task.status == DownloadStatus.ENQUEUED) { + final String finalHeaders = TextUtils.isEmpty(headers) ? task.headers : headers; + WorkRequest request = buildRequest(task.url, task.savedDir, task.filename, finalHeaders, + task.showNotification, task.openFileFromNotification, false, requiresStorageNotLow, task.saveInPublicStorage, + task.albumName, task.artistName, task.artistId, task.playlistId, task.albumId, task.musicId); String newTaskId = request.getId().toString(); result.success(newTaskId); - sendUpdateProgress(newTaskId, DownloadStatus.ENQUEUED, task.progress); + sendUpdateProgress(newTaskId, DownloadStatus.ENQUEUED, task.progress, ""); taskDao.updateTask(taskId, newTaskId, DownloadStatus.ENQUEUED, task.progress, false); WorkManager.getInstance(context).enqueue(request); } else { @@ -336,16 +411,32 @@ private void remove(MethodCall call, MethodChannel.Result result) { } if (shouldDeleteContent) { String filename = task.filename; + String extension = ""; + List audioExtension = Arrays.asList("mp3", "m4a", "ogg"); + if (filename == null) { filename = task.url.substring(task.url.lastIndexOf("/") + 1, task.url.length()); } + int i = filename.lastIndexOf('.'); + if (i > 0) { + extension = filename.toLowerCase().substring(i + 1); + } String saveFilePath = task.savedDir + File.separator + filename; + if (audioExtension.contains(extension)) { + Uri rootUri = MediaStore.Audio.Media.getContentUriForPath(saveFilePath); + context.getContentResolver().delete(rootUri, + MediaStore.MediaColumns.DATA + "=?", new String[]{saveFilePath}); + } File tempFile = new File(saveFilePath); if (tempFile.exists()) { deleteFileInMediaStore(tempFile); tempFile.delete(); } + File directory = new File(task.savedDir); + if (directory.exists() && directory.isDirectory() && directory.list().length == 0) { + directory.delete(); + } } taskDao.deleteTask(taskId); diff --git a/android/src/main/java/vn/hunghd/flutterdownloader/TaskContract.java b/android/src/main/java/vn/hunghd/flutterdownloader/TaskContract.java index 334479b0..9820d064 100644 --- a/android/src/main/java/vn/hunghd/flutterdownloader/TaskContract.java +++ b/android/src/main/java/vn/hunghd/flutterdownloader/TaskContract.java @@ -20,6 +20,12 @@ public static class TaskEntry implements BaseColumns { public static final String COLUMN_NAME_SHOW_NOTIFICATION = "show_notification"; public static final String COLUMN_NAME_OPEN_FILE_FROM_NOTIFICATION = "open_file_from_notification"; public static final String COLUMN_NAME_TIME_CREATED = "time_created"; + public static final String COLUMN_NAME_ALBUM_NAME = "music_album"; + public static final String COLUMN_NAME_ARTIST_NAME = "music_artist"; + public static final String COLUMN_NAME_ARTIST_ID = "artist_id"; + public static final String COLUMN_NAME_PLAYLIST_ID = "playlist_id"; + public static final String COLUMN_NAME_ALBUM_ID = "album_id"; + public static final String COLUMN_NAME_MUSIC_ID = "music_id"; public static final String COLUMN_SAVE_IN_PUBLIC_STORAGE = "save_in_public_storage"; } diff --git a/android/src/main/java/vn/hunghd/flutterdownloader/TaskDao.java b/android/src/main/java/vn/hunghd/flutterdownloader/TaskDao.java index 13567097..2fc26c0a 100644 --- a/android/src/main/java/vn/hunghd/flutterdownloader/TaskDao.java +++ b/android/src/main/java/vn/hunghd/flutterdownloader/TaskDao.java @@ -25,6 +25,12 @@ public class TaskDao { TaskContract.TaskEntry.COLUMN_NAME_OPEN_FILE_FROM_NOTIFICATION, TaskContract.TaskEntry.COLUMN_NAME_SHOW_NOTIFICATION, TaskContract.TaskEntry.COLUMN_NAME_TIME_CREATED, + TaskContract.TaskEntry.COLUMN_NAME_ALBUM_NAME, + TaskContract.TaskEntry.COLUMN_NAME_ARTIST_NAME, + TaskContract.TaskEntry.COLUMN_NAME_ARTIST_ID, + TaskContract.TaskEntry.COLUMN_NAME_PLAYLIST_ID, + TaskContract.TaskEntry.COLUMN_NAME_ALBUM_ID, + TaskContract.TaskEntry.COLUMN_NAME_MUSIC_ID, TaskContract.TaskEntry.COLUMN_SAVE_IN_PUBLIC_STORAGE }; @@ -33,7 +39,8 @@ public TaskDao(TaskDbHelper helper) { } public void insertOrUpdateNewTask(String taskId, String url, int status, int progress, String fileName, - String savedDir, String headers, boolean showNotification, boolean openFileFromNotification, boolean saveInPublicStorage) { + String savedDir, String headers, boolean showNotification, boolean openFileFromNotification, boolean saveInPublicStorage, + String albumName, String artistName, String artistId, String playlistId, String albumId, String musicId) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); @@ -49,6 +56,12 @@ public void insertOrUpdateNewTask(String taskId, String url, int status, int pro values.put(TaskContract.TaskEntry.COLUMN_NAME_OPEN_FILE_FROM_NOTIFICATION, openFileFromNotification ? 1 : 0); values.put(TaskContract.TaskEntry.COLUMN_NAME_RESUMABLE, 0); values.put(TaskContract.TaskEntry.COLUMN_NAME_TIME_CREATED, System.currentTimeMillis()); + values.put(TaskContract.TaskEntry.COLUMN_NAME_ALBUM_NAME, albumName); + values.put(TaskContract.TaskEntry.COLUMN_NAME_ARTIST_NAME, artistName); + values.put(TaskContract.TaskEntry.COLUMN_NAME_ARTIST_ID, artistId); + values.put(TaskContract.TaskEntry.COLUMN_NAME_PLAYLIST_ID, playlistId); + values.put(TaskContract.TaskEntry.COLUMN_NAME_ALBUM_ID, albumId); + values.put(TaskContract.TaskEntry.COLUMN_NAME_MUSIC_ID, musicId); values.put(TaskContract.TaskEntry.COLUMN_SAVE_IN_PUBLIC_STORAGE, saveInPublicStorage ? 1 : 0); db.beginTransaction(); @@ -225,9 +238,15 @@ private DownloadTask parseCursor(Cursor cursor) { int showNotification = cursor.getShort(cursor.getColumnIndexOrThrow(TaskContract.TaskEntry.COLUMN_NAME_SHOW_NOTIFICATION)); int clickToOpenDownloadedFile = cursor.getShort(cursor.getColumnIndexOrThrow(TaskContract.TaskEntry.COLUMN_NAME_OPEN_FILE_FROM_NOTIFICATION)); long timeCreated = cursor.getLong(cursor.getColumnIndexOrThrow(TaskContract.TaskEntry.COLUMN_NAME_TIME_CREATED)); + String albumName = cursor.getString(cursor.getColumnIndexOrThrow(TaskContract.TaskEntry.COLUMN_NAME_ALBUM_NAME)); + String artistName = cursor.getString(cursor.getColumnIndexOrThrow(TaskContract.TaskEntry.COLUMN_NAME_ARTIST_NAME)); + String artistId = cursor.getString(cursor.getColumnIndexOrThrow(TaskContract.TaskEntry.COLUMN_NAME_ARTIST_ID)); + String playlistId = cursor.getString(cursor.getColumnIndexOrThrow(TaskContract.TaskEntry.COLUMN_NAME_PLAYLIST_ID)); + String albumId = cursor.getString(cursor.getColumnIndexOrThrow(TaskContract.TaskEntry.COLUMN_NAME_ALBUM_ID)); + String musicId = cursor.getString(cursor.getColumnIndexOrThrow(TaskContract.TaskEntry.COLUMN_NAME_MUSIC_ID)); int saveInPublicStorage = cursor.getShort(cursor.getColumnIndexOrThrow(TaskContract.TaskEntry.COLUMN_SAVE_IN_PUBLIC_STORAGE)); return new DownloadTask(primaryId, taskId, status, progress, url, filename, savedDir, headers, - mimeType, resumable == 1, showNotification == 1, clickToOpenDownloadedFile == 1, timeCreated, saveInPublicStorage == 1); + mimeType, resumable == 1, showNotification == 1, clickToOpenDownloadedFile == 1, timeCreated, saveInPublicStorage == 1, albumName, artistName, artistId, playlistId, albumId, musicId); } } diff --git a/android/src/main/java/vn/hunghd/flutterdownloader/TaskDbHelper.java b/android/src/main/java/vn/hunghd/flutterdownloader/TaskDbHelper.java index e5132554..2ac87811 100644 --- a/android/src/main/java/vn/hunghd/flutterdownloader/TaskDbHelper.java +++ b/android/src/main/java/vn/hunghd/flutterdownloader/TaskDbHelper.java @@ -3,11 +3,10 @@ import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; - import vn.hunghd.flutterdownloader.TaskContract.TaskEntry; public class TaskDbHelper extends SQLiteOpenHelper { - public static final int DATABASE_VERSION = 3; + public static final int DATABASE_VERSION = 6; public static final String DATABASE_NAME = "download_tasks.db"; private static TaskDbHelper instance = null; @@ -27,6 +26,12 @@ public class TaskDbHelper extends SQLiteOpenHelper { TaskEntry.COLUMN_NAME_SHOW_NOTIFICATION + " TINYINT DEFAULT 0, " + TaskEntry.COLUMN_NAME_OPEN_FILE_FROM_NOTIFICATION + " TINYINT DEFAULT 0, " + TaskEntry.COLUMN_NAME_TIME_CREATED + " INTEGER DEFAULT 0, " + + TaskEntry.COLUMN_NAME_ALBUM_NAME + " TEXT, " + + TaskEntry.COLUMN_NAME_ARTIST_NAME + " TEXT, " + + TaskEntry.COLUMN_NAME_ARTIST_ID + " TEXT," + + TaskEntry.COLUMN_NAME_PLAYLIST_ID + " TEXT," + + TaskEntry.COLUMN_NAME_ALBUM_ID + " TEXT," + + TaskEntry.COLUMN_NAME_MUSIC_ID + " TEXT," + TaskEntry.COLUMN_SAVE_IN_PUBLIC_STORAGE + " TINYINT DEFAULT 0" + ")"; @@ -53,12 +58,31 @@ private TaskDbHelper(Context context) { @Override public void onCreate(SQLiteDatabase db) { db.execSQL(SQL_CREATE_ENTRIES); + + } + public void tryStatement(SQLiteDatabase db, String query) { + try { + db.execSQL(query); + } catch (Exception e) {} } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - db.execSQL(SQL_DELETE_ENTRIES); - onCreate(db); + if (oldVersion < 5) { + tryStatement(db,"ALTER TABLE " + TaskEntry.TABLE_NAME + " ADD COLUMN " + TaskEntry.COLUMN_NAME_ALBUM_NAME + " TEXT;"); + tryStatement(db,"ALTER TABLE " + TaskEntry.TABLE_NAME + " ADD COLUMN " + TaskEntry.COLUMN_NAME_ARTIST_NAME + " TEXT;"); + tryStatement(db,"ALTER TABLE " + TaskEntry.TABLE_NAME + " ADD COLUMN " + TaskEntry.COLUMN_NAME_ARTIST_ID + " TEXT;"); + tryStatement(db,"ALTER TABLE " + TaskEntry.TABLE_NAME + " ADD COLUMN " + TaskEntry.COLUMN_NAME_PLAYLIST_ID + " TEXT;"); + tryStatement(db,"ALTER TABLE " + TaskEntry.TABLE_NAME + " ADD COLUMN " + TaskEntry.COLUMN_NAME_ALBUM_ID + " TEXT;"); + tryStatement(db,"ALTER TABLE " + TaskEntry.TABLE_NAME + " ADD COLUMN " + TaskEntry.COLUMN_NAME_MUSIC_ID + " TEXT;"); + } + if (oldVersion < 6) { + tryStatement(db,"ALTER TABLE " + TaskEntry.TABLE_NAME + " ADD COLUMN " + TaskEntry.COLUMN_SAVE_IN_PUBLIC_STORAGE + " TINYINT DEFAULT 0;"); + } else { + // default migration. should only be a fallback solution + db.execSQL(SQL_DELETE_ENTRIES); + onCreate(db); + } } @Override diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 5150e668..0babf8ad 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -1,11 +1,11 @@ - started - in progress - canceled - failed - complete - paused + Iniciado + Baixando + cancelado + falhou + completo + pausado Downloader - Display download progress + Progresso do download \ No newline at end of file diff --git a/example/.gitignore b/example/.gitignore index dee655cc..a3d51fb3 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -7,3 +7,9 @@ build/ .flutter-plugins +.flutter-plugins-dependencies + +/.classpath +/.project +/.settings +/android/app/.classpath \ No newline at end of file diff --git a/example/android/.gitignore b/example/android/.gitignore index bc2100d8..3761e1ae 100644 --- a/example/android/.gitignore +++ b/example/android/.gitignore @@ -5,3 +5,10 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java + +/.classpath +/.project +/.settings +/app/.classpath +/app/.project +/app/.settings/ \ No newline at end of file diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 1dedfc5e..926a3d8d 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -23,7 +23,8 @@ android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustResize" + android:exported="true"> diff --git a/example/android/build.gradle b/example/android/build.gradle index 547139be..0b4cf534 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.6.3' + classpath 'com.android.tools.build:gradle:4.1.0' } } diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 6ff4a1b0..3549a20b 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/example/lib/main.dart b/example/lib/main.dart index c5644879..0831cb6a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -166,10 +166,15 @@ class _MyHomePageState extends State { } static void downloadCallback( - String id, DownloadTaskStatus status, int progress) { + String id, + DownloadTaskStatus status, + int progress, + String obs, + ) { if (debug) { print( - 'Background Isolate Callback: task ($id) is in status ($status) and process ($progress)'); + 'Background Isolate Callback: task ($id) is in status ($status) and process ($progress) and obs ($obs)', + ); } final SendPort send = IsolateNameServer.lookupPortByName('downloader_send_port')!; @@ -292,6 +297,7 @@ class _MyHomePageState extends State { ); } + // ignore: unused_element void _cancelDownload(_TaskInfo task) async { await FlutterDownloader.cancel(taskId: task.taskId!); } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index f9298db4..05a7da71 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: flutter_downloader: path: ../ - android_path_provider: ^0.2.1 + android_path_provider: ^0.3.0 device_info: ^2.0.2 # The following adds the Cupertino Icons font to your application. diff --git a/ios/.gitignore b/ios/.gitignore index 710ec6cf..aa479fd3 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -34,3 +34,4 @@ Icon? .tags* /Flutter/Generated.xcconfig +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/ios/Classes/FlutterDownloaderPlugin.m b/ios/Classes/FlutterDownloaderPlugin.m index 5c9df825..9cf8b5b6 100644 --- a/ios/Classes/FlutterDownloaderPlugin.m +++ b/ios/Classes/FlutterDownloaderPlugin.m @@ -23,6 +23,7 @@ #define KEY_OPEN_FILE_FROM_NOTIFICATION @"open_file_from_notification" #define KEY_QUERY @"query" #define KEY_TIME_CREATED @"time_created" +#define KEY_DOWNLOADS @"downloads" #define NULL_VALUE @"" @@ -31,6 +32,7 @@ #define STEP_UPDATE 5 +static NSDictionary* dictColumnNames = nil; @interface FlutterDownloaderPlugin() { FlutterEngine *_headlessRunner; @@ -53,6 +55,7 @@ @implementation FlutterDownloaderPlugin static FlutterPluginRegistrantCallback registerPlugins = nil; static BOOL initialized = NO; +static BOOL backgroundIsolateRun = NO; static BOOL debug = YES; @synthesize databaseQueue; @@ -62,20 +65,21 @@ - (instancetype)init:(NSObject *)registrar; if (self = [super init]) { _headlessRunner = [[FlutterEngine alloc] initWithName:@"FlutterDownloaderIsolate" project:nil allowHeadlessExecution:YES]; _registrar = registrar; - + + dictColumnNames = @{@"id": @0, @"task_id": @1, @"url":@2,@"status": @3, @"progress": @4, @"file_name":@5,@"saved_dir": @6, @"resumable": @7 ,@"headers": @8 ,@"show_notification": @9 ,@"open_file_from_notification": @10 ,@"time_created": @11}; _mainChannel = [FlutterMethodChannel - methodChannelWithName:@"vn.hunghd/downloader" - binaryMessenger:[registrar messenger]]; + methodChannelWithName:@"vn.hunghd/downloader" + binaryMessenger:[registrar messenger]]; [registrar addMethodCallDelegate:self channel:_mainChannel]; - + _callbackChannel = [FlutterMethodChannel methodChannelWithName:@"vn.hunghd/downloader_background" binaryMessenger:[_headlessRunner binaryMessenger]]; - + _eventQueue = [[NSMutableArray alloc] init]; - + NSBundle *frameworkBundle = [NSBundle bundleForClass:FlutterDownloaderPlugin.class]; - + // initialize Database NSURL *bundleUrl = [[frameworkBundle resourceURL] URLByAppendingPathComponent:@"FlutterDownloaderDatabase.bundle"]; NSBundle *resourceBundle = [NSBundle bundleWithURL:bundleUrl]; @@ -86,7 +90,7 @@ - (instancetype)init:(NSObject *)registrar; databaseQueue = dispatch_queue_create("vn.hunghd.flutter_downloader", 0); _dbManager = [[DBManager alloc] initWithDatabaseFilePath:dbPath]; _runningTaskById = [[NSMutableDictionary alloc] init]; - + // init NSURLSession NSBundle *mainBundle = [NSBundle mainBundle]; NSNumber *maxConcurrentTasks = [mainBundle objectForInfoDictionaryKey:@"FDMaximumConcurrentTasks"]; @@ -102,7 +106,7 @@ - (instancetype)init:(NSObject *)registrar; if (debug) { NSLog(@"init NSURLSession with id: %@", [[_session configuration] identifier]); } - + _allFilesDownloadedMsg = [mainBundle objectForInfoDictionaryKey:@"FDAllFilesDownloadedMessage"]; if (_allFilesDownloadedMsg == nil) { _allFilesDownloadedMsg = @"All files have been downloaded"; @@ -111,7 +115,7 @@ - (instancetype)init:(NSObject *)registrar; NSLog(@"AllFilesDownloadedMessage: %@", _allFilesDownloadedMsg); } } - + return self; } @@ -125,13 +129,17 @@ - (void)startBackgroundIsolate:(int64_t)handle { NSString *uri = info.callbackLibraryPath; [_headlessRunner runWithEntrypoint:entrypoint libraryURI:uri]; NSAssert(registerPlugins != nil, @"failed to set registerPlugins"); - + // Once our headless runner has been started, we need to register the application's plugins // with the runner in order for them to work on the background isolate. `registerPlugins` is // a callback set from AppDelegate.m in the main application. This callback should register // all relevant plugins (excluding those which require UI). - registerPlugins(_headlessRunner); + + if (!backgroundIsolateRun) { + registerPlugins(_headlessRunner); + } [_registrar addMethodCallDelegate:self channel:_callbackChannel]; + backgroundIsolateRun = YES; } - (FlutterMethodChannel *)channel { @@ -149,7 +157,7 @@ - (NSURLSessionDownloadTask*)downloadTaskWithURL: (NSURL*) url fileName: (NSStri NSError *jsonError; NSData *data = [headers dataUsingEncoding:NSUTF8StringEncoding]; NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:&jsonError]; - + for (NSString *key in json) { NSString *value = json[key]; if (debug) { @@ -160,7 +168,7 @@ - (NSURLSessionDownloadTask*)downloadTaskWithURL: (NSURL*) url fileName: (NSStri } NSURLSessionDownloadTask *task = [[self currentSession] downloadTaskWithRequest:request]; [task resume]; - + return task; } @@ -199,21 +207,21 @@ - (void)pauseTaskWithId: (NSString*)taskId // Save partial downloaded data to a file NSFileManager *fileManager = [NSFileManager defaultManager]; NSURL *destinationURL = [weakSelf fileUrlOf:taskId taskInfo:task downloadTask:download]; - + if ([fileManager fileExistsAtPath:[destinationURL path]]) { [fileManager removeItemAtURL:destinationURL error:nil]; } - + BOOL success = [resumeData writeToURL:destinationURL atomically:YES]; if (debug) { NSLog(@"save partial downloaded data to a file: %s", success ? "success" : "failure"); } }]; - + [weakSelf updateRunningTaskById:taskId progress:progress status:STATUS_PAUSED resumable:YES]; - - [weakSelf sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_PAUSED) andProgress:@(progress)]; - + + [weakSelf sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_PAUSED) andProgress:@(progress) andErrorType:@""]; + dispatch_sync([weakSelf databaseQueue], ^{ [weakSelf updateTask:taskId status:STATUS_PAUSED progress:progress resumable:YES]; }); @@ -235,7 +243,7 @@ - (void)cancelTaskWithId: (NSString*)taskId NSString *taskIdValue = [self identifierForTask:download]; if ([taskId isEqualToString:taskIdValue] && (state == NSURLSessionTaskStateRunning)) { [download cancel]; - [weakSelf sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_CANCELED) andProgress:@(-1)]; + [weakSelf sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_CANCELED) andProgress:@(-1) andErrorType:@""]; dispatch_sync([weakSelf databaseQueue], ^{ [weakSelf updateTask:taskId status:STATUS_CANCELED progress:-1]; }); @@ -253,7 +261,7 @@ - (void)cancelAllTasks { if (state == NSURLSessionTaskStateRunning) { [download cancel]; NSString *taskId = [self identifierForTask:download]; - [weakSelf sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_CANCELED) andProgress:@(-1)]; + [weakSelf sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_CANCELED) andProgress:@(-1) andErrorType:@""]; dispatch_sync([weakSelf databaseQueue], ^{ [weakSelf updateTask:taskId status:STATUS_CANCELED progress:-1]; }); @@ -262,9 +270,9 @@ - (void)cancelAllTasks { }]; } -- (void)sendUpdateProgressForTaskId: (NSString*)taskId inStatus: (NSNumber*) status andProgress: (NSNumber*) progress +- (void)sendUpdateProgressForTaskId: (NSString*)taskId inStatus: (NSNumber*) status andProgress: (NSNumber*) progress andErrorType: (NSString*) errorType { - NSArray *args = @[@(_callbackHandle), taskId, status, progress]; + NSArray *args = @[@(_callbackHandle), taskId, status, progress, errorType]; if (initialized) { [_callbackChannel invokeMethod:@"" arguments:args]; } else { @@ -308,7 +316,7 @@ - (NSURL*)fileUrlOf:(NSString*)taskId taskInfo:(NSDictionary*)taskInfo downloadT if (debug) { NSLog(@"SuggestedFileName: %@", suggestedFilename); } - + // check filename, if it is empty then we try to extract it from http response or url path if (filename == (NSString*) [NSNull null] || [NULL_VALUE isEqualToString: filename]) { if (suggestedFilename) { @@ -316,24 +324,24 @@ - (NSURL*)fileUrlOf:(NSString*)taskId taskInfo:(NSDictionary*)taskInfo downloadT } else { filename = downloadTask.currentRequest.URL.lastPathComponent; } - + NSMutableDictionary *mutableTask = [taskInfo mutableCopy]; [mutableTask setObject:filename forKey:KEY_FILE_NAME]; - + // update taskInfo if ([_runningTaskById objectForKey:taskId]) { _runningTaskById[taskId][KEY_FILE_NAME] = filename; } - + // update DB __typeof__(self) __weak weakSelf = self; dispatch_sync(databaseQueue, ^{ [weakSelf updateTask:taskId filename:filename]; }); - + return [self fileUrlFromDict:mutableTask]; } - + return [self fileUrlFromDict:taskInfo]; } @@ -519,20 +527,50 @@ - (NSDictionary*)loadTaskWithId:(NSString*)taskId } } +- (NSString*) stringAtIndex:(NSArray*)record withArrColumnNames:(NSArray*)arrColumnNames withFieldName:(NSString*)fieldName +{ + NSUInteger index = [arrColumnNames indexOfObject:fieldName]; + if(index == NSNotFound){ + index = [dictColumnNames[fieldName] intValue]; + } + return [record objectAtIndex:index]; +} +- (int) intAtIndex:(NSArray*)record withArrColumnNames:(NSArray*)arrColumnNames withFieldName:(NSString*)fieldName +{ + NSUInteger index = [arrColumnNames indexOfObject:fieldName]; + if(index == NSNotFound){ + index = [dictColumnNames[fieldName] intValue]; + } + return [[record objectAtIndex:index] intValue]; +} + +- (long long) longlongAtIndex:(NSArray*)record withArrColumnNames:(NSArray*)arrColumnNames withFieldName:(NSString*)fieldName +{ + NSUInteger index = [arrColumnNames indexOfObject:fieldName]; + if(index == NSNotFound){ + index = [dictColumnNames[fieldName] intValue]; + } + return [[record objectAtIndex:index] longLongValue]; +} - (NSDictionary*) taskDictFromRecordArray:(NSArray*)record { - NSString *taskId = [record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"task_id"]]; - int status = [[record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"status"]] intValue]; - int progress = [[record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"progress"]] intValue]; - NSString *url = [record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"url"]]; - NSString *filename = [record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"file_name"]]; - NSString *savedDir = [self absoluteSavedDirPath:[record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"saved_dir"]]]; - NSString *headers = [record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"headers"]]; + NSString *taskId = [self stringAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"task_id"]; + int status = [self intAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"status"]; + int progress = [self intAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"progress"]; + + NSString *url = [self stringAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"url"]; + NSString *filename = [self stringAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"file_name"]; + + NSString *savedDir = [self absoluteSavedDirPath: [self stringAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"saved_dir"]]; + + NSString *headers = [self stringAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"headers"]; headers = [self escape:headers revert:true]; - int resumable = [[record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"resumable"]] intValue]; - int showNotification = [[record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"show_notification"]] intValue]; - int openFileFromNotification = [[record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"open_file_from_notification"]] intValue]; - long long timeCreated = [[record objectAtIndex:[_dbManager.arrColumnNames indexOfObject:@"time_created"]] longLongValue]; + + int resumable = [self intAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"resumable"]; + int showNotification = [self intAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"show_notification"]; + int openFileFromNotification = [self intAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"open_file_from_notification"]; + + long long timeCreated =[self longlongAtIndex:record withArrColumnNames:_dbManager.arrColumnNames withFieldName:@"time_created"]; return [NSDictionary dictionaryWithObjectsAndKeys:taskId, KEY_TASK_ID, @(status), KEY_STATUS, @(progress), KEY_PROGRESS, url, KEY_URL, filename, KEY_FILE_NAME, headers, KEY_HEADERS, savedDir, KEY_SAVED_DIR, [NSNumber numberWithBool:(resumable == 1)], KEY_RESUMABLE, [NSNumber numberWithBool:(showNotification == 1)], KEY_SHOW_NOTIFICATION, [NSNumber numberWithBool:(openFileFromNotification == 1)], KEY_OPEN_FILE_FROM_NOTIFICATION, @(timeCreated), KEY_TIME_CREATED, nil]; } @@ -573,29 +611,81 @@ - (void)enqueueMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result NSString *headers = call.arguments[KEY_HEADERS]; NSNumber *showNotification = call.arguments[KEY_SHOW_NOTIFICATION]; NSNumber *openFileFromNotification = call.arguments[KEY_OPEN_FILE_FROM_NOTIFICATION]; - + NSURLSessionDownloadTask *task = [self downloadTaskWithURL:[NSURL URLWithString:urlString] fileName:fileName andSavedDir:savedDir andHeaders:headers]; - + NSString *taskId = [self identifierForTask:task]; + dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void){ + + [_runningTaskById setObject: [NSMutableDictionary dictionaryWithObjectsAndKeys: + urlString, KEY_URL, + fileName, KEY_FILE_NAME, + savedDir, KEY_SAVED_DIR, + headers, KEY_HEADERS, + showNotification, KEY_SHOW_NOTIFICATION, + openFileFromNotification, KEY_OPEN_FILE_FROM_NOTIFICATION, + @(NO), KEY_RESUMABLE, + @(STATUS_ENQUEUED), KEY_STATUS, + @(0), KEY_PROGRESS, nil] + forKey:taskId]; + + __typeof__(self) __weak weakSelf = self; + dispatch_sync(databaseQueue, ^{ + [weakSelf addNewTask:taskId url:urlString status:STATUS_ENQUEUED progress:0 filename:fileName savedDir:shortSavedDir headers:headers resumable:NO showNotification: [showNotification boolValue] openFileFromNotification: [openFileFromNotification boolValue]]; + }); + dispatch_async(dispatch_get_main_queue(), ^{ + result(taskId); + [self sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_ENQUEUED) andProgress:@0 andErrorType:@""]; + }); + }); +} - [_runningTaskById setObject: [NSMutableDictionary dictionaryWithObjectsAndKeys: - urlString, KEY_URL, - fileName, KEY_FILE_NAME, - savedDir, KEY_SAVED_DIR, - headers, KEY_HEADERS, - showNotification, KEY_SHOW_NOTIFICATION, - openFileFromNotification, KEY_OPEN_FILE_FROM_NOTIFICATION, - @(NO), KEY_RESUMABLE, - @(STATUS_ENQUEUED), KEY_STATUS, - @(0), KEY_PROGRESS, nil] - forKey:taskId]; - - __typeof__(self) __weak weakSelf = self; - dispatch_sync(databaseQueue, ^{ - [weakSelf addNewTask:taskId url:urlString status:STATUS_ENQUEUED progress:0 filename:fileName savedDir:shortSavedDir headers:headers resumable:NO showNotification: [showNotification boolValue] openFileFromNotification: [openFileFromNotification boolValue]]; +- (void)enqueueItemsMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + NSArray *downloads = call.arguments[KEY_DOWNLOADS]; + + NSString *headers = call.arguments[KEY_HEADERS]; + NSNumber *showNotification = call.arguments[KEY_SHOW_NOTIFICATION]; + NSNumber *openFileFromNotification = call.arguments[KEY_OPEN_FILE_FROM_NOTIFICATION]; + + NSMutableArray *taskIds = [[NSMutableArray alloc] init]; + dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void){ + for (int i = 0; i < [downloads count]; i++) { + NSDictionary* downloadDict = [downloads objectAtIndex:i]; + + NSString *urlString = downloadDict[KEY_URL]; + NSString *savedDir = downloadDict[KEY_SAVED_DIR]; + NSString *shortSavedDir = [self shortenSavedDirPath:savedDir]; + NSString *fileName = downloadDict[KEY_FILE_NAME]; + + NSURLSessionDownloadTask *task = [self downloadTaskWithURL:[NSURL URLWithString:urlString] fileName:fileName andSavedDir:savedDir andHeaders:headers]; + + NSString *taskId = [self identifierForTask:task]; + + [_runningTaskById setObject: [NSMutableDictionary dictionaryWithObjectsAndKeys: + urlString, KEY_URL, + fileName, KEY_FILE_NAME, + savedDir, KEY_SAVED_DIR, + headers, KEY_HEADERS, + showNotification, KEY_SHOW_NOTIFICATION, + openFileFromNotification, KEY_OPEN_FILE_FROM_NOTIFICATION, + @(NO), KEY_RESUMABLE, + @(STATUS_ENQUEUED), KEY_STATUS, + @(0), KEY_PROGRESS, nil] + forKey:taskId]; + + __typeof__(self) __weak weakSelf = self; + NSLog(@"Enqueue: Database Start: %@", fileName); + dispatch_sync(databaseQueue, ^{ + [weakSelf addNewTask:taskId url:urlString status:STATUS_ENQUEUED progress:0 filename:fileName savedDir:shortSavedDir headers:headers resumable:NO showNotification: [showNotification boolValue] openFileFromNotification: [openFileFromNotification boolValue]]; + }); + NSLog(@"Enqueue: Database End: %@", fileName); + [taskIds addObject:taskId]; + [self sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_ENQUEUED) andProgress:@0 andErrorType:@""]; + } + dispatch_async(dispatch_get_main_queue(), ^{ + result(taskIds); + }); }); - result(taskId); - [self sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_ENQUEUED) andProgress:@0]; } - (void)loadTasksMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { @@ -639,33 +729,33 @@ - (void)resumeMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { NSNumber* status = taskDict[KEY_STATUS]; if ([status intValue] == STATUS_PAUSED) { NSURL *partialFileURL = [self fileUrlFromDict:taskDict]; - + if (debug) { NSLog(@"Try to load resume data at url: %@", partialFileURL); } - + NSData *resumeData = [NSData dataWithContentsOfURL:partialFileURL]; - + if (resumeData != nil) { NSURLSessionDownloadTask *task = [[self currentSession] downloadTaskWithResumeData:resumeData]; NSString *newTaskId = [self identifierForTask:task]; [task resume]; - + // update memory-cache, assign a new taskId for paused task NSMutableDictionary *newTask = [NSMutableDictionary dictionaryWithDictionary:taskDict]; newTask[KEY_STATUS] = @(STATUS_RUNNING); newTask[KEY_RESUMABLE] = @(NO); [_runningTaskById setObject:newTask forKey:newTaskId]; [_runningTaskById removeObjectForKey:taskId]; - + result(newTaskId); - + __typeof__(self) __weak weakSelf = self; dispatch_sync([self databaseQueue], ^{ [weakSelf updateTask:taskId newTaskId:newTaskId status:STATUS_RUNNING resumable:NO]; NSDictionary *task = [weakSelf loadTaskWithId:newTaskId]; NSNumber *progress = task[KEY_PROGRESS]; - [weakSelf sendUpdateProgressForTaskId:newTaskId inStatus:@(STATUS_RUNNING) andProgress:progress]; + [weakSelf sendUpdateProgressForTaskId:newTaskId inStatus:@(STATUS_RUNNING) andProgress:progress andErrorType:@""]; }); } else { result([FlutterError errorWithCode:@"invalid_data" @@ -684,6 +774,7 @@ - (void)resumeMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { - (void)retryMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { NSString *taskId = call.arguments[KEY_TASK_ID]; + NSString *headers = call.arguments[KEY_HEADERS]; NSDictionary* taskDict = [self loadTaskWithId:taskId]; if (taskDict != nil) { NSNumber* status = taskDict[KEY_STATUS]; @@ -691,11 +782,12 @@ - (void)retryMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { NSString *urlString = taskDict[KEY_URL]; NSString *savedDir = taskDict[KEY_SAVED_DIR]; NSString *fileName = taskDict[KEY_FILE_NAME]; - NSString *headers = taskDict[KEY_HEADERS]; - + if (headers == nil || [headers length] > 0) { + headers = taskDict[KEY_HEADERS]; + } NSURLSessionDownloadTask *newTask = [self downloadTaskWithURL:[NSURL URLWithString:urlString] fileName:fileName andSavedDir:savedDir andHeaders:headers]; NSString *newTaskId = [self identifierForTask:newTask]; - + // update memory-cache NSMutableDictionary *newTaskDict = [NSMutableDictionary dictionaryWithDictionary:taskDict]; newTaskDict[KEY_STATUS] = @(STATUS_ENQUEUED); @@ -703,13 +795,13 @@ - (void)retryMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { newTaskDict[KEY_RESUMABLE] = @(NO); [_runningTaskById setObject:newTaskDict forKey:newTaskId]; [_runningTaskById removeObjectForKey:taskId]; - + __typeof__(self) __weak weakSelf = self; dispatch_sync([self databaseQueue], ^{ [weakSelf updateTask:taskId newTaskId:newTaskId status:STATUS_ENQUEUED resumable:NO]; }); result(newTaskId); - [self sendUpdateProgressForTaskId:newTaskId inStatus:@(STATUS_ENQUEUED) andProgress:@(0)]; + [self sendUpdateProgressForTaskId:newTaskId inStatus:@(STATUS_ENQUEUED) andProgress:@(0) andErrorType:@""]; } else { result([FlutterError errorWithCode:@"invalid_status" message:@"only failed and canceled task can be retried" @@ -727,7 +819,7 @@ - (void)openMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { NSNumber* status = taskDict[KEY_STATUS]; if ([status intValue] == STATUS_COMPLETE) { NSURL *downloadedFileURL = [self fileUrlFromDict:taskDict]; - + BOOL success = [self openDocumentWithURL:downloadedFileURL]; result([NSNumber numberWithBool:success]); } else { @@ -754,7 +846,7 @@ - (void)removeMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { NSString *taskIdValue = [weakSelf identifierForTask:download]; if ([taskId isEqualToString:taskIdValue] && (state == NSURLSessionTaskStateRunning)) { [download cancel]; - [weakSelf sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_CANCELED) andProgress:@(-1)]; + [weakSelf sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_CANCELED) andProgress:@(-1) andErrorType:@""]; dispatch_sync([weakSelf databaseQueue], ^{ [weakSelf deleteTask:taskId]; }); @@ -766,10 +858,10 @@ - (void)removeMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { [self deleteTask:taskId]; if (shouldDeleteContent) { NSURL *destinationURL = [self fileUrlFromDict:taskDict]; - + NSError *error; NSFileManager *fileManager = [NSFileManager defaultManager]; - + if ([fileManager fileExistsAtPath:[destinationURL path]]) { [fileManager removeItemAtURL:destinationURL error:&error]; if (debug) { @@ -780,6 +872,13 @@ - (void)removeMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } } } + NSString *savedDir = taskDict[KEY_SAVED_DIR]; + NSArray *folderContents = [fileManager contentsOfDirectoryAtPath:savedDir error:&error]; + if (folderContents) { + if (folderContents.count == 0) { + [fileManager removeItemAtPath:savedDir error:&error]; + } + } } result([NSNull null]); } else { @@ -794,7 +893,7 @@ + (void)registerWithRegistrar:(NSObject*)registrar { } + (void)setPluginRegistrantCallback:(FlutterPluginRegistrantCallback)callback { - registerPlugins = callback; + registerPlugins = callback; } - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { @@ -806,6 +905,8 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { [self registerCallbackMethodCall:call result:result]; } else if ([@"enqueue" isEqualToString:call.method]) { [self enqueueMethodCall:call result:result]; + } else if ([@"enqueueItems" isEqualToString:call.method]) { + [self enqueueItemsMethodCall:call result:result]; } else if ([@"loadTasks" isEqualToString:call.method]) { [self loadTasksMethodCall:call result:result]; } else if ([@"loadTasksWithRawQuery" isEqualToString:call.method]) { @@ -841,10 +942,13 @@ - (void)applicationWillTerminate:(nonnull UIApplication *)application NSLog(@"applicationWillTerminate:"); } for (NSString* key in _runningTaskById) { - if ([_runningTaskById[key][KEY_STATUS] intValue] < STATUS_COMPLETE) { + NSLog(@"applicationWillTerminate - _runningTaskById[key]: %@", _runningTaskById[key]); + NSLog(@"applicationWillTerminate - _runningTaskById[key][KEY_STATUS]: %@", _runningTaskById[key][KEY_STATUS]); + if ([_runningTaskById objectForKey:key] != nil && [_runningTaskById[key] objectForKey:KEY_STATUS] != nil && _runningTaskById[key][KEY_STATUS] != nil && [_runningTaskById[key][KEY_STATUS] intValue] < STATUS_COMPLETE) { [self updateTask:key status:STATUS_CANCELED progress:-1]; } } + _session = nil; _mainChannel = nil; _dbManager = nil; @@ -855,21 +959,23 @@ - (void)applicationWillTerminate:(nonnull UIApplication *)application # pragma mark - NSURLSessionTaskDelegate - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite { - if (totalBytesExpectedToWrite == NSURLSessionTransferSizeUnknown) { - if (debug) { - NSLog(@"Unknown transfer size"); - } - } else { - NSString *taskId = [self identifierForTask:downloadTask]; - int progress = round(totalBytesWritten * 100 / (double)totalBytesExpectedToWrite); - NSNumber *lastProgress = _runningTaskById[taskId][KEY_PROGRESS]; - if (([lastProgress intValue] == 0 || (progress > [lastProgress intValue] + STEP_UPDATE) || progress == 100) && progress != [lastProgress intValue]) { - [self sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_RUNNING) andProgress:@(progress)]; - __typeof__(self) __weak weakSelf = self; - dispatch_sync(databaseQueue, ^{ - [weakSelf updateTask:taskId status:STATUS_RUNNING progress:progress]; - }); - _runningTaskById[taskId][KEY_PROGRESS] = @(progress); + @synchronized (self) { + if (totalBytesExpectedToWrite == NSURLSessionTransferSizeUnknown) { + if (debug) { + NSLog(@"Unknown transfer size"); + } + } else { + NSString *taskId = [self identifierForTask:downloadTask]; + int progress = round(totalBytesWritten * 100 / (double)totalBytesExpectedToWrite); + NSNumber *lastProgress = _runningTaskById[taskId][KEY_PROGRESS]; + if (([lastProgress intValue] == 0 || (progress > [lastProgress intValue] + STEP_UPDATE) || progress == 100) && progress != [lastProgress intValue]) { + [self sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_RUNNING) andProgress:@(progress) andErrorType:@""]; + __typeof__(self) __weak weakSelf = self; + dispatch_sync(databaseQueue, ^{ + [weakSelf updateTask:taskId status:STATUS_RUNNING progress:progress]; + }); + _runningTaskById[taskId][KEY_PROGRESS] = @(progress); + } } } } @@ -879,35 +985,36 @@ - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTas if (debug) { NSLog(@"URLSession:downloadTask:didFinishDownloadingToURL:"); } - + NSString *taskId = [self identifierForTask:downloadTask ofSession:session]; NSDictionary *task = [self loadTaskWithId:taskId]; NSURL *destinationURL = [self fileUrlOf:taskId taskInfo:task downloadTask:downloadTask]; - + [_runningTaskById removeObjectForKey:taskId]; - + NSError *error; NSFileManager *fileManager = [NSFileManager defaultManager]; - + if ([fileManager fileExistsAtPath:[destinationURL path]]) { [fileManager removeItemAtURL:destinationURL error:nil]; } - + BOOL success = [fileManager copyItemAtURL:location toURL:destinationURL error:&error]; - + __typeof__(self) __weak weakSelf = self; if (success) { - [self sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_COMPLETE) andProgress:@100]; + [self sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_COMPLETE) andProgress:@100 andErrorType:@""]; dispatch_sync(databaseQueue, ^{ [weakSelf updateTask:taskId status:STATUS_COMPLETE progress:100]; }); } else { + NSString *errorString = [NSString stringWithFormat:@"Unable to copy temp file. Error: %@", [error localizedDescription]]; if (debug) { NSLog(@"Unable to copy temp file. Error: %@", [error localizedDescription]); } - [self sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_FAILED) andProgress:@(-1)]; + [self sendUpdateProgressForTaskId:taskId inStatus:@(STATUS_FAILED) andProgress:@(-1) andErrorType:errorString]; dispatch_sync(databaseQueue, ^{ [weakSelf updateTask:taskId status:STATUS_FAILED progress:-1]; }); @@ -929,6 +1036,8 @@ -(void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompl if (debug) { NSLog(@"Download completed with error: %@", error != nil ? [error localizedDescription] : @(httpStatusCode)); } + NSString *errorString = [NSString stringWithFormat:@"Download completed with error: %@", error != nil ? [error localizedDescription] : @(httpStatusCode)]; + NSString *taskId = [self identifierForTask:task ofSession:session]; NSDictionary *taskInfo = [self loadTaskWithId:taskId]; NSNumber *resumable = taskInfo[KEY_RESUMABLE]; @@ -940,7 +1049,7 @@ -(void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompl status = STATUS_FAILED; } [_runningTaskById removeObjectForKey:taskId]; - [self sendUpdateProgressForTaskId:taskId inStatus:@(status) andProgress:@(-1)]; + [self sendUpdateProgressForTaskId:taskId inStatus:@(status) andProgress:@(-1) andErrorType:errorString]; __typeof__(self) __weak weakSelf = self; dispatch_sync(databaseQueue, ^{ [weakSelf updateTask:taskId status:status progress:-1]; @@ -960,18 +1069,18 @@ -(void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session if (debug) { NSLog(@"all download tasks have been finished"); } - + if (self.backgroundTransferCompletionHandler != nil) { // Copy locally the completion handler. void(^completionHandler)(void) = self.backgroundTransferCompletionHandler; - + // Make nil the backgroundTransferCompletionHandler. self.backgroundTransferCompletionHandler = nil; - + [[NSOperationQueue mainQueue] addOperationWithBlock:^{ // Call the completion handler to tell the system that there are no other background transfers. completionHandler(); - + // Show a local notification when all downloads are over. UILocalNotification *localNotification = [[UILocalNotification alloc] init]; localNotification.alertBody = self->_allFilesDownloadedMsg; @@ -1001,7 +1110,7 @@ - (void)documentInteractionController:(UIDocumentInteractionController *)control if (debug) { NSLog(@"Finished sending the document to app %@ ...", application); } - + } - (void)documentInteractionControllerDidDismissOpenInMenu:(UIDocumentInteractionController *)controller diff --git a/lib/src/callback_dispatcher.dart b/lib/src/callback_dispatcher.dart index da5ec540..badd11d5 100644 --- a/lib/src/callback_dispatcher.dart +++ b/lib/src/callback_dispatcher.dart @@ -17,11 +17,6 @@ void callbackDispatcher() { final Function? callback = PluginUtilities.getCallbackFromHandle(handle); - if (callback == null) { - print('Fatal: could not find callback'); - exit(-1); - } - if (callback == null) { print('Fatal: could not find callback'); exit(-1); @@ -29,8 +24,9 @@ void callbackDispatcher() { final String id = args[1]; final int status = args[2]; final int progress = args[3]; + final String obs = args[4]; - callback(id, DownloadTaskStatus(status), progress); + callback(id, DownloadTaskStatus(status), progress, obs); }); backgroundChannel.invokeMethod('didInitializeDispatcher'); diff --git a/lib/src/downloader.dart b/lib/src/downloader.dart index e35f25b0..d8e70b0a 100644 --- a/lib/src/downloader.dart +++ b/lib/src/downloader.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui'; +import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -15,9 +16,26 @@ import 'models.dart'; /// * `status`: current status of a download task /// * `progress`: current progress value of a download task, the value is in /// range of 0 and 100 +/// * `obs`: any String indicating any more info needed /// typedef void DownloadCallback( - String id, DownloadTaskStatus status, int progress); + String id, + DownloadTaskStatus status, + int progress, + String obs, +); + +StringBuffer toHeaderBuilder(Map? data) { + final headerBuilder = StringBuffer(); + if (data != null) { + headerBuilder.write('{'); + headerBuilder.writeAll( + data.entries.map((entry) => '\"${entry.key}\": \"${entry.value}\"'), + ','); + headerBuilder.write('}'); + } + return headerBuilder; +} /// /// A convenient class wraps all api functions of **FlutterDownloader** plugin @@ -26,7 +44,10 @@ class FlutterDownloader { static const _channel = const MethodChannel('vn.hunghd/downloader'); static bool _initialized = false; - static Future initialize({bool debug = true}) async { + static Future initialize({ + bool debug = true, + bool useQueue = false, + }) async { assert(!_initialized, 'FlutterDownloader.initialize() must be called only once!'); @@ -34,7 +55,9 @@ class FlutterDownloader { final callback = PluginUtilities.getCallbackHandle(callbackDispatcher)!; await _channel.invokeMethod( - 'initialize', [callback.toRawHandle(), debug ? 1 : 0]); + 'initialize', + [callback.toRawHandle(), debug ? 1 : 0, useQueue], + ); _initialized = true; return null; } @@ -68,36 +91,39 @@ class FlutterDownloader { /// /// an unique identifier of the new download task /// - static Future enqueue( - {required String url, - required String savedDir, - String? fileName, - Map? headers, - bool showNotification = true, - bool openFileFromNotification = true, - bool requiresStorageNotLow = true, - bool saveInPublicStorage = false}) async { + static Future enqueue({ + required String url, + required String savedDir, + String? fileName, + Map? headers, + bool showNotification = true, + bool openFileFromNotification = true, + bool requiresStorageNotLow = true, + bool saveInPublicStorage = false, + String? albumName, + String? artistName, + String? artistId, + String? playlistId, + String? albumId, + String? musicId, + }) async { assert(_initialized, 'FlutterDownloader.initialize() must be called first'); - assert(Directory(savedDir).existsSync()); - - StringBuffer headerBuilder = StringBuffer(); - if (headers != null) { - headerBuilder.write('{'); - headerBuilder.writeAll( - headers.entries - .map((entry) => '\"${entry.key}\": \"${entry.value}\"'), - ','); - headerBuilder.write('}'); - } + assert(saveInPublicStorage || Directory(savedDir).existsSync()); try { String? taskId = await _channel.invokeMethod('enqueue', { 'url': url, 'saved_dir': savedDir, 'file_name': fileName, - 'headers': headerBuilder.toString(), + 'headers': toHeaderBuilder(headers).toString(), 'show_notification': showNotification, 'open_file_from_notification': openFileFromNotification, 'requires_storage_not_low': requiresStorageNotLow, + 'music_album': albumName, + 'music_artist': artistName, + "artist_id": artistId, + "playlist_id": playlistId, + "album_id": albumId, + "music_id": musicId, 'save_in_public_storage': saveInPublicStorage, }); return taskId; @@ -107,6 +133,57 @@ class FlutterDownloader { } } + /// + /// Create a bunch of new download tasks + /// + /// **parameters:** + /// + /// * `downloads`: download items containing link, absolute path of the directory + /// where downloaded file is saved, name of downloaded file. If this parameter + /// is not set, the plugin will try to extract a file name from HTTP headers + /// response or `url` + /// * `headers`: HTTP headers + /// * `showNotification`: sets `true` to show a notification displaying the + /// download progress (only Android), otherwise, `false` value will disable + /// this feature. The default value is `true` + /// * `openFileFromNotification`: if `showNotification` is `true`, this flag + /// controls the way to response to user's click action on the notification + /// (only Android). If it is `true`, user can click on the notification to + /// open and preview the downloaded file, otherwise, nothing happens. The + /// default value is `true` + /// + /// **return:** + /// + /// an unique identifier of the new download task + /// + static Future?> enqueueItems({ + required List downloads, + Map? headers, + bool showNotification = true, + bool openFileFromNotification = true, + bool requiresStorageNotLow = true, + bool saveInPublicStorage = false, + }) async { + assert(_initialized, 'FlutterDownloader.initialize() must be called first'); + try { + List result = await _channel.invokeMethod('enqueueItems', { + 'downloads': downloads.map((item) => item.toMap()).toList(), + 'headers': toHeaderBuilder(headers).toString(), + 'show_notification': showNotification, + 'open_file_from_notification': openFileFromNotification, + 'requires_storage_not_low': requiresStorageNotLow, + 'save_in_public_storage': saveInPublicStorage, + }); + return result.map((taskId) { + print('Download task is enqueued with id($taskId)'); + return taskId as String; + }).toList(); + } on PlatformException catch (e) { + print('Download task is failed with reason(${e.message})'); + return null; + } + } + /// /// Load all tasks from Sqlite database /// @@ -121,13 +198,20 @@ class FlutterDownloader { List result = await _channel.invokeMethod('loadTasks'); return result .map((item) => new DownloadTask( - taskId: item['task_id'], - status: DownloadTaskStatus(item['status']), - progress: item['progress'], - url: item['url'], - filename: item['file_name'], - savedDir: item['saved_dir'], - timeCreated: item['time_created'])) + taskId: item['task_id'], + status: DownloadTaskStatus(item['status']), + progress: item['progress'], + url: item['url'], + filename: item['file_name'], + savedDir: item['saved_dir'], + timeCreated: item['time_created'], + albumName: item['music_album'], + artistName: item['music_artist'], + artistId: item["artist_id"], + playlistId: item["playlist_id"], + albumId: item["album_id"], + musicId: item["music_id"], + )) .toList(); } on PlatformException catch (e) { print(e.message); @@ -164,13 +248,20 @@ class FlutterDownloader { .invokeMethod('loadTasksWithRawQuery', {'query': query}); return result .map((item) => new DownloadTask( - taskId: item['task_id'], - status: DownloadTaskStatus(item['status']), - progress: item['progress'], - url: item['url'], - filename: item['file_name'], - savedDir: item['saved_dir'], - timeCreated: item['time_created'])) + taskId: item['task_id'], + status: DownloadTaskStatus(item['status']), + progress: item['progress'], + url: item['url'], + filename: item['file_name'], + savedDir: item['saved_dir'], + timeCreated: item['time_created'], + albumName: item['music_album'], + artistName: item['music_artist'], + artistId: item["artist_id"], + playlistId: item["playlist_id"], + albumId: item["album_id"], + musicId: item["music_id"], + )) .toList(); } on PlatformException catch (e) { print(e.message); @@ -243,6 +334,7 @@ class FlutterDownloader { static Future resume({ required String taskId, bool requiresStorageNotLow = true, + Map? headers, }) async { assert(_initialized, 'FlutterDownloader.initialize() must be called first'); @@ -250,6 +342,7 @@ class FlutterDownloader { return await _channel.invokeMethod('resume', { 'task_id': taskId, 'requires_storage_not_low': requiresStorageNotLow, + 'headers': toHeaderBuilder(headers).toString(), }); } on PlatformException catch (e) { print(e.message); @@ -272,6 +365,7 @@ class FlutterDownloader { static Future retry({ required String taskId, bool requiresStorageNotLow = true, + Map? headers, }) async { assert(_initialized, 'FlutterDownloader.initialize() must be called first'); @@ -279,6 +373,7 @@ class FlutterDownloader { return await _channel.invokeMethod('retry', { 'task_id': taskId, 'requires_storage_not_low': requiresStorageNotLow, + 'headers': toHeaderBuilder(headers).toString(), }); } on PlatformException catch (e) { print(e.message); @@ -394,10 +489,10 @@ class FlutterDownloader { static registerCallback(DownloadCallback callback) { assert(_initialized, 'FlutterDownloader.initialize() must be called first'); - final callbackHandle = PluginUtilities.getCallbackHandle(callback)!; + final callbackHandle = PluginUtilities.getCallbackHandle(callback); assert(callbackHandle != null, 'callback must be a top-level or a static function'); _channel.invokeMethod( - 'registerCallback', [callbackHandle.toRawHandle()]); + 'registerCallback', [callbackHandle?.toRawHandle()]); } } diff --git a/lib/src/models.dart b/lib/src/models.dart index f651e324..df7a99e6 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -32,6 +32,54 @@ class DownloadTaskStatus { String toString() => 'DownloadTaskStatus($_value)'; } +/// +/// A model class for incomming downloads. +/// +/// * [url]: download link +/// * [savedDir]: absolute path of the directory where downloaded file is saved +/// * [fileName]: name of downloaded file. If this parameter is not set, the +/// plugin will try to extract a file name from HTTP headers response or `url` +/// +class DownloadItem { + DownloadItem({ + required this.url, + required this.savedDir, + required this.fileName, + this.albumName, + this.artistName, + this.artistId, + this.playlistId, + this.albumId, + this.musicId, + }); + + final String url; + final String savedDir; + final String fileName; + final String? albumName; + final String? artistName; + final String? artistId; + final String? playlistId; + final String? albumId; + final String? musicId; + + @override + String toString() => + "DownloadItem(url: $url, savedDir: $savedDir, fileName: $fileName, albumName: $albumName, artistName: $artistName, albumName: $albumName)"; + + Map toMap() => { + "url": url, + "saved_dir": savedDir, + "file_name": fileName, + "music_album": albumName, + "music_artist": artistName, + "artist_id": artistId, + "playlist_id": playlistId, + "album_id": albumId, + "music_id": musicId + }; +} + /// /// A model class encapsulates all task information according to data in Sqlite /// database. @@ -52,17 +100,33 @@ class DownloadTask { final String? filename; final String savedDir; final int timeCreated; + final String? albumName; + final String? artistName; + final String? artistId; + final String? playlistId; + final String? albumId; + final String? musicId; - DownloadTask( - {required this.taskId, - required this.status, - required this.progress, - required this.url, - required this.filename, - required this.savedDir, - required this.timeCreated}); + DownloadTask({ + required this.taskId, + required this.status, + required this.progress, + required this.url, + required this.filename, + required this.savedDir, + required this.timeCreated, + this.albumName, + this.artistName, + this.artistId, + this.playlistId, + this.albumId, + this.musicId, + }); @override String toString() => - "DownloadTask(taskId: $taskId, status: $status, progress: $progress, url: $url, filename: $filename, savedDir: $savedDir, timeCreated: $timeCreated)"; + "DownloadTask(taskId: $taskId, status: $status, progress: $progress, " + "url: $url, filename: $filename, savedDir: $savedDir, timeCreated: $timeCreated, " + "albumName: $albumName, artistName: $artistName, artistId: $artistId, " + "playlistId: $playlistId, albumId: $albumId, musicId: $musicId)"; } diff --git a/pubspec.yaml b/pubspec.yaml index f35cd95e..c09a644a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_downloader description: A plugin for creating and managing download tasks. Supports iOS and Android. -version: 1.7.1 +version: 1.7.2 homepage: https://github.com/fluttercommunity/flutter_downloader maintainer: Hung Duy Ha (@hnvn)