diff --git a/.fvm/flutter_sdk b/.fvm/flutter_sdk new file mode 120000 index 0000000..a334f59 --- /dev/null +++ b/.fvm/flutter_sdk @@ -0,0 +1 @@ +/Users/sebastian/fvm/versions/1.22.5 \ No newline at end of file diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json new file mode 100644 index 0000000..4a41da6 --- /dev/null +++ b/.fvm/fvm_config.json @@ -0,0 +1,3 @@ +{ + "flutterSdkVersion": "1.22.5" +} \ No newline at end of file diff --git a/README.md b/README.md index 1c8d9d8..d8b3eb0 100644 --- a/README.md +++ b/README.md @@ -70,29 +70,33 @@ func registerPlugins(registry: FlutterPluginRegistry) { ### Optional configuration: -- **Configure maximum number of concurrent tasks:** the plugin depends on `WorkManager` library and `WorkManager` depends on the number of available processor to configure the maximum number of tasks running at a moment. You can setup a fixed number for this configuration by adding following codes to your `AndroidManifest.xml`: +#### Configure maximum number of concurrent tasks -```xml - - - - - - - - - +The plugin depends on the `WorkManager` library. The configuration can be done using the instructions at [https://developer.android.com/topic/libraries/architecture/workmanager/advanced/custom-configuration](https://developer.android.com/topic/libraries/architecture/workmanager/advanced/custom-configuration). + +The example project shows a custom configuration of up to 10 simultaneous uploads. + +Two steps are required: + +Depend on the appropriate work-runtime in your host App. +``` gradle +implementation "androidx.work:work-runtime:$work_version" ``` +Override the default `Application` and implement the `androidx.work.Configuration.Provider` interface: + +``` java +@NonNull +@Override +public Configuration getWorkManagerConfiguration() { + return new Configuration.Builder() + .setMinimumLoggingLevel(android.util.Log.INFO) + .setExecutor(Executors.newFixedThreadPool(10)) + .build(); +} +``` + + ## Usage #### Import package: diff --git a/android/build.gradle b/android/build.gradle index 0b9c5f5..6fe959a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -40,6 +40,8 @@ android { } dependencies { + implementation 'com.microsoft.azure.android:azure-storage-android:2.0.0@aar' + implementation "androidx.work:work-runtime:2.4.0" implementation "androidx.concurrent:concurrent-futures:1.1.0" implementation "androidx.annotation:annotation:1.1.0" diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 077a608..bba894e 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,2 +1,5 @@ - + + + diff --git a/android/src/main/java/com/bluechilli/flutteruploader/AzureUploadWorker.java b/android/src/main/java/com/bluechilli/flutteruploader/AzureUploadWorker.java new file mode 100644 index 0000000..d1fefb8 --- /dev/null +++ b/android/src/main/java/com/bluechilli/flutteruploader/AzureUploadWorker.java @@ -0,0 +1,166 @@ +package com.bluechilli.flutteruploader; + +import static com.bluechilli.flutteruploader.UploadWorker.EXTRA_ID; +import static com.bluechilli.flutteruploader.UploadWorker.EXTRA_STATUS; +import static com.bluechilli.flutteruploader.UploadWorker.EXTRA_STATUS_CODE; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.concurrent.futures.CallbackToFutureAdapter; +import androidx.work.Data; +import androidx.work.ListenableWorker; +import androidx.work.WorkerParameters; +import com.google.common.util.concurrent.ListenableFuture; +import com.microsoft.azure.storage.AccessCondition; +import com.microsoft.azure.storage.CloudStorageAccount; +import com.microsoft.azure.storage.OperationContext; +import com.microsoft.azure.storage.RetryNoRetry; +import com.microsoft.azure.storage.blob.BlobRequestOptions; +import com.microsoft.azure.storage.blob.CloudAppendBlob; +import com.microsoft.azure.storage.blob.CloudBlobClient; +import com.microsoft.azure.storage.blob.CloudBlobContainer; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.channels.Channels; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class AzureUploadWorker extends ListenableWorker { + + private static final String TAG = "AzureUploadWorker"; + + /** + * @param appContext The application {@link Context} + * @param workerParams Parameters to setup the internal state of this worker + */ + public AzureUploadWorker(@NonNull Context appContext, @NonNull WorkerParameters workerParams) { + super(appContext, workerParams); + } + + private Executor backgroundExecutor = Executors.newSingleThreadExecutor(); + + @NonNull + @Override + public ListenableFuture startWork() { + FlutterEngineHelper.start(getApplicationContext()); + + return CallbackToFutureAdapter.getFuture( + completer -> { + backgroundExecutor.execute( + () -> { + try { + final Result result = doWorkInternal(); + completer.set(result); + } catch (Throwable e) { + Log.e(TAG, "Error while uploading to Azure", e); + completer.setException(e); + } finally { + // Do not destroy the engine at this very moment. + // Keep it running in the background for just a little while. + // stopEngine(); + } + }); + + return getId().toString(); + }); + } + + private Result doWorkInternal() throws Throwable { + final String connectionString = getInputData().getString("connectionString"); + final String containerName = getInputData().getString("container"); + final boolean createContainer = getInputData().getBoolean("createContainer", false); + final String blobName = getInputData().getString("blobName"); + final String path = getInputData().getString("path"); + final int blockSize = getInputData().getInt("blockSize", 1024 * 1024); + + final SharedPreferences preferences = + getApplicationContext().getSharedPreferences("AzureUploadWorker", Context.MODE_PRIVATE); + final String bytesWrittenKey = "bytesWritten." + getId(); + + // Log.d(TAG, "bytesWrittenKey: " + bytesWrittenKey); + + long bytesWritten = preferences.getInt(bytesWrittenKey, 0); + + // Log.d(TAG, "bytesWritten : " + bytesWritten); + + CloudStorageAccount account = CloudStorageAccount.parse(connectionString); + CloudBlobClient blobClient = account.createCloudBlobClient(); + + final CloudBlobContainer container = blobClient.getContainerReference(containerName); + + final OperationContext opContext = new OperationContext(); + // opContext.setLogLevel(BuildConfig.DEBUG ? Log.VERBOSE : Log.WARN); + opContext.setLogLevel(Log.WARN); + + final BlobRequestOptions options = new BlobRequestOptions(); + options.setRetryPolicyFactory(new RetryNoRetry()); + + if (createContainer) { + container.createIfNotExists(options, opContext); + } + + final CloudAppendBlob appendBlob = container.getAppendBlobReference(blobName); + + if (bytesWritten == 0) { + appendBlob.createOrReplace(AccessCondition.generateEmptyCondition(), options, opContext); + } + + try (final RandomAccessFile file = new RandomAccessFile(path, "r"); + final InputStream is = Channels.newInputStream(file.getChannel())) { + final long contentLength = file.length(); + + Log.d(TAG, "file contentLength: " + contentLength + ", blockSize: " + blockSize); + if (bytesWritten != 0) { + if (is.skip(bytesWritten) != bytesWritten) { + throw new IllegalArgumentException("source file length mismatch?"); + } + } + + while (bytesWritten < contentLength && !isStopped()) { + final long thisBlock = Math.min(contentLength - bytesWritten, blockSize); + + Log.d(TAG, "Appending block at " + bytesWritten + ", thisBlock: " + thisBlock); + + appendBlob.append( + is, thisBlock, AccessCondition.generateEmptyCondition(), options, opContext); + + bytesWritten += thisBlock; + + double p = ((double) (bytesWritten + thisBlock) / (double) contentLength) * 100; + int progress = (int) Math.round(p); + + if (!isStopped()) { + setProgressAsync( + new Data.Builder() + .putInt("status", UploadStatus.RUNNING) + .putInt("progress", progress) + .build()); + } + + preferences.edit().putInt(bytesWrittenKey, (int) bytesWritten).apply(); + } + } catch (FileNotFoundException e) { + Log.e(TAG, "Source path not found: " + path, e); + preferences.edit().remove(bytesWrittenKey).apply(); + return Result.failure(); + } catch (IOException e) { + return Result.retry(); + } catch (Exception e) { + Log.e(TAG, "Unrecoverable exception: " + e); + preferences.edit().remove(bytesWrittenKey).apply(); + return Result.failure(); + } + + final Data.Builder output = + new Data.Builder() + .putString(EXTRA_ID, getId().toString()) + .putInt(EXTRA_STATUS, UploadStatus.COMPLETE) + .putInt(EXTRA_STATUS_CODE, 200); + + return Result.success(output.build()); + } +} diff --git a/android/src/main/java/com/bluechilli/flutteruploader/FlutterEngineHelper.java b/android/src/main/java/com/bluechilli/flutteruploader/FlutterEngineHelper.java new file mode 100644 index 0000000..ab21c2b --- /dev/null +++ b/android/src/main/java/com/bluechilli/flutteruploader/FlutterEngineHelper.java @@ -0,0 +1,43 @@ +package com.bluechilli.flutteruploader; + +import android.content.Context; +import androidx.annotation.Nullable; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.embedding.engine.dart.DartExecutor; +import io.flutter.view.FlutterCallbackInformation; +import io.flutter.view.FlutterMain; + +public class FlutterEngineHelper { + @Nullable private static FlutterEngine engine; + + public static void start(Context context) { + long callbackHandle = SharedPreferenceHelper.getCallbackHandle(context); + + if (callbackHandle != -1L && engine == null) { + engine = new FlutterEngine(context); + FlutterMain.ensureInitializationComplete(context, null); + + FlutterCallbackInformation callbackInfo = + FlutterCallbackInformation.lookupCallbackInformation(callbackHandle); + String dartBundlePath = FlutterMain.findAppBundlePath(); + + engine + .getDartExecutor() + .executeDartCallback( + new DartExecutor.DartCallback(context.getAssets(), dartBundlePath, callbackInfo)); + } + } + + // private void stopEngine() { + // Log.d(TAG, "Destroying worker engine."); + // + // if (engine != null) { + // try { + // engine.destroy(); + // } catch (Throwable e) { + // Log.e(TAG, "Can not destroy engine", e); + // } + // engine = null; + // } + // } +} diff --git a/android/src/main/java/com/bluechilli/flutteruploader/FlutterUploaderInitializer.java b/android/src/main/java/com/bluechilli/flutteruploader/FlutterUploaderInitializer.java deleted file mode 100644 index 730edce..0000000 --- a/android/src/main/java/com/bluechilli/flutteruploader/FlutterUploaderInitializer.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.bluechilli.flutteruploader; - -import android.content.ComponentName; -import android.content.ContentProvider; -import android.content.ContentValues; -import android.content.Context; -import android.content.pm.PackageManager; -import android.content.pm.ProviderInfo; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.util.Log; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.work.Configuration; -import androidx.work.WorkManager; -import java.util.concurrent.Executors; - -public class FlutterUploaderInitializer extends ContentProvider { - - private static final String TAG = "UploaderInitializer"; - private static final int DEFAULT_MAX_CONCURRENT_TASKS = 3; - private static final int DEFAULT_UPLOAD_CONNECTION_TIMEOUT = 3600; - - @Override - public boolean onCreate() { - int maximumConcurrentTask = getMaxConcurrentTaskMetadata(getContext()); - WorkManager.initialize( - getContext(), - new Configuration.Builder() - .setExecutor(Executors.newFixedThreadPool(maximumConcurrentTask)) - .build()); - return true; - } - - @Nullable - @Override - public Cursor query( - @NonNull Uri uri, - @Nullable String[] strings, - @Nullable String s, - @Nullable String[] strings1, - @Nullable String s1) { - return null; - } - - @Nullable - @Override - public String getType(@NonNull Uri uri) { - return null; - } - - @Nullable - @Override - public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) { - return null; - } - - @Override - public int delete(@NonNull Uri uri, @Nullable String s, @Nullable String[] strings) { - return 0; - } - - @Override - public int update( - @NonNull Uri uri, - @Nullable ContentValues contentValues, - @Nullable String s, - @Nullable String[] strings) { - return 0; - } - - private static int getMaxConcurrentTaskMetadata(Context context) { - try { - ProviderInfo pi = - context - .getPackageManager() - .getProviderInfo( - new ComponentName( - context, "com.bluechilli.flutteruploader.FlutterUploaderInitializer"), - PackageManager.GET_META_DATA); - Bundle bundle = pi.metaData; - int max = - bundle.getInt( - "com.bluechilli.flutteruploader.MAX_CONCURRENT_TASKS", DEFAULT_MAX_CONCURRENT_TASKS); - Log.d(TAG, "MAX_CONCURRENT_TASKS = " + max); - return max; - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "Failed to load meta-data, NameNotFound: " + e.getMessage()); - } catch (NullPointerException e) { - Log.e(TAG, "Failed to load meta-data, NullPointer: " + e.getMessage()); - } - return DEFAULT_MAX_CONCURRENT_TASKS; - } - - public static int getConnectionTimeout(Context context) { - try { - ProviderInfo pi = - context - .getPackageManager() - .getProviderInfo( - new ComponentName( - context, "com.bluechilli.flutteruploader.FlutterUploaderInitializer"), - PackageManager.GET_META_DATA); - Bundle bundle = pi.metaData; - int max = - bundle.getInt( - "com.bluechilli.flutteruploader.UPLOAD_CONNECTION_TIMEOUT_IN_SECONDS", - DEFAULT_UPLOAD_CONNECTION_TIMEOUT); - Log.d(TAG, "UPLOAD_CONNECTION_TIMEOUT_IN_SECONDS = " + max); - return max; - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "Failed to load meta-data, NameNotFound: " + e.getMessage()); - } catch (NullPointerException e) { - Log.e(TAG, "Failed to load meta-data, NullPointer: " + e.getMessage()); - } - - return DEFAULT_UPLOAD_CONNECTION_TIMEOUT; - } -} diff --git a/android/src/main/java/com/bluechilli/flutteruploader/FlutterUploaderPlugin.java b/android/src/main/java/com/bluechilli/flutteruploader/FlutterUploaderPlugin.java index c0d0dca..0a42d78 100644 --- a/android/src/main/java/com/bluechilli/flutteruploader/FlutterUploaderPlugin.java +++ b/android/src/main/java/com/bluechilli/flutteruploader/FlutterUploaderPlugin.java @@ -5,6 +5,8 @@ import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.work.WorkInfo; import androidx.work.WorkManager; import com.bluechilli.flutteruploader.plugin.CachingStreamHandler; import com.bluechilli.flutteruploader.plugin.StatusListener; @@ -18,6 +20,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; /** FlutterUploaderPlugin */ @@ -38,6 +41,7 @@ public class FlutterUploaderPlugin implements FlutterPlugin, StatusListener { private EventChannel resultEventChannel; private final CachingStreamHandler> resultStreamHandler = new CachingStreamHandler<>(); + private LiveData> workInfoLiveData; public static void registerWith(Registrar registrar) { final FlutterUploaderPlugin plugin = new FlutterUploaderPlugin(); @@ -60,15 +64,13 @@ public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { } private void startListening(Context context, BinaryMessenger messenger) { - final int timeout = FlutterUploaderInitializer.getConnectionTimeout(context); - channel = new MethodChannel(messenger, CHANNEL_NAME); - methodCallHandler = new MethodCallHandlerImpl(context, timeout, this); + methodCallHandler = new MethodCallHandlerImpl(context, this); uploadObserver = new UploadObserver(this); - WorkManager.getInstance(context) - .getWorkInfosByTagLiveData(FLUTTER_UPLOAD_WORK_TAG) - .observeForever(uploadObserver); + workInfoLiveData = + WorkManager.getInstance(context).getWorkInfosByTagLiveData(FLUTTER_UPLOAD_WORK_TAG); + workInfoLiveData.observeForever(uploadObserver); channel.setMethodCallHandler(methodCallHandler); @@ -84,9 +86,8 @@ private void stopListening(Context context) { channel = null; if (uploadObserver != null) { - WorkManager.getInstance(context) - .getWorkInfosByTagLiveData(FLUTTER_UPLOAD_WORK_TAG) - .removeObserver(uploadObserver); + workInfoLiveData.removeObserver(uploadObserver); + workInfoLiveData = null; uploadObserver = null; } @@ -161,6 +162,15 @@ public void onCompleted( resultStreamHandler.add(id, args); } + @Override + public void onPaused(String id) { + Map args = new HashMap<>(); + args.put("taskId", id); + args.put("status", UploadStatus.PAUSED); + + resultStreamHandler.add(id, args); + } + @Override public void onWorkPruned() { progressStreamHandler.clear(); diff --git a/android/src/main/java/com/bluechilli/flutteruploader/MethodCallHandlerImpl.java b/android/src/main/java/com/bluechilli/flutteruploader/MethodCallHandlerImpl.java index a10bda8..06d9ef8 100644 --- a/android/src/main/java/com/bluechilli/flutteruploader/MethodCallHandlerImpl.java +++ b/android/src/main/java/com/bluechilli/flutteruploader/MethodCallHandlerImpl.java @@ -6,6 +6,7 @@ import androidx.work.BackoffPolicy; import androidx.work.Constraints; import androidx.work.Data; +import androidx.work.Data.Builder; import androidx.work.NetworkType; import androidx.work.OneTimeWorkRequest; import androidx.work.WorkManager; @@ -28,13 +29,13 @@ public class MethodCallHandlerImpl implements MethodCallHandler { + private static final int DEFAULT_CONNECTION_TIMEOUT = 3600; + /** The generic {@link WorkManager} tag which matches any upload. */ public static final String FLUTTER_UPLOAD_WORK_TAG = "flutter_upload_task"; private final Context context; - private final int connectionTimeout; - @NonNull private final StatusListener statusListener; private final Executor workManagerExecutor = Executors.newSingleThreadExecutor(); @@ -42,10 +43,9 @@ public class MethodCallHandlerImpl implements MethodCallHandler { private static final List VALID_HTTP_METHODS = Arrays.asList("POST", "PUT", "PATCH"); - MethodCallHandlerImpl(Context context, int timeout, @NonNull StatusListener listener) { + MethodCallHandlerImpl(Context context, @NonNull StatusListener listener) { mainExecutor = ContextCompat.getMainExecutor(context); this.context = context; - this.connectionTimeout = timeout; this.statusListener = listener; } @@ -61,6 +61,9 @@ public void onMethodCall(MethodCall call, @NonNull Result result) { case "enqueueBinary": enqueueBinary(call, result); break; + case "enqueueAzure": + enqueueAzure(call, result); + break; case "cancel": cancel(call, result); break; @@ -92,6 +95,7 @@ private void enqueue(MethodCall call, MethodChannel.Result result) { Map parameters = call.argument("data"); Map headers = call.argument("headers"); String tag = call.argument("tag"); + Integer connectionTimeout = call.argument("timeout"); if (method == null) { method = "POST"; @@ -107,6 +111,10 @@ private void enqueue(MethodCall call, MethodChannel.Result result) { return; } + if (connectionTimeout == null) { + connectionTimeout = DEFAULT_CONNECTION_TIMEOUT; + } + List items = new ArrayList<>(); for (Map file : files) { @@ -115,7 +123,7 @@ private void enqueue(MethodCall call, MethodChannel.Result result) { WorkRequest request = buildRequest( - new UploadTask(url, method, items, headers, parameters, connectionTimeout, false, tag)); + new UploadTask(url, method, items, headers, parameters, connectionTimeout, tag), false); WorkManager.getInstance(context) .enqueue(request) .getResult() @@ -137,6 +145,7 @@ private void enqueueBinary(MethodCall call, MethodChannel.Result result) { String path = call.argument("path"); Map headers = call.argument("headers"); String tag = call.argument("tag"); + Integer connectionTimeout = call.argument("timeout"); if (method == null) { method = "POST"; @@ -152,6 +161,10 @@ private void enqueueBinary(MethodCall call, MethodChannel.Result result) { return; } + if (connectionTimeout == null) { + connectionTimeout = DEFAULT_CONNECTION_TIMEOUT; + } + WorkRequest request = buildRequest( new UploadTask( @@ -161,8 +174,42 @@ private void enqueueBinary(MethodCall call, MethodChannel.Result result) { headers, Collections.emptyMap(), connectionTimeout, - true, - tag)); + tag), + true); + WorkManager.getInstance(context).enqueue(request); + String taskId = request.getId().toString(); + + result.success(taskId); + statusListener.onUpdateProgress(taskId, UploadStatus.ENQUEUED, 0); + } + + private void enqueueAzure(MethodCall call, MethodChannel.Result result) { + final String path = call.argument("path"); + final String connectionString = call.argument("connectionString"); + final String container = call.argument("container"); + final String blobName = call.argument("blobName"); + final Integer blockSize = call.argument("blockSize"); + + Builder inputBuilder = + new Builder() + .putString("path", path) + .putString("connectionString", connectionString) + .putString("container", container) + .putString("blobName", blobName); + + if (blockSize != null) { + inputBuilder.putInt("blockSize", blockSize); + } + + WorkRequest request = + new OneTimeWorkRequest.Builder(AzureUploadWorker.class) + .setConstraints( + new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setInputData(inputBuilder.build()) + .addTag(FLUTTER_UPLOAD_WORK_TAG) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.SECONDS) + .build(); + WorkManager.getInstance(context) .enqueue(request) .getResult() @@ -202,7 +249,7 @@ private void clearUploads(MethodCall call, MethodChannel.Result result) { workManagerExecutor); } - private WorkRequest buildRequest(UploadTask task) { + private WorkRequest buildRequest(UploadTask task, boolean binaryUpload) { Gson gson = new Gson(); Data.Builder dataBuilder = @@ -210,7 +257,6 @@ private WorkRequest buildRequest(UploadTask task) { .putString(UploadWorker.ARG_URL, task.getURL()) .putString(UploadWorker.ARG_METHOD, task.getMethod()) .putInt(UploadWorker.ARG_REQUEST_TIMEOUT, task.getTimeout()) - .putBoolean(UploadWorker.ARG_BINARY_UPLOAD, task.isBinaryUpload()) .putString(UploadWorker.ARG_UPLOAD_REQUEST_TAG, task.getTag()); List files = task.getFiles(); @@ -228,7 +274,8 @@ private WorkRequest buildRequest(UploadTask task) { dataBuilder.putString(UploadWorker.ARG_DATA, parametersJson); } - return new OneTimeWorkRequest.Builder(UploadWorker.class) + return new OneTimeWorkRequest.Builder( + binaryUpload ? RawUploadWorker.class : MultipartFormDataUploadWorker.class) .setConstraints( new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .addTag(FLUTTER_UPLOAD_WORK_TAG) diff --git a/android/src/main/java/com/bluechilli/flutteruploader/MimeTypeDetector.java b/android/src/main/java/com/bluechilli/flutteruploader/MimeTypeDetector.java new file mode 100644 index 0000000..b901444 --- /dev/null +++ b/android/src/main/java/com/bluechilli/flutteruploader/MimeTypeDetector.java @@ -0,0 +1,22 @@ +package com.bluechilli.flutteruploader; + +import android.util.Log; +import android.webkit.MimeTypeMap; + +public class MimeTypeDetector { + static final String TAG = "MimeTypeDetector"; + + public static String detect(String url) { + String type = "application/octet-stream"; + String extension = MimeTypeMap.getFileExtensionFromUrl(url); + try { + if (extension != null && !extension.isEmpty()) { + type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase()); + } + } catch (Exception ex) { + Log.d(TAG, "getMimeType", ex); + } + + return type; + } +} diff --git a/android/src/main/java/com/bluechilli/flutteruploader/MultipartFormDataUploadWorker.java b/android/src/main/java/com/bluechilli/flutteruploader/MultipartFormDataUploadWorker.java new file mode 100644 index 0000000..b50650f --- /dev/null +++ b/android/src/main/java/com/bluechilli/flutteruploader/MultipartFormDataUploadWorker.java @@ -0,0 +1,92 @@ +package com.bluechilli.flutteruploader; + +import android.content.Context; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.work.WorkerParameters; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import java.io.File; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; + +public class MultipartFormDataUploadWorker extends UploadWorker { + private static final String TAG = "MultipartFormDataUpload"; + + public MultipartFormDataUploadWorker( + @NonNull Context appContext, @NonNull WorkerParameters workerParams) { + super(appContext, workerParams); + } + + @Override + @Nullable + RequestBody buildRequestBody() { + final Gson gson = new Gson(); + + String parametersJson = getInputData().getString(ARG_DATA); + String filesJson = getInputData().getString(ARG_FILES); + + final Type mapStringStringType = new TypeToken>() {}.getType(); + final Type listFileItemType = new TypeToken>() {}.getType(); + + Map parameters = new HashMap<>(); + List files = new ArrayList<>(); + + if (parametersJson != null) { + parameters = gson.fromJson(parametersJson, mapStringStringType); + } + + if (filesJson != null) { + files = gson.fromJson(filesJson, listFileItemType); + } + + MultipartBody.Builder formRequestBuilder = prepareRequest(parameters, null); + int fileExistsCount = 0; + for (FileItem item : files) { + File file = new File(item.getPath()); + + if (file.exists() && file.isFile()) { + fileExistsCount++; + String mimeType = MimeTypeDetector.detect(item.getPath()); + MediaType contentType = MediaType.parse(mimeType); + RequestBody fileBody = RequestBody.create(file, contentType); + formRequestBuilder.addFormDataPart(item.getFieldname(), file.getName(), fileBody); + } else { + Log.w(TAG, "File does not exists -> file:" + item.getPath()); + } + } + + if (fileExistsCount == 0 && parameters.isEmpty()) { + return null; + } + + return formRequestBuilder.build(); + } + + private MultipartBody.Builder prepareRequest(Map parameters, String boundary) { + MultipartBody.Builder requestBodyBuilder = + boundary != null && !boundary.isEmpty() + ? new MultipartBody.Builder(boundary) + : new MultipartBody.Builder(); + + requestBodyBuilder.setType(MultipartBody.FORM); + + if (parameters == null) return requestBodyBuilder; + + for (String key : parameters.keySet()) { + String parameter = parameters.get(key); + if (parameter != null) { + requestBodyBuilder.addFormDataPart(key, parameter); + } + } + + return requestBodyBuilder; + } +} diff --git a/android/src/main/java/com/bluechilli/flutteruploader/RawUploadWorker.java b/android/src/main/java/com/bluechilli/flutteruploader/RawUploadWorker.java new file mode 100644 index 0000000..15f06d0 --- /dev/null +++ b/android/src/main/java/com/bluechilli/flutteruploader/RawUploadWorker.java @@ -0,0 +1,48 @@ +package com.bluechilli.flutteruploader; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.work.WorkerParameters; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import java.io.File; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import okhttp3.MediaType; +import okhttp3.RequestBody; + +public class RawUploadWorker extends UploadWorker { + + public RawUploadWorker(@NonNull Context appContext, @NonNull WorkerParameters workerParams) { + super(appContext, workerParams); + } + + @Nullable + @Override + RequestBody buildRequestBody() { + final Gson gson = new Gson(); + + String filesJson = getInputData().getString(ARG_FILES); + + final Type listFileItemType = new TypeToken>() {}.getType(); + + List files = new ArrayList<>(); + + if (filesJson != null) { + files = gson.fromJson(filesJson, listFileItemType); + } + + final FileItem item = files.get(0); + File file = new File(item.getPath()); + + if (!file.exists()) { + return null; + } + + String mimeType = MimeTypeDetector.detect(item.getPath()); + MediaType contentType = MediaType.parse(mimeType); + return RequestBody.create(file, contentType); + } +} diff --git a/android/src/main/java/com/bluechilli/flutteruploader/UploadTask.java b/android/src/main/java/com/bluechilli/flutteruploader/UploadTask.java index 6164595..b26b6a9 100644 --- a/android/src/main/java/com/bluechilli/flutteruploader/UploadTask.java +++ b/android/src/main/java/com/bluechilli/flutteruploader/UploadTask.java @@ -1,6 +1,5 @@ package com.bluechilli.flutteruploader; -import android.net.Uri; import java.util.List; import java.util.Map; @@ -12,7 +11,6 @@ public class UploadTask { private Map data; private List files; private int requestTimeoutInSeconds; - private boolean binaryUpload; private String tag; public UploadTask( @@ -22,7 +20,6 @@ public UploadTask( Map headers, Map data, int requestTimeoutInSeconds, - boolean binaryUpload, String tag) { this.url = url; this.method = method; @@ -30,7 +27,6 @@ public UploadTask( this.headers = headers; this.data = data; this.requestTimeoutInSeconds = requestTimeoutInSeconds; - this.binaryUpload = binaryUpload; this.tag = tag; } @@ -38,10 +34,6 @@ public String getURL() { return url; } - public Uri getUri() { - return Uri.parse(url); - } - public String getMethod() { return method; } @@ -62,10 +54,6 @@ public int getTimeout() { return requestTimeoutInSeconds; } - public boolean isBinaryUpload() { - return binaryUpload; - } - public String getTag() { return tag; } diff --git a/android/src/main/java/com/bluechilli/flutteruploader/UploadWorker.java b/android/src/main/java/com/bluechilli/flutteruploader/UploadWorker.java index e242999..c541b04 100644 --- a/android/src/main/java/com/bluechilli/flutteruploader/UploadWorker.java +++ b/android/src/main/java/com/bluechilli/flutteruploader/UploadWorker.java @@ -2,7 +2,6 @@ import android.content.Context; import android.util.Log; -import android.webkit.MimeTypeMap; import android.webkit.URLUtil; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -14,10 +13,6 @@ import com.google.gson.Gson; import com.google.gson.JsonIOException; import com.google.gson.reflect.TypeToken; -import io.flutter.embedding.engine.FlutterEngine; -import io.flutter.embedding.engine.dart.DartExecutor; -import io.flutter.view.FlutterCallbackInformation; -import io.flutter.view.FlutterMain; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -33,24 +28,20 @@ import java.util.concurrent.TimeUnit; import okhttp3.Call; import okhttp3.Headers; -import okhttp3.MediaType; -import okhttp3.MultipartBody; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; -public class UploadWorker extends ListenableWorker implements CountProgressListener { +public abstract class UploadWorker extends ListenableWorker implements CountProgressListener { public static final String ARG_URL = "url"; public static final String ARG_METHOD = "method"; public static final String ARG_HEADERS = "headers"; public static final String ARG_DATA = "data"; public static final String ARG_FILES = "files"; public static final String ARG_REQUEST_TIMEOUT = "requestTimeout"; - public static final String ARG_BINARY_UPLOAD = "binaryUpload"; public static final String ARG_UPLOAD_REQUEST_TAG = "tag"; - public static final String ARG_ID = "primaryId"; public static final String EXTRA_STATUS_CODE = "statusCode"; public static final String EXTRA_STATUS = "status"; public static final String EXTRA_ERROR_MESSAGE = "errorMessage"; @@ -68,22 +59,16 @@ public class UploadWorker extends ListenableWorker implements CountProgressListe private Call call; private boolean isCancelled = false; - private Context context; - - public UploadWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { - super(context, workerParams); - - this.context = context; + public UploadWorker(@NonNull Context appContext, @NonNull WorkerParameters workerParams) { + super(appContext, workerParams); } - @Nullable private static FlutterEngine engine; - private Executor backgroundExecutor = Executors.newSingleThreadExecutor(); @NonNull @Override public ListenableFuture startWork() { - startEngine(); + FlutterEngineHelper.start(getApplicationContext()); return CallbackToFutureAdapter.getFuture( completer -> { @@ -106,14 +91,11 @@ public ListenableFuture startWork() { } @NonNull - public Result doWorkInternal() { + private Result doWorkInternal() { String url = getInputData().getString(ARG_URL); String method = getInputData().getString(ARG_METHOD); int timeout = getInputData().getInt(ARG_REQUEST_TIMEOUT, 3600); - boolean isBinaryUpload = getInputData().getBoolean(ARG_BINARY_UPLOAD, false); String headersJson = getInputData().getString(ARG_HEADERS); - String parametersJson = getInputData().getString(ARG_DATA); - String filesJson = getInputData().getString(ARG_FILES); tag = getInputData().getString(ARG_UPLOAD_REQUEST_TAG); if (tag == null) { @@ -124,72 +106,23 @@ public Result doWorkInternal() { try { Map headers = null; - Map parameters = null; - List files = new ArrayList<>(); - Gson gson = new Gson(); - Type type = new TypeToken>() {}.getType(); - Type fileItemType = new TypeToken>() {}.getType(); + final Gson gson = new Gson(); + final Type mapStringStringType = new TypeToken>() {}.getType(); if (headersJson != null) { - headers = gson.fromJson(headersJson, type); - } - - if (parametersJson != null) { - parameters = gson.fromJson(parametersJson, type); - } - - if (filesJson != null) { - files = gson.fromJson(filesJson, fileItemType); + headers = gson.fromJson(headersJson, mapStringStringType); } - final RequestBody innerRequestBody; - - if (isBinaryUpload) { - final FileItem item = files.get(0); - File file = new File(item.getPath()); - - if (!file.exists()) { - return Result.failure( - createOutputErrorData( - UploadStatus.FAILED, - DEFAULT_ERROR_STATUS_CODE, - "invalid_files", - "There are no items to upload", - null)); - } - - String mimeType = GetMimeType(item.getPath()); - MediaType contentType = MediaType.parse(mimeType); - innerRequestBody = RequestBody.create(file, contentType); - } else { - MultipartBody.Builder formRequestBuilder = prepareRequest(parameters, null); - int fileExistsCount = 0; - for (FileItem item : files) { - File file = new File(item.getPath()); - Log.d(TAG, "attaching file: " + item.getPath()); - - if (file.exists() && file.isFile()) { - fileExistsCount++; - String mimeType = GetMimeType(item.getPath()); - MediaType contentType = MediaType.parse(mimeType); - RequestBody fileBody = RequestBody.create(file, contentType); - formRequestBuilder.addFormDataPart(item.getFieldname(), file.getName(), fileBody); - } else { - Log.d(TAG, "File does not exists -> file:" + item.getPath()); - } - } - - if (fileExistsCount <= 0) { - return Result.failure( - createOutputErrorData( - UploadStatus.FAILED, - DEFAULT_ERROR_STATUS_CODE, - "invalid_files", - "There are no items to upload", - null)); - } + final RequestBody innerRequestBody = buildRequestBody(); - innerRequestBody = formRequestBuilder.build(); + if (innerRequestBody == null) { + return Result.failure( + createOutputErrorData( + UploadStatus.FAILED, + DEFAULT_ERROR_STATUS_CODE, + "invalid_parameters", + "There are no items to upload", + null)); } RequestBody requestBody = new CountingRequestBody(innerRequestBody, getId().toString(), this); @@ -300,7 +233,7 @@ public Result doWorkInternal() { "IllegalStateException while building a outputData object. Replace response with on-disk reference."); builder.putString(EXTRA_RESPONSE, null); - File responseFile = writeResponseToTemporaryFile(context, responseString); + File responseFile = writeResponseToTemporaryFile(responseString); if (responseFile != null) { builder.putString(EXTRA_RESPONSE_FILE, responseFile.getAbsolutePath()); } @@ -313,24 +246,28 @@ public Result doWorkInternal() { if (isCancelled) { return Result.failure(); } - return handleException(context, ex, "protocol"); + return handleException(ex, "protocol"); } catch (JsonIOException ex) { - return handleException(context, ex, "json_error"); + return handleException(ex, "json_error"); } catch (UnknownHostException ex) { - return handleException(context, ex, "unknown_host"); + return handleException(ex, "unknown_host"); } catch (IOException ex) { - return handleException(context, ex, "io_error"); + return handleException(ex, "io_error"); } catch (Exception ex) { - return handleException(context, ex, "upload error"); + return handleException(ex, "upload error"); } finally { call = null; } } - private File writeResponseToTemporaryFile(Context context, String body) { + @Nullable + abstract RequestBody buildRequestBody(); + + private File writeResponseToTemporaryFile(String body) { + final File cacheDir = getApplicationContext().getCacheDir(); FileOutputStream fos = null; try { - File tempFile = File.createTempFile("flutter_uploader", null, context.getCacheDir()); + File tempFile = File.createTempFile("flutter_uploader", null, cacheDir); fos = new FileOutputStream(tempFile); fos.write(body.getBytes()); fos.close(); @@ -347,40 +284,7 @@ private File writeResponseToTemporaryFile(Context context, String body) { return null; } - private void startEngine() { - long callbackHandle = SharedPreferenceHelper.getCallbackHandle(context); - - Log.d(TAG, "callbackHandle: " + callbackHandle); - - if (callbackHandle != -1L && engine == null) { - engine = new FlutterEngine(context); - FlutterMain.ensureInitializationComplete(context, null); - - FlutterCallbackInformation callbackInfo = - FlutterCallbackInformation.lookupCallbackInformation(callbackHandle); - String dartBundlePath = FlutterMain.findAppBundlePath(); - - engine - .getDartExecutor() - .executeDartCallback( - new DartExecutor.DartCallback(context.getAssets(), dartBundlePath, callbackInfo)); - } - } - - private void stopEngine() { - Log.d(TAG, "Destroying worker engine."); - - if (engine != null) { - try { - engine.destroy(); - } catch (Throwable e) { - Log.e(TAG, "Can not destroy engine", e); - } - engine = null; - } - } - - private Result handleException(Context context, Exception ex, String code) { + private Result handleException(Exception ex, String code) { Log.e(TAG, "exception encountered", ex); int finalStatus = isCancelled ? UploadStatus.CANCELED : UploadStatus.FAILED; @@ -395,46 +299,7 @@ private Result handleException(Context context, Exception ex, String code) { getStacktraceAsStringList(ex.getStackTrace()))); } - private String GetMimeType(String url) { - String type = "application/octet-stream"; - String extension = MimeTypeMap.getFileExtensionFromUrl(url); - try { - if (extension != null && !extension.isEmpty()) { - String mimeType = - MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase()); - if (mimeType != null && !mimeType.isEmpty()) { - type = mimeType; - } - } - } catch (Exception ex) { - Log.d(TAG, "UploadWorker - GetMimeType", ex); - } - - return type; - } - - private MultipartBody.Builder prepareRequest(Map parameters, String boundary) { - - MultipartBody.Builder requestBodyBuilder = - boundary != null && !boundary.isEmpty() - ? new MultipartBody.Builder(boundary) - : new MultipartBody.Builder(); - - requestBodyBuilder.setType(MultipartBody.FORM); - - if (parameters == null) return requestBodyBuilder; - - for (String key : parameters.keySet()) { - String parameter = parameters.get(key); - if (parameter != null) { - requestBodyBuilder.addFormDataPart(key, parameter); - } - } - - return requestBodyBuilder; - } - - private void sendUpdateProcessEvent(Context context, int status, int progress) { + private void sendUpdateProcessEvent(int status, int progress) { setProgressAsync( new Data.Builder().putInt("status", status).putInt("progress", progress).build()); } @@ -470,7 +335,7 @@ public void OnProgress(String taskId, long bytesWritten, long contentLength) { + ", progress: " + progress); - sendUpdateProcessEvent(context, UploadStatus.RUNNING, progress); + sendUpdateProcessEvent(UploadStatus.RUNNING, progress); } @Override @@ -501,7 +366,7 @@ public void OnError(String taskId, String code, String message) { + code + ", error: " + message); - sendUpdateProcessEvent(context, UploadStatus.FAILED, -1); + sendUpdateProcessEvent(UploadStatus.FAILED, -1); } private String[] getStacktraceAsStringList(StackTraceElement[] stacktrace) { diff --git a/android/src/main/java/com/bluechilli/flutteruploader/plugin/StatusListener.java b/android/src/main/java/com/bluechilli/flutteruploader/plugin/StatusListener.java index 31bef4a..ea7189b 100644 --- a/android/src/main/java/com/bluechilli/flutteruploader/plugin/StatusListener.java +++ b/android/src/main/java/com/bluechilli/flutteruploader/plugin/StatusListener.java @@ -23,5 +23,7 @@ void onCompleted( String response, @Nullable Map headers); + void onPaused(String id); + void onWorkPruned(); } diff --git a/android/src/main/java/com/bluechilli/flutteruploader/plugin/UploadObserver.java b/android/src/main/java/com/bluechilli/flutteruploader/plugin/UploadObserver.java index b93e2df..f3f645b 100644 --- a/android/src/main/java/com/bluechilli/flutteruploader/plugin/UploadObserver.java +++ b/android/src/main/java/com/bluechilli/flutteruploader/plugin/UploadObserver.java @@ -32,22 +32,19 @@ public void onChanged(List workInfoList) { } for (WorkInfo info : workInfoList) { - String id = info.getId().toString(); + final String id = info.getId().toString(); switch (info.getState()) { case ENQUEUED: - { - listener.onEnqueued(info.getId().toString()); - } + listener.onEnqueued(id); + break; case RUNNING: - { - Data progress = info.getProgress(); + Data progress = info.getProgress(); - listener.onUpdateProgress( - info.getId().toString(), - progress.getInt("status", -1), - progress.getInt("progress", -1)); - } + listener.onUpdateProgress( + info.getId().toString(), + progress.getInt("status", UploadStatus.RUNNING), + progress.getInt("progress", -1)); break; case FAILED: { @@ -61,6 +58,9 @@ public void onChanged(List workInfoList) { listener.onFailed(id, failedStatus, statusCode, code, errorMessage, details); } break; + case BLOCKED: + listener.onPaused(id); + break; case CANCELLED: listener.onFailed(id, UploadStatus.CANCELED, 500, "flutter_upload_cancelled", null, null); break; diff --git a/example/.fvm/flutter_sdk b/example/.fvm/flutter_sdk new file mode 120000 index 0000000..a334f59 --- /dev/null +++ b/example/.fvm/flutter_sdk @@ -0,0 +1 @@ +/Users/sebastian/fvm/versions/1.22.5 \ No newline at end of file diff --git a/example/.fvm/fvm_config.json b/example/.fvm/fvm_config.json new file mode 100644 index 0000000..4a41da6 --- /dev/null +++ b/example/.fvm/fvm_config.json @@ -0,0 +1,3 @@ +{ + "flutterSdkVersion": "1.22.5" +} \ No newline at end of file diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 67d554f..06473eb 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -21,6 +21,8 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } +def work_version = "2.4.0" + apply plugin: 'com.android.application' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" @@ -60,6 +62,9 @@ flutter { } dependencies { + // So that the example app can configure logging. + implementation "androidx.work:work-runtime:2.4.0" + testImplementation 'junit:junit:4.13.1' androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index bd91504..b23006d 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,6 @@ xmlns:tools="http://schemas.android.com/tools" package="com.bluechilli.flutteruploaderexample"> - @@ -12,17 +11,17 @@ additional functionality it is fine to subclass or reimplement FlutterApplication and put your custom class here. -->