From 7b1624bbcda126c7b1aff15ab3c24b9a9e4e2987 Mon Sep 17 00:00:00 2001 From: Alex Gotev Date: Sun, 29 Sep 2019 16:26:12 +0200 Subject: [PATCH] 4.0.0-alpha01 (#461) * #450 upgraded gradle plugin to 3.4.2, gradle wrapper to 5.4.1 and replaced dcendents maven plugin with new gradle-maven-plugin from sky-uk. Added kotlin support in the modules and bumped versions for new development cycles. Added script for local snapshot release. Updated RecyclerAdapter. * update travis configuration * update travis configuration * #450 ported OkHttp module to Kotlin and upgraded to OkHttp 4.0.1 * #450 OkHttp BodyWriter is now cloaseable. Relying on Kotlin "use" to safely handle it. Refactored createBody method. * #450 migrated HurlStack and generic http facade to Kotlin * #450 try to make travis happy again * #450 Refactored http package to network. Migrated ServerResponse to Kotlin and moved to network package. Setup kotlin in demo app and added global broadcast receiver. * #450 migrated logger to Kotlin * #450 better name for request body writer delegate method * #450 migrated Scheme Handlers to Kotlin. It's now possible to register custom scheme handlers. Improved exception messages. Started migration of parcelable data classes to Kotlin. * #450 moved all the configurable parameters in UploadServiceConfig. Enhanced debug log messages by printing upload service configuration, android version and processor cores * #450 migrated UploadFile and Placeholders to Kotlin. Refactored scheme handlers names. Dropped ContentType class in favor of a String extension. * #450 do not parcelize utility property bodyString * #450 migrated BroadcastData in Kotlin * #450 migrated UploadInfo to Kotlin. * #450 Moved SchemeHandlers to UploadServiceConfig. Set default logger delegate debug log to info because some devices (e.g. Huawei ALE-L21 with Android API 22) does not display Log.d in LogCat and some vendor specific settings are needed. It's better to not deal with those. * #450 Migrated UploadNotificationAction to Kotlin * #450 fix cancelling uploads on Android API 26+ * #450 migrated UploadTaskParameters to Kotlin and refactored where needed * #450 updated gradle plugin * #450 updated Kotlin to 1.3.50. Used require to make code more readable. * #450 migrated binary upload task to kotlin. Added http URL validator extension for strings * #450 moved notification handling in separate listener. Removed automatic notification channel creation on Android 8+. This reduces UploadTask competences, complexity and code. * #450 renamed package notifier to tasklistener * #450 moved broadcast operations out of UploadTask. This further simplifies UploadTask logic and competences. * #450 file deletion is now a competence of the scheme handler. This also solves a potential bug in FTP upload module and allows file deletion via content resolver (if supported). UploadInfo now contains full UploadFile information instead of the string path. * #450 remove static constants in UploadTask in favor of a ServerResponse factory method * #450 upload task now receives the notification ID as an init parameter * #450 moved throttling logic in separate function * #450 converted UploadTask in Kotlin * #450 default number of retries for each request is now configurable in Retry Policy global configuration. Dropped Upload Delegate implementation. * #450 Removed example empty UploadReceiver. BroadcastReceivers are now called RequestObservers. Log level can now be tuned based on BuildConfig.DEBUG value. Removed OkHttp call timeout. Not suitable for uploads which can run an indefinite amount of time. Started implementation of new kind of broadcast receivers. Added utility inline fun to safely perform the same action on all of the task's observers. * #450 create new uploadInfo for each observer * #450 http uploads now uses UTF-8 by default. removed concept of Intent from UploadTasks. Additional params are now passed directly in UploadTaskParameters. Converted UploadService to Kotlin * #450 refactor starting upload. Reduced code in UploadService. * #450 better log messages when instantiating new task fails * #450 task observers are now passed as parameters in the init method. This in turn allowed to make the task independent from the upload service. * #450 code shrinking * #450 simplified notifying progress by sending only the bytes transferred and let the base class handle the rest * #450 inject http stack in upload tasks. Done some renaming of http stack data types and methods to make the code more fluent to read * #450 calculate sleep deadline only once * #450 make HttpRequest implement the Closeable interface * #450 use better equals * #450 using lambda functions in logger to save performance evaluating messages which are not going to be logged. Started conversion of HttpUploadTask to Kotlin. * #450 http stack requests auto-close on success and on error * #450 converted MultipartUploadTask to Kotlin * #450 code reorder * #450 remove unnecessary annotation in Kotlin * #450 progress listener is now passed to the BodyWriter which manages it internally. Simplified multipart upload task. Simplified notification handler. * #450 Changed the task events lifecycle to be more Rx and LiveData-like * #450 implemented tagging each HTTP connection with the uploadID to have better logging * #450 refactored successfully uploaded files logic. Moved wake lock logic in extensions * #450 removed author comments as they can be read from github, as well as some old docs * #450 refactor sending broadcast * #40 ported UploadNotificationConfig and UploadNotificationStatusConfig in Kotlin. Made necessary refactorings. * #450 migrated all the request builders to Kotlin * #450 made UploadServiceConfig, UploadServiceLogger and UploadService static methods java friendly. Migrated HttpUploadTaskParameters to Kotlin. Added extensions for multipart upload files. * #450 migrated FTPUploadTaskParameters and UnixPermissions to Kotlin. Added Unit Tests for UnixPermissions. * #450 moved content provider file selector in files picker activity. Moved Apache Commons FTP Code in FTPClientWrapper. Ported FTPUploadTask to Kotlin. Newer macOS doesn't have FTP server anymore, so setup a simple script to use vsftpd inside docker. --- .idea/gradle.xml | 15 - .idea/runConfigurations.xml | 12 - .travis.yml | 5 + build.gradle | 2 +- examples/app/.idea/gradle.xml | 12 +- examples/app/build.gradle | 2 +- examples/app/demoapp/build.gradle | 6 +- .../app/demoapp/src/main/AndroidManifest.xml | 11 +- .../java/net/gotev/uploadservicedemo/App.java | 36 +- .../gotev/uploadservicedemo/BaseActivity.java | 41 +- .../BinaryUploadActivity.java | 11 +- .../uploadservicedemo/FTPUploadActivity.java | 18 +- .../GlobalBroadcastReceiver.kt | 43 ++ .../gotev/uploadservicedemo/MainActivity.java | 2 +- .../MultipartUploadActivity.java | 17 +- .../uploadservicedemo/UploadActivity.java | 15 +- .../adapteritems/EmptyItem.java | 10 +- .../adapteritems/UploadItem.java | 8 +- .../events/NotificationActions.java | 2 + .../events/NotificationActionsReceiver.java | 2 +- .../events/UploadReceiver.java | 36 -- .../uploadservicedemo/issues/Issue226.java | 32 +- .../uploadservicedemo/issues/Issue245.java | 2 +- .../uploadservicedemo/issues/Issue251.java | 12 +- .../utils/FilesPickerActivity.java | 57 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- generate-ftp-javadoc | 13 - generate-javadoc | 13 - gradle/wrapper/gradle-wrapper.properties | 2 +- local-release | 5 + manifest.gradle | 34 +- release | 7 +- uploadservice-ftp/build.gradle | 112 +--- .../uploadservice/ftp/FTPClientWrapper.kt | 281 ++++++++ .../uploadservice/ftp/FTPUploadRequest.java | 281 -------- .../uploadservice/ftp/FTPUploadRequest.kt | 182 ++++++ .../uploadservice/ftp/FTPUploadTask.java | 308 --------- .../gotev/uploadservice/ftp/FTPUploadTask.kt | 97 +++ .../ftp/FTPUploadTaskParameters.java | 90 --- .../ftp/FTPUploadTaskParameters.kt | 41 ++ .../uploadservice/ftp/UnixPermissions.java | 183 ------ .../uploadservice/ftp/UnixPermissions.kt | 63 ++ .../uploadservice/ftp/UploadFileExtensions.kt | 47 ++ .../uploadservice/ftp/TestRolePermissions.kt | 205 ++++++ .../test-server/linux/Vagrantfile | 119 ---- uploadservice-ftp/test-server/osx/start | 6 - uploadservice-ftp/test-server/osx/stop | 3 - uploadservice-ftp/test-server/vsftpd-osx | 13 + uploadservice-okhttp/build.gradle | 109 +--- .../okhttp/OkHttpBodyWriter.java | 35 - .../uploadservice/okhttp/OkHttpBodyWriter.kt | 31 + .../uploadservice/okhttp/OkHttpExtensions.kt | 23 + .../uploadservice/okhttp/OkHttpStack.java | 39 -- .../gotev/uploadservice/okhttp/OkHttpStack.kt | 17 + .../okhttp/OkHttpStackConnection.java | 136 ---- .../okhttp/OkHttpStackRequest.kt | 91 +++ uploadservice/build.gradle | 113 +--- .../uploadservice/BinaryUploadRequest.java | 101 --- .../gotev/uploadservice/BinaryUploadTask.java | 30 - .../gotev/uploadservice/BroadcastData.java | 113 ---- .../net/gotev/uploadservice/ContentType.java | 198 ------ .../uploadservice/DefaultLoggerDelegate.java | 33 - .../uploadservice/HttpUploadRequest.java | 157 ----- .../gotev/uploadservice/HttpUploadRequest.kt | 141 ++++ .../gotev/uploadservice/HttpUploadTask.java | 112 ---- .../net/gotev/uploadservice/HttpUploadTask.kt | 88 +++ .../HttpUploadTaskParameters.java | 85 --- .../java/net/gotev/uploadservice/Logger.java | 87 --- .../uploadservice/MultipartUploadRequest.java | 167 ----- .../uploadservice/MultipartUploadTask.java | 156 ----- .../net/gotev/uploadservice/NameValue.java | 96 --- .../net/gotev/uploadservice/Placeholders.java | 57 -- .../net/gotev/uploadservice/Placeholders.kt | 51 ++ .../gotev/uploadservice/ServerResponse.java | 116 ---- .../net/gotev/uploadservice/UploadFile.java | 186 ------ .../net/gotev/uploadservice/UploadInfo.java | 253 -------- .../UploadNotificationAction.java | 112 ---- .../UploadNotificationConfig.java | 251 -------- .../UploadNotificationStatusConfig.java | 128 ---- .../gotev/uploadservice/UploadRequest.java | 161 ----- .../net/gotev/uploadservice/UploadRequest.kt | 126 ++++ .../gotev/uploadservice/UploadService.java | 457 ------------- .../net/gotev/uploadservice/UploadService.kt | 287 +++++++++ .../UploadServiceBroadcastReceiver.java | 118 ---- .../uploadservice/UploadServiceConfig.kt | 183 ++++++ .../UploadServiceSingleBroadcastReceiver.java | 57 -- .../uploadservice/UploadStatusDelegate.java | 50 -- .../net/gotev/uploadservice/UploadTask.java | 607 ------------------ .../net/gotev/uploadservice/UploadTask.kt | 272 ++++++++ .../uploadservice/UploadTaskParameters.java | 82 --- .../gotev/uploadservice/data/BroadcastData.kt | 28 + .../data/HttpUploadTaskParameters.kt | 17 + .../net/gotev/uploadservice/data/NameValue.kt | 16 + .../uploadservice/data/RetryPolicyConfig.kt | 35 + .../gotev/uploadservice/data/UploadFile.kt | 29 + .../gotev/uploadservice/data/UploadInfo.kt | 119 ++++ .../data/UploadNotificationAction.kt | 22 + .../data/UploadNotificationConfig.kt | 118 ++++ .../data/UploadNotificationStatusConfig.kt | 78 +++ .../gotev/uploadservice/data/UploadStatus.kt | 8 + .../data/UploadTaskParameters.kt | 15 + .../uploadservice/exceptions/Exceptions.kt | 6 + .../extensions/CollectionsExtensions.kt | 15 + .../extensions/ContextExtensions.kt | 28 + .../extensions/StringExtensions.kt | 62 ++ .../extensions/WakeLockExtensions.kt | 21 + .../gotev/uploadservice/http/BodyWriter.java | 82 --- .../uploadservice/http/HttpConnection.java | 62 -- .../gotev/uploadservice/http/HttpStack.java | 19 - .../http/impl/HurlBodyWriter.java | 34 - .../uploadservice/http/impl/HurlStack.java | 42 -- .../http/impl/HurlStackConnection.java | 173 ----- .../logger/DefaultLoggerDelegate.kt | 22 + .../logger/UploadServiceLogger.kt | 59 ++ .../gotev/uploadservice/network/BodyWriter.kt | 86 +++ .../uploadservice/network/HttpRequest.kt | 54 ++ .../gotev/uploadservice/network/HttpStack.kt | 16 + .../uploadservice/network/ServerResponse.kt | 57 ++ .../network/hurl/HurlBodyWriter.kt | 28 + .../uploadservice/network/hurl/HurlStack.kt | 19 + .../network/hurl/HurlStackRequest.kt | 134 ++++ .../observer/request/RequestObserver.kt | 103 +++ .../observer/request/SingleRequestObserver.kt | 17 + .../observer/task/BroadcastEmitter.kt | 32 + .../observer/task/NotificationHandler.kt | 116 ++++ .../observer/task/TaskCompletionNotifier.kt | 23 + .../observer/task/UploadTaskObserver.kt | 12 + .../protocols/binary/BinaryUploadRequest.kt | 62 ++ .../protocols/binary/BinaryUploadTask.kt | 18 + .../multipart/MultipartUploadRequest.kt | 61 ++ .../multipart/MultipartUploadTask.kt | 85 +++ .../multipart/UploadFileExtensions.kt | 22 + .../ContentResolverSchemeHandler.kt | 62 ++ .../schemehandlers/ContentSchemeHandler.java | 83 --- .../schemehandlers/FileSchemeHandler.java | 44 -- .../schemehandlers/FileSchemeHandler.kt | 32 + .../schemehandlers/SchemeHandler.java | 20 - .../schemehandlers/SchemeHandler.kt | 36 ++ .../schemehandlers/SchemeHandlerFactory.java | 51 -- 139 files changed, 4357 insertions(+), 6254 deletions(-) delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/runConfigurations.xml create mode 100644 examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/GlobalBroadcastReceiver.kt delete mode 100644 examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/UploadReceiver.java delete mode 100755 generate-ftp-javadoc delete mode 100755 generate-javadoc create mode 100755 local-release create mode 100644 uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPClientWrapper.kt delete mode 100644 uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadRequest.java create mode 100644 uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadRequest.kt delete mode 100644 uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTask.java create mode 100644 uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTask.kt delete mode 100644 uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTaskParameters.java create mode 100644 uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTaskParameters.kt delete mode 100644 uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UnixPermissions.java create mode 100644 uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UnixPermissions.kt create mode 100644 uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UploadFileExtensions.kt create mode 100644 uploadservice-ftp/src/test/java/net/gotev/uploadservice/ftp/TestRolePermissions.kt delete mode 100644 uploadservice-ftp/test-server/linux/Vagrantfile delete mode 100755 uploadservice-ftp/test-server/osx/start delete mode 100755 uploadservice-ftp/test-server/osx/stop create mode 100755 uploadservice-ftp/test-server/vsftpd-osx delete mode 100644 uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpBodyWriter.java create mode 100644 uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpBodyWriter.kt create mode 100644 uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpExtensions.kt delete mode 100644 uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStack.java create mode 100644 uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStack.kt delete mode 100644 uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStackConnection.java create mode 100644 uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStackRequest.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/BinaryUploadRequest.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/BinaryUploadTask.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/BroadcastData.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/ContentType.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/DefaultLoggerDelegate.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadRequest.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadRequest.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTask.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTask.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTaskParameters.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/Logger.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/MultipartUploadRequest.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/MultipartUploadTask.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/NameValue.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/Placeholders.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/Placeholders.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/ServerResponse.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadFile.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadInfo.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationAction.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationConfig.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationStatusConfig.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadRequest.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadRequest.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadService.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadService.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceBroadcastReceiver.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceConfig.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceSingleBroadcastReceiver.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadStatusDelegate.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadTask.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadTask.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/UploadTaskParameters.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/BroadcastData.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/HttpUploadTaskParameters.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/NameValue.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/RetryPolicyConfig.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/UploadFile.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/UploadInfo.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationAction.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationConfig.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationStatusConfig.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/UploadStatus.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/data/UploadTaskParameters.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/exceptions/Exceptions.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/extensions/CollectionsExtensions.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/extensions/ContextExtensions.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/extensions/StringExtensions.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/extensions/WakeLockExtensions.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/http/BodyWriter.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/http/HttpConnection.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/http/HttpStack.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlBodyWriter.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlStack.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlStackConnection.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/logger/DefaultLoggerDelegate.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/logger/UploadServiceLogger.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/network/BodyWriter.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/network/HttpRequest.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/network/HttpStack.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/network/ServerResponse.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlBodyWriter.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlStack.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlStackRequest.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/observer/request/RequestObserver.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/observer/request/SingleRequestObserver.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/observer/task/BroadcastEmitter.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/observer/task/NotificationHandler.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/observer/task/TaskCompletionNotifier.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/observer/task/UploadTaskObserver.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/protocols/binary/BinaryUploadRequest.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/protocols/binary/BinaryUploadTask.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/MultipartUploadRequest.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/MultipartUploadTask.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/UploadFileExtensions.kt create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/ContentResolverSchemeHandler.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/ContentSchemeHandler.java delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/FileSchemeHandler.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/FileSchemeHandler.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandler.java create mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandler.kt delete mode 100644 uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandlerFactory.java diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 2996d531..00000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 7f68460d..00000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index fe9be0bf..dc2da78b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,12 @@ android: - tools - platform-tools - build-tools-28.0.3 + - build-tools-29.0.2 - android-28 + - android-29 - extra-google-m2repository - extra-android-m2repository +env: + global: + - API=29 # Android API default diff --git a/build.gradle b/build.gradle index 1565d587..232fe121 100644 --- a/build.gradle +++ b/build.gradle @@ -10,8 +10,8 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:$gradle_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "com.github.ben-manes:gradle-versions-plugin:$gradle_versions_plugin_version" - classpath "com.github.dcendents:android-maven-gradle-plugin:$maven_gradle_version" classpath "com.jfrog.bintray.gradle:gradle-bintray-plugin:$bintray_plugin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/examples/app/.idea/gradle.xml b/examples/app/.idea/gradle.xml index 2996d531..bb71d8b3 100644 --- a/examples/app/.idea/gradle.xml +++ b/examples/app/.idea/gradle.xml @@ -3,11 +3,17 @@ diff --git a/examples/app/build.gradle b/examples/app/build.gradle index 806e76b2..bae03228 100644 --- a/examples/app/build.gradle +++ b/examples/app/build.gradle @@ -12,10 +12,10 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:$gradle_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" //classpath dependencies to import library project classpath "com.github.ben-manes:gradle-versions-plugin:$gradle_versions_plugin_version" - classpath "com.github.dcendents:android-maven-gradle-plugin:$maven_gradle_version" classpath "com.jfrog.bintray.gradle:gradle-bintray-plugin:$bintray_plugin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/examples/app/demoapp/build.gradle b/examples/app/demoapp/build.gradle index 05f7d628..07c01c0b 100644 --- a/examples/app/demoapp/build.gradle +++ b/examples/app/demoapp/build.gradle @@ -1,4 +1,6 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' android { compileSdkVersion target_sdk @@ -52,6 +54,8 @@ dependencies { implementation "androidx.appcompat:appcompat:$androidx_appcompat_version" implementation 'com.google.android.material:material:1.0.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + // Debugging implementation "com.facebook.stetho:stetho:${stethoVersion}" implementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}" @@ -62,7 +66,7 @@ dependencies { annotationProcessor "com.jakewharton:butterknife-compiler:${butterKnifeVersion}" implementation 'com.nononsenseapps:filepicker:4.0.0-beta1' - implementation 'net.gotev:recycleradapter:2.4.0' + implementation 'net.gotev:recycleradapter:2.8.1' /*implementation "net.gotev:uploadservice:${libraryVersion}" implementation "net.gotev:uploadservice-okhttp:${libraryVersion}" diff --git a/examples/app/demoapp/src/main/AndroidManifest.xml b/examples/app/demoapp/src/main/AndroidManifest.xml index d4b0ef00..a69f9532 100644 --- a/examples/app/demoapp/src/main/AndroidManifest.xml +++ b/examples/app/demoapp/src/main/AndroidManifest.xml @@ -2,15 +2,14 @@ - - + android:theme="@style/AppTheme" + android:usesCleartextTraffic="true"> - - - - - - diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/App.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/App.java index 0c8a5f82..da9f5a32 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/App.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/App.java @@ -1,14 +1,19 @@ package net.gotev.uploadservicedemo; import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; import android.os.StrictMode; import android.util.Log; import com.facebook.stetho.Stetho; import com.facebook.stetho.okhttp3.StethoInterceptor; -import net.gotev.uploadservice.Logger; -import net.gotev.uploadservice.UploadService; +import net.gotev.uploadservice.UploadServiceConfig; +import net.gotev.uploadservice.data.RetryPolicyConfig; +import net.gotev.uploadservice.logger.UploadServiceLogger; import net.gotev.uploadservice.okhttp.OkHttpStack; import java.io.IOException; @@ -27,6 +32,8 @@ */ public class App extends Application { + public static String CHANNEL = "UploadServiceDemoChannel"; + @Override public void onCreate() { super.onCreate(); @@ -43,17 +50,30 @@ public void onCreate() { // Set your application namespace to avoid conflicts with other apps // using this library - UploadService.NAMESPACE = BuildConfig.APPLICATION_ID; - - // Set upload service debug log messages level - Logger.setLogLevel(Logger.LogLevel.DEBUG); + UploadServiceConfig.setNamespace(BuildConfig.APPLICATION_ID); // Set up the Http Stack to use. If you omit this or comment it, HurlStack will be // used by default - UploadService.HTTP_STACK = new OkHttpStack(getOkHttpClient()); + UploadServiceConfig.setHttpStack(new OkHttpStack(getOkHttpClient())); // setup backoff multiplier - UploadService.BACKOFF_MULTIPLIER = 2; + UploadServiceConfig.setRetryPolicy(new RetryPolicyConfig(1, 10, 2, 3)); + + // Set upload service debug log messages level + UploadServiceLogger.setDevelopmentMode(BuildConfig.DEBUG); + + createNotificationChannel(); + + GlobalBroadcastReceiver receiver = new GlobalBroadcastReceiver(); + receiver.register(this); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= 26) { + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + NotificationChannel channel = new NotificationChannel(CHANNEL, "Upload Service Demo", NotificationManager.IMPORTANCE_LOW); + notificationManager.createNotificationChannel(channel); + } } private OkHttpClient getOkHttpClient() { diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/BaseActivity.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/BaseActivity.java index 7ebe8f6b..d782916b 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/BaseActivity.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/BaseActivity.java @@ -5,14 +5,15 @@ import android.content.Intent; import android.graphics.Color; import android.net.Uri; +import android.view.View; +import android.view.inputmethod.InputMethodManager; + import androidx.annotation.LayoutRes; import androidx.annotation.StringRes; import androidx.appcompat.app.AppCompatActivity; -import android.view.View; -import android.view.inputmethod.InputMethodManager; -import net.gotev.uploadservice.UploadNotificationAction; -import net.gotev.uploadservice.UploadNotificationConfig; +import net.gotev.uploadservice.data.UploadNotificationConfig; +import net.gotev.uploadservice.data.UploadNotificationAction; import net.gotev.uploadservicedemo.events.NotificationActions; import butterknife.ButterKnife; @@ -42,35 +43,35 @@ protected void onPause() { } protected UploadNotificationConfig getNotificationConfig(final String uploadId, @StringRes int title) { - UploadNotificationConfig config = new UploadNotificationConfig(); + UploadNotificationConfig config = new UploadNotificationConfig(App.CHANNEL); PendingIntent clickIntent = PendingIntent.getActivity( this, 1, new Intent(this, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT); config.setTitleForAllStatuses(getString(title)) - .setRingToneEnabled(true) .setClickIntentForAllStatuses(clickIntent) - .setClearOnActionForAllStatuses(true); + .setClearOnActionForAllStatuses(true) + .setRingToneEnabled(true); - config.getProgress().message = getString(R.string.uploading); - config.getProgress().iconResourceID = R.drawable.ic_upload; - config.getProgress().iconColorResourceID = Color.BLUE; - config.getProgress().actions.add(new UploadNotificationAction( + config.getProgress().setMessage(getString(R.string.uploading)); + config.getProgress().setIconResourceID(R.drawable.ic_upload); + config.getProgress().setIconColorResourceID(Color.BLUE); + config.getProgress().getActions().add(new UploadNotificationAction( R.drawable.ic_cancelled, getString(R.string.cancel_upload), NotificationActions.getCancelUploadAction(this, 1, uploadId))); - config.getCompleted().message = getString(R.string.upload_success); - config.getCompleted().iconResourceID = R.drawable.ic_upload_success; - config.getCompleted().iconColorResourceID = Color.GREEN; + config.getCompleted().setMessage(getString(R.string.upload_success)); + config.getCompleted().setIconResourceID(R.drawable.ic_upload_success); + config.getCompleted().setIconColorResourceID(Color.GREEN); - config.getError().message = getString(R.string.upload_error); - config.getError().iconResourceID = R.drawable.ic_upload_error; - config.getError().iconColorResourceID = Color.RED; + config.getError().setMessage(getString(R.string.upload_error)); + config.getError().setIconResourceID(R.drawable.ic_upload_error); + config.getError().setIconColorResourceID(Color.RED); - config.getCancelled().message = getString(R.string.upload_cancelled); - config.getCancelled().iconResourceID = R.drawable.ic_cancelled; - config.getCancelled().iconColorResourceID = Color.YELLOW; + config.getCancelled().setMessage(getString(R.string.upload_cancelled)); + config.getCancelled().setIconResourceID(R.drawable.ic_cancelled); + config.getCancelled().setIconColorResourceID(Color.YELLOW); return config; } diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/BinaryUploadActivity.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/BinaryUploadActivity.java index 189cf413..95a45648 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/BinaryUploadActivity.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/BinaryUploadActivity.java @@ -2,12 +2,13 @@ import android.content.Intent; import android.os.Bundle; -import androidx.annotation.Nullable; import android.view.View; import android.widget.Toast; +import androidx.annotation.Nullable; + import net.gotev.recycleradapter.AdapterItem; -import net.gotev.uploadservice.BinaryUploadRequest; +import net.gotev.uploadservice.protocols.binary.BinaryUploadRequest; import net.gotev.uploadservicedemo.adapteritems.EmptyItem; import net.gotev.uploadservicedemo.adapteritems.UploadItem; import net.gotev.uploadservicedemo.utils.UploadItemUtils; @@ -41,7 +42,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { @Override public void onAddFile() { fileParameterName = "file"; - openFilePicker(false); + //openFilePicker(false); + performFileSearch(); } @Override @@ -50,7 +52,8 @@ public void onDone(String httpMethod, String serverUrl, UploadItemUtils uploadIt try { final String uploadId = UUID.randomUUID().toString(); - final BinaryUploadRequest request = new BinaryUploadRequest(this, uploadId, serverUrl) + final BinaryUploadRequest request = new BinaryUploadRequest(this, serverUrl) + .setUploadID(uploadId) .setMethod(httpMethod) .setNotificationConfig(getNotificationConfig(uploadId, R.string.binary_upload)) //.setCustomUserAgent(getUserAgent()) diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/FTPUploadActivity.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/FTPUploadActivity.java index dcf33524..75049bf0 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/FTPUploadActivity.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/FTPUploadActivity.java @@ -7,9 +7,13 @@ import android.widget.EditText; import android.widget.Toast; +import androidx.annotation.Nullable; +import androidx.core.app.NavUtils; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + import net.gotev.recycleradapter.RecyclerAdapter; import net.gotev.uploadservice.ftp.FTPUploadRequest; -import net.gotev.uploadservice.ftp.UnixPermissions; import net.gotev.uploadservicedemo.adapteritems.EmptyItem; import net.gotev.uploadservicedemo.adapteritems.UploadItem; import net.gotev.uploadservicedemo.dialogs.AddFileParameterNameDialog; @@ -21,10 +25,6 @@ import java.util.List; import java.util.UUID; -import androidx.annotation.Nullable; -import androidx.core.app.NavUtils; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; import butterknife.BindView; import butterknife.OnClick; @@ -83,7 +83,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { @Override public void onValue(String value) { remotePath = value; - openFilePicker(false); + //openFilePicker(false); + performFileSearch(); } }); } @@ -154,11 +155,12 @@ public void onDone() { try { final String uploadId = UUID.randomUUID().toString(); - final FTPUploadRequest request = new FTPUploadRequest(this, uploadId, serverUrl.getText().toString(), ftpPort) + final FTPUploadRequest request = new FTPUploadRequest(this, serverUrl.getText().toString(), ftpPort) + .setUploadID(uploadId) .setMaxRetries(UploadActivity.MAX_RETRIES) .setNotificationConfig(getNotificationConfig(uploadId, R.string.ftp_upload)) .setUsernameAndPassword(ftpUsername.getText().toString(), ftpPassword.getText().toString()) - .setCreatedDirectoriesPermissions(new UnixPermissions("777")) + //.setCreatedDirectoriesPermissions(new UnixPermissions("777")) .setSocketTimeout(5000) .setConnectTimeout(5000); diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/GlobalBroadcastReceiver.kt b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/GlobalBroadcastReceiver.kt new file mode 100644 index 00000000..ceb1baa2 --- /dev/null +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/GlobalBroadcastReceiver.kt @@ -0,0 +1,43 @@ +package net.gotev.uploadservicedemo + +import android.content.Context +import android.util.Log +import net.gotev.uploadservice.data.UploadInfo +import net.gotev.uploadservice.exceptions.UploadError +import net.gotev.uploadservice.exceptions.UserCancelledUploadException +import net.gotev.uploadservice.network.ServerResponse +import net.gotev.uploadservice.observer.request.RequestObserver + +/** + * @author Aleksandar Gotev + */ +class GlobalBroadcastReceiver : RequestObserver() { + override fun onProgress(context: Context, uploadInfo: UploadInfo) { + Log.e("RECEIVER", "Progress: $uploadInfo") + } + + override fun onSuccess(context: Context, uploadInfo: UploadInfo, serverResponse: ServerResponse) { + Log.e("RECEIVER", "Success: $serverResponse") + } + + override fun onError(context: Context, uploadInfo: UploadInfo, exception: Throwable) { + when(exception) { + is UserCancelledUploadException -> { + Log.e("RECEIVER", "Error, user cancelled upload: $uploadInfo") + } + + is UploadError -> { + Log.e("RECEIVER", "Error, upload error: ${exception.serverResponse}") + } + + else -> { + Log.e("RECEIVER", "Error: $uploadInfo", exception) + + } + } + } + + override fun onCompleted(context: Context, uploadInfo: UploadInfo) { + Log.e("RECEIVER", "Completed: $uploadInfo") + } +} diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/MainActivity.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/MainActivity.java index 17852752..ac97e636 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/MainActivity.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/MainActivity.java @@ -39,7 +39,7 @@ public void onFTPUpload(View view) { @OnClick(R.id.cancelAllUploadsButton) public void onCancelAllUploadsButtonClick() { - UploadService.stopAllUploads(); + UploadService.Companion.stopAllUploads(); } @OnClick(R.id.run_issue) diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/MultipartUploadActivity.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/MultipartUploadActivity.java index 69d799ff..631bb97b 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/MultipartUploadActivity.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/MultipartUploadActivity.java @@ -4,7 +4,7 @@ import android.widget.Toast; import net.gotev.recycleradapter.AdapterItem; -import net.gotev.uploadservice.MultipartUploadRequest; +import net.gotev.uploadservice.protocols.multipart.MultipartUploadRequest; import net.gotev.uploadservicedemo.adapteritems.EmptyItem; import net.gotev.uploadservicedemo.adapteritems.UploadItem; import net.gotev.uploadservicedemo.utils.UploadItemUtils; @@ -33,13 +33,14 @@ public void onDone(String httpMethod, String serverUrl, UploadItemUtils uploadIt final String uploadId = UUID.randomUUID().toString(); final MultipartUploadRequest request = - new MultipartUploadRequest(this, uploadId, serverUrl) - .setMethod(httpMethod) - .setUtf8Charset() - .setNotificationConfig(getNotificationConfig(uploadId, R.string.multipart_upload)) - .setMaxRetries(MAX_RETRIES) - //.setCustomUserAgent(getUserAgent()) - .setUsesFixedLengthStreamingMode(FIXED_LENGTH_STREAMING_MODE); + new MultipartUploadRequest(this, serverUrl) + .setUploadID(uploadId) + .setMethod(httpMethod) + .setNotificationConfig(getNotificationConfig(uploadId, R.string.multipart_upload)) + .setMaxRetries(MAX_RETRIES) + //.setAutoDeleteFilesAfterSuccessfulUpload(true) + //.setCustomUserAgent(getUserAgent()) + .setUsesFixedLengthStreamingMode(FIXED_LENGTH_STREAMING_MODE); uploadItemUtils.forEach(new UploadItemUtils.ForEachDelegate() { diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/UploadActivity.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/UploadActivity.java index cd841017..c24c70a3 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/UploadActivity.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/UploadActivity.java @@ -7,6 +7,11 @@ import android.widget.EditText; import android.widget.Spinner; +import androidx.annotation.Nullable; +import androidx.core.app.NavUtils; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + import net.gotev.recycleradapter.AdapterItem; import net.gotev.recycleradapter.RecyclerAdapter; import net.gotev.uploadservicedemo.dialogs.AddFileParameterNameDialog; @@ -17,10 +22,6 @@ import java.util.List; -import androidx.annotation.Nullable; -import androidx.core.app.NavUtils; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; import butterknife.BindView; import butterknife.OnClick; @@ -30,7 +31,7 @@ public abstract class UploadActivity extends FilesPickerActivity { - public static final int MAX_RETRIES = 3; + public static final int MAX_RETRIES = 4; public static final boolean FIXED_LENGTH_STREAMING_MODE = true; @BindView(R.id.http_method) @@ -101,7 +102,8 @@ public void onNew(String name, String value) { @Override public void onValue(String value) { fileParameterName = value; - openFilePicker(false); + //openFilePicker(false); // Bundled file picker + performFileSearch(); // System file picker } }); } @@ -184,5 +186,4 @@ public String getUserAgent() { public abstract void onDone(String httpMethod, String serverUrl, UploadItemUtils uploadItemUtils); public abstract void onInfo(); - } diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/adapteritems/EmptyItem.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/adapteritems/EmptyItem.java index 3c03f1a9..21016cbe 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/adapteritems/EmptyItem.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/adapteritems/EmptyItem.java @@ -4,13 +4,13 @@ import android.view.View; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + import net.gotev.recycleradapter.AdapterItem; import net.gotev.uploadservicedemo.R; import net.gotev.uploadservicedemo.views.ButterKnifeViewHolder; -import org.jetbrains.annotations.NotNull; - -import androidx.annotation.StringRes; import butterknife.BindView; /** @@ -26,7 +26,7 @@ public EmptyItem(@StringRes int textResource) { text = textResource; } - @NotNull + @NonNull @Override public String diffingId() { return EmptyItem.class.getName(); @@ -38,7 +38,7 @@ public int getLayoutId() { } @Override - public void bind(@NotNull Holder holder) { + public void bind(boolean firstTime, @NonNull Holder holder) { Context ctx = holder.textView.getContext(); holder.textView.setText(ctx.getString(text)); } diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/adapteritems/UploadItem.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/adapteritems/UploadItem.java index dfb49218..91bbce57 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/adapteritems/UploadItem.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/adapteritems/UploadItem.java @@ -4,13 +4,12 @@ import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.NonNull; + import net.gotev.recycleradapter.AdapterItem; import net.gotev.uploadservicedemo.R; import net.gotev.uploadservicedemo.views.ButterKnifeViewHolder; -import org.jetbrains.annotations.NotNull; - -import androidx.annotation.NonNull; import butterknife.BindView; import butterknife.OnClick; @@ -52,7 +51,7 @@ public int getLayoutId() { } @Override - public void bind(@NotNull Holder holder) { + public void bind(boolean firstTime, @NonNull UploadItem.Holder holder) { holder.image.setImageResource(icons[mType]); holder.title.setText(mTitle); holder.subtitle.setText(mSubtitle); @@ -99,7 +98,6 @@ public int compareTo(@NonNull AdapterItem otherItem) { return mTitle.compareTo(other.mTitle); } - @NotNull @Override public String diffingId() { return UploadItem.class.getName() + mTitle + mSubtitle; diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/NotificationActions.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/NotificationActions.java index 3e6ef0e9..5d3cc7e5 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/NotificationActions.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/NotificationActions.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.Intent; +import net.gotev.uploadservice.UploadServiceConfig; import net.gotev.uploadservicedemo.BuildConfig; /** @@ -25,6 +26,7 @@ public static PendingIntent getCancelUploadAction(final Context context, final int requestCode, final String uploadID) { Intent intent = new Intent(INTENT_ACTION); + intent.setPackage(UploadServiceConfig.getNamespace()); intent.putExtra(PARAM_ACTION, ACTION_CANCEL_UPLOAD); intent.putExtra(PARAM_UPLOAD_ID, uploadID); diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/NotificationActionsReceiver.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/NotificationActionsReceiver.java index 35e42a27..64806a76 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/NotificationActionsReceiver.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/NotificationActionsReceiver.java @@ -23,6 +23,6 @@ public void onReceive(Context context, Intent intent) { private void onUserRequestedUploadCancellation(Context context, String uploadId) { Log.e("CANCEL_UPLOAD", "User requested cancellation of upload with ID: " + uploadId); - UploadService.stopUpload(uploadId); + UploadService.Companion.stopUpload(uploadId); } } diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/UploadReceiver.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/UploadReceiver.java deleted file mode 100644 index a6c9c75e..00000000 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/events/UploadReceiver.java +++ /dev/null @@ -1,36 +0,0 @@ -package net.gotev.uploadservicedemo.events; - -import android.content.Context; - -import net.gotev.uploadservice.ServerResponse; -import net.gotev.uploadservice.UploadInfo; -import net.gotev.uploadservice.UploadServiceBroadcastReceiver; - -/** - * This implementation is empty on purpose, just to show how it's possible to intercept - * all the upload events app-wise with a global broadcast receiver registered in the manifest. - * - * @author Aleksandar Gotev - */ - -public class UploadReceiver extends UploadServiceBroadcastReceiver { - @Override - public void onProgress(Context context, UploadInfo uploadInfo) { - super.onProgress(context, uploadInfo); - } - - @Override - public void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, Exception exception) { - super.onError(context, uploadInfo, serverResponse, exception); - } - - @Override - public void onCompleted(Context context, UploadInfo uploadInfo, ServerResponse serverResponse) { - super.onCompleted(context, uploadInfo, serverResponse); - } - - @Override - public void onCancelled(Context context, UploadInfo uploadInfo) { - super.onCancelled(context, uploadInfo); - } -} diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue226.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue226.java index fa1acd1f..b355db40 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue226.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue226.java @@ -4,11 +4,9 @@ import android.os.Handler; import android.util.Log; -import net.gotev.uploadservice.MultipartUploadRequest; -import net.gotev.uploadservice.ServerResponse; -import net.gotev.uploadservice.UploadInfo; -import net.gotev.uploadservice.UploadNotificationConfig; -import net.gotev.uploadservice.UploadStatusDelegate; +import net.gotev.uploadservice.protocols.multipart.MultipartUploadRequest; +import net.gotev.uploadservice.data.UploadNotificationConfig; +import net.gotev.uploadservicedemo.App; /** * https://github.com/gotev/android-upload-service/issues/226 @@ -46,26 +44,27 @@ private void multipleRequests(Context ctx, int requestNumber, int totalRequests) final String endpoint = "http://posttestserver.com/post.php"; final int maxRetries = 2; - final UploadNotificationConfig notificationConfig = new UploadNotificationConfig(); + final UploadNotificationConfig notificationConfig = new UploadNotificationConfig(App.CHANNEL); - notificationConfig.getCancelled().autoClear = false; - notificationConfig.getCompleted().autoClear = true; + notificationConfig.getCancelled().setAutoClear(false); + notificationConfig.getCompleted().setAutoClear(true); try { final String fatherId = "father " + requestNumber + "/" + totalRequests + "-" + Long.toString(System.nanoTime()); - new MultipartUploadRequest(ctx, fatherId, endpoint) + new MultipartUploadRequest(ctx, endpoint) + .setUploadID(fatherId) .setMethod("POST") .setNotificationConfig(notificationConfig.setTitleForAllStatuses(fatherId)) .addParameter("color", "#ffffff") .setMaxRetries(maxRetries) - .setDelegate(new UploadStatusDelegate() { + /*.setDelegate(new UploadStatusDelegate() { @Override public void onProgress(Context context, UploadInfo uploadInfo) { } @Override - public void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, Exception exception) { + public void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, Throwable exception) { } @@ -78,7 +77,7 @@ public void onCompleted(final Context context, UploadInfo uploadInfo, ServerResp public void onCancelled(Context context, UploadInfo uploadInfo) { } - }) + })*/ .startUpload(); } catch (Exception exc) { Log.e(getClass().getSimpleName(), "multipleRequests Error", exc); @@ -92,21 +91,22 @@ private void startChildRequest(final Context context, final int maxRetries) { try { String childId = fatherId + " -> child" + Long.toString(System.nanoTime()); - new MultipartUploadRequest(context, childId, endpoint) + new MultipartUploadRequest(context, endpoint) + .setUploadID(childId) .setMethod("POST") .setNotificationConfig(notificationConfig.setTitleForAllStatuses(childId)) .addParameter("color", "#ffffff") .addParameter("test", "value") .addParameter("new", "parameter") .setMaxRetries(maxRetries) - .setDelegate(new UploadStatusDelegate() { + /*.setDelegate(new UploadStatusDelegate() { @Override public void onProgress(Context context, UploadInfo uploadInfo) { } @Override - public void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, Exception exception) { + public void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, Throwable exception) { } @@ -119,7 +119,7 @@ public void onCompleted(Context context, UploadInfo uploadInfo, ServerResponse s public void onCancelled(Context context, UploadInfo uploadInfo) { Log.e(uploadInfo.getUploadId(), "Cancelled"); } - }) + })*/ .startUpload(); } catch (Exception exc) { Log.e(getClass().getSimpleName(), "second request error", exc); diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue245.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue245.java index 8ccb3dcd..fb4f4665 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue245.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue245.java @@ -3,7 +3,7 @@ import android.content.Context; import android.util.Log; -import net.gotev.uploadservice.MultipartUploadRequest; +import net.gotev.uploadservice.protocols.multipart.MultipartUploadRequest; /** * https://github.com/gotev/android-upload-service/issues/245 diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue251.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue251.java index 37886102..27965091 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue251.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/issues/Issue251.java @@ -3,11 +3,7 @@ import android.content.Context; import android.util.Log; -import net.gotev.uploadservice.MultipartUploadRequest; -import net.gotev.uploadservice.ServerResponse; -import net.gotev.uploadservice.UploadInfo; -import net.gotev.uploadservice.UploadService; -import net.gotev.uploadservice.UploadStatusDelegate; +import net.gotev.uploadservice.protocols.multipart.MultipartUploadRequest; /** * https://github.com/gotev/android-upload-service/issues/251 @@ -28,14 +24,14 @@ public void run() { new MultipartUploadRequest(context, "http://posttestserver.com/post.php") .setMethod("POST") .setNotificationConfig(null) - .setDelegate(new UploadStatusDelegate() { + /*.setDelegate(new UploadStatusDelegate() { @Override public void onProgress(Context context, UploadInfo uploadInfo) { UploadService.stopUpload(uploadInfo.getUploadId()); } @Override - public void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, Exception exception) { + public void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, Throwable exception) { } @Override @@ -47,7 +43,7 @@ public void onCompleted(Context context, UploadInfo uploadInfo, ServerResponse s public void onCancelled(Context context, UploadInfo uploadInfo) { } - }) + })*/ .setAutoDeleteFilesAfterSuccessfulUpload(true) .addParameter("color", "#ffffff") .setMaxRetries(2) diff --git a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/utils/FilesPickerActivity.java b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/utils/FilesPickerActivity.java index 25f1e192..ef7ad934 100644 --- a/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/utils/FilesPickerActivity.java +++ b/examples/app/demoapp/src/main/java/net/gotev/uploadservicedemo/utils/FilesPickerActivity.java @@ -6,11 +6,12 @@ import android.net.Uri; import android.os.Bundle; import android.os.Environment; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.util.Log; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.nononsenseapps.filepicker.FilePickerActivity; import com.nononsenseapps.filepicker.Utils; @@ -28,6 +29,7 @@ public class FilesPickerActivity extends BaseActivity { private static final int FILE_CODE = 1; private static final int PERMISSIONS_REQUEST_CODE = 2; + private static final int READ_REQUEST_CODE = 42; private AndroidPermissions mPermissions; private boolean mEnableMultipleSelections; @@ -80,11 +82,50 @@ private void startIntent() { startActivityForResult(intent, FILE_CODE); } + /** + * Fires an intent to spin up the "file chooser" UI and select an image. + */ + public void performFileSearch() { + + // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's file + // browser. + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + + // Filter to only show results that can be "opened", such as a + // file (as opposed to a list of contacts or timezones) + intent.addCategory(Intent.CATEGORY_OPENABLE); + + // Filter to show only images, using the image MIME data type. + // If one wanted to search for ogg vorbis files, the type would be "audio/ogg". + // To search for all documents available via installed storage providers, + // it would be "*/*". + intent.setType("*/*"); + + startActivityForResult(intent, READ_REQUEST_CODE); + } + @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == FILE_CODE && resultCode == Activity.RESULT_OK) { - if (data.getBooleanExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)) { - ArrayList extraPaths = data.getStringArrayListExtra(FilePickerActivity.EXTRA_PATHS); + public void onActivityResult(int requestCode, int resultCode, + Intent resultData) { + + // The ACTION_OPEN_DOCUMENT intent was sent with the request code + // READ_REQUEST_CODE. If the request code seen here doesn't match, it's the + // response to some other intent, and the code below shouldn't run at all. + + if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + // The document selected by the user won't be returned in the intent. + // Instead, a URI to that document will be contained in the return intent + // provided to this method as a parameter. + // Pull that URI using resultData.getData(). + if (resultData != null) { + Uri uri = resultData.getData(); + List data = new ArrayList<>(1); + data.add(uri.toString()); + onPickedFiles(data); + } + } else if (requestCode == FILE_CODE && resultCode == Activity.RESULT_OK) { + if (resultData.getBooleanExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)) { + ArrayList extraPaths = resultData.getStringArrayListExtra(FilePickerActivity.EXTRA_PATHS); if (extraPaths != null) { ArrayList paths = new ArrayList<>(extraPaths.size()); @@ -97,12 +138,14 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { } } else { - Uri picked = data.getData(); + Uri picked = resultData.getData(); if (picked != null) { onPickedFiles(Collections.singletonList(Utils.getFileForUri(picked).getAbsolutePath())); } } + } else { + super.onActivityResult(requestCode, resultCode, resultData); } } diff --git a/examples/app/gradle/wrapper/gradle-wrapper.properties b/examples/app/gradle/wrapper/gradle-wrapper.properties index b595a166..db6ec453 100644 --- a/examples/app/gradle/wrapper/gradle-wrapper.properties +++ b/examples/app/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-4.10.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/generate-ftp-javadoc b/generate-ftp-javadoc deleted file mode 100755 index 3a702d97..00000000 --- a/generate-ftp-javadoc +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -e -./gradlew :uploadservice-ftp:clean :uploadservice-ftp:javadoc -rm -rf ../javadoc-ftp -mv uploadservice-ftp/build/docs/javadoc/ ../javadoc-ftp -git checkout gh-pages -rm -rf javadoc-ftp -mv ../javadoc-ftp . -cd javadoc-ftp -git add . --force -git commit -m "updated ftp module javadocs" -git push -cd .. -git checkout master diff --git a/generate-javadoc b/generate-javadoc deleted file mode 100755 index f66b8f8e..00000000 --- a/generate-javadoc +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -e -./gradlew :uploadservice:clean :uploadservice:javadoc -rm -rf ../javadoc -mv uploadservice/build/docs/javadoc/ ../ -git checkout gh-pages -rm -rf javadoc -mv ../javadoc . -cd javadoc -git add . --force -git commit -m "updated javadocs" -git push -cd .. -git checkout master diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cdfab82e..0e273dd4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/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-4.10.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/local-release b/local-release new file mode 100755 index 00000000..6ed80cbc --- /dev/null +++ b/local-release @@ -0,0 +1,5 @@ +#!/bin/bash +export LOCAL_MAVEN_URL="file://$(pwd)/releases/" +mkdir -p releases +./gradlew clean build publish -PmavPublishToInternalRepo=true + diff --git a/manifest.gradle b/manifest.gradle index cf97bf99..ed05d99a 100644 --- a/manifest.gradle +++ b/manifest.gradle @@ -1,36 +1,38 @@ ext { + mavRepoInternalUrl = System.getenv('LOCAL_MAVEN_URL') + github_username = 'gotev' github_repository_name = 'android-upload-service' maintainer = 'Aleksandar Gotev' + bintray_project_name = 'android-upload-service' library_description = 'Easily upload files in the background with automatic Android Notification Center progress indication.' library_keywords = ['android', 'upload', 'service', 'library', 'background', 'notification'] library_licenses = ["Apache-2.0"] - library_licenses_url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + library_licenses_url = 'https://www.apache.org/licenses/LICENSE-2.0.txt' library_project_group = 'net.gotev' - library_version = '3.5.2' - version_code = 32 - min_sdk = 18 - target_sdk = 28 + library_version = '4.0.0-alpha01' + version_code = 40 + min_sdk = 21 + target_sdk = 29 demo_app_id = 'net.gotev.uploadservicedemo' // Gradle classpath dependencies versions - kotlin_version = '1.3.11' - gradle_version = '3.3.2' - maven_gradle_version = '2.1' + kotlin_version = '1.3.50' + gradle_version = '3.5.0' gradle_versions_plugin_version = '0.20.0' - bintray_plugin_version = '1.7' + bintray_plugin_version = '1.8.4' // Library and app testing dependencies versions junit_version = '4.12' - androidx_test_core_version = '1.0.0' - androidx_test_runner_version = '1.1.0' - androidx_test_rules_version = '1.1.0' - androidx_test_ext_junit_version = '1.0.0' - androidx_test_ext_truth_version = '1.0.0' - truth_version = '0.42' - androidx_test_espresso_version = '3.1.0' + androidx_test_core_version = '1.2.0' + androidx_test_runner_version = '1.2.0' + androidx_test_rules_version = '1.2.0' + androidx_test_ext_junit_version = '1.1.1' + androidx_test_ext_truth_version = '1.2.0' + truth_version = '0.45' + androidx_test_espresso_version = '3.2.0' // Library and app dependencies versions androidx_appcompat_version = '1.0.2' diff --git a/release b/release index e6d18198..8779a429 100755 --- a/release +++ b/release @@ -1,4 +1,3 @@ -#!/bin/bash -e -./gradlew :uploadservice:clean :uploadservice:bintrayUpload -./gradlew :uploadservice-ftp:clean :uploadservice-ftp:bintrayUpload -./gradlew :uploadservice-okhttp:clean :uploadservice-okhttp:bintrayUpload +#!/bin/bash +./gradlew clean publish bintrayUpload + diff --git a/uploadservice-ftp/build.gradle b/uploadservice-ftp/build.gradle index 0f03ecaf..6fa0a98a 100644 --- a/uploadservice-ftp/build.gradle +++ b/uploadservice-ftp/build.gradle @@ -1,23 +1,40 @@ apply plugin: 'com.android.library' -apply plugin: 'com.github.dcendents.android-maven' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' apply plugin: 'com.jfrog.bintray' apply plugin: 'com.github.ben-manes.versions' -// start - do not modify this if your project is on github -def siteUrl = "https://github.com/${github_username}/${github_repository_name}" -def gitUrl = siteUrl + '.git' -def bugTrackerUrl = siteUrl + '/issues/' -def projectName = "android-upload-service-ftp" -// end - do not modify this if your project is on github +Properties properties = new Properties() +if (project.rootProject.file("local.properties").exists()) { + properties.load(project.rootProject.file('local.properties').newDataInputStream()) +} // start - module specific overrides of default values written in manifest.gradle +def bintray_project_name = "android-upload-service-ftp" def library_description = "FTP Upload implementation for Android Upload Service." def library_keywords = ['android', 'upload', 'service', 'ftp', 'upload'] // end - module specific overrides +// start - do not modify this if your project is on github +project.ext{ + mavDevelopers = [(properties.getProperty("bintray.user")):(maintainer)] + mavSiteUrl = "https://github.com/${github_username}/${github_repository_name}" + mavGitUrl = mavSiteUrl + '.git' + bugTrackerUrl = mavSiteUrl + '/issues/' + mavProjectName = bintray_project_name + mavLibraryLicenses = ["Apache-2.0": 'http://www.apache.org/licenses/LICENSE-2.0.txt'] + mavLibraryDescription = library_description + mavVersion = library_version +} +// end - do not modify this if your project is on github + group = library_project_group version = library_version +androidExtensions { + experimental = true +} + android { compileSdkVersion target_sdk @@ -65,58 +82,24 @@ dependencies { // Espresso dependencies androidTestImplementation "androidx.test.espresso:espresso-core:$androidx_test_espresso_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + api 'commons-net:commons-net:3.6' //api "net.gotev:uploadservice:${version}" //comment the previous line and uncomment the next line for development (it uses the local lib) api project(':uploadservice') } -Properties properties = new Properties() -if (project.rootProject.file("local.properties").exists()) { - properties.load(project.rootProject.file('local.properties').newDataInputStream()) -} - -install { - repositories.mavenInstaller { - pom.project { - name projectName - description library_description - packaging 'aar' - groupId library_project_group - version version - url siteUrl - licenses { - license { - name library_licenses[0] - url library_licenses_url - } - } - developers { - developer { - id properties.getProperty("bintray.user") - name maintainer - } - } - scm { - connection gitUrl - developerConnection gitUrl - url siteUrl - - } - } - } -} - bintray { user = properties.getProperty("bintray.user") key = properties.getProperty("bintray.apikey") - configurations = ['archives'] + publications = ['mavenPublish'] pkg { repo = "maven" - name = projectName + name = mavProjectName desc = library_description - websiteUrl = siteUrl - vcsUrl = gitUrl + websiteUrl = mavSiteUrl + vcsUrl = mavGitUrl issueTrackerUrl = bugTrackerUrl licenses = library_licenses labels = library_keywords @@ -125,37 +108,4 @@ bintray { } } -task sourcesJar(type: Jar) { - from android.sourceSets.main.java.srcDirs - classifier = 'sources' -} - -task javadoc(type: Javadoc) { - excludes = ['**/*.kt'] // < ---- Exclude all kotlin files from javadoc file. - - title = "$projectName $project.version API" - description "Generates Javadoc" - source = android.sourceSets.main.java.srcDirs - classpath += files(android.bootClasspath) - exclude '**/BuildConfig.java', '**/R.java' - options { - windowTitle("$projectName $project.version Reference") - locale = 'en_US' - encoding = 'UTF-8' - charSet = 'UTF-8' - links("http://docs.oracle.com/javase/7/docs/api/") - linksOffline("http://d.android.com/reference", "${android.sdkDirectory}/docs/reference") - setMemberLevel(JavadocMemberLevel.PUBLIC) - addStringOption('Xdoclint:none', '-quiet') - } -} - -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' - from javadoc.destinationDir -} - -artifacts { - archives javadocJar - archives sourcesJar -} +apply from: 'https://raw.githubusercontent.com/sky-uk/gradle-maven-plugin/master/gradle-mavenizer.gradle' diff --git a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPClientWrapper.kt b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPClientWrapper.kt new file mode 100644 index 00000000..fd9cee27 --- /dev/null +++ b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPClientWrapper.kt @@ -0,0 +1,281 @@ +package net.gotev.uploadservice.ftp + +import android.content.Context +import net.gotev.uploadservice.UploadServiceConfig +import net.gotev.uploadservice.data.UploadFile +import net.gotev.uploadservice.logger.UploadServiceLogger +import org.apache.commons.net.ftp.FTP +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.net.ftp.FTPReply +import org.apache.commons.net.ftp.FTPSClient +import org.apache.commons.net.io.CopyStreamEvent +import org.apache.commons.net.io.CopyStreamListener +import java.io.Closeable +import java.io.IOException + +class FTPClientWrapper( + observer: Observer, + connectTimeout: Int, + useSSL: Boolean = false, + sslProtocol: String = "TLS", + implicitSecurity: Boolean = false +) : Closeable { + + interface Observer { + fun onTransfer( + client: FTPClientWrapper, + totalBytesTransferred: Long, + bytesTransferred: Int, + streamSize: Long + ) + } + + private val streamListener = object : CopyStreamListener { + override fun bytesTransferred(event: CopyStreamEvent?) { + } + + override fun bytesTransferred( + totalBytesTransferred: Long, + bytesTransferred: Int, + streamSize: Long + ) { + observer.onTransfer( + this@FTPClientWrapper, + totalBytesTransferred, + bytesTransferred, + streamSize + ) + } + } + + private val ftpClient: FTPClient = if (!useSSL) { + UploadServiceLogger.debug(javaClass.simpleName) { "Creating plain FTP client" } + FTPClient() + } else { + UploadServiceLogger.debug(javaClass.simpleName) { + "Creating FTP over SSL (FTPS) client with $sslProtocol protocol and " + + if (implicitSecurity) + "implicit security" + else + "explicit security" + } + + FTPSClient(sslProtocol, implicitSecurity) + }.apply { + bufferSize = UploadServiceConfig.bufferSizeBytes + copyStreamListener = streamListener + defaultTimeout = connectTimeout + this.connectTimeout = connectTimeout + autodetectUTF8 = true + } + + private fun internalConnect(server: String, port: Int) { + ftpClient.connect(server, port) + + if (!FTPReply.isPositiveCompletion(ftpClient.replyCode)) { + throw IOException("Can't connect to $server:$port. Response: ${ftpClient.replyString}") + } + + // If FTPS, perform https://tools.ietf.org/html/rfc4217#page-17 + (ftpClient as? FTPSClient)?.apply { + execPBSZ(0) + execPROT("P") + } + } + + private fun internalLogin(server: String, port: Int, username: String, password: String) { + if (!ftpClient.login(username, password)) { + throw IOException("Login error on $server:$port with username: $username. " + + "Check your credentials and try again.") + } + } + + private fun setupKeepAlive(socketTimeout: Int) { + // to prevent the socket timeout on the control socket during file transfer, + // set the control keep alive timeout to a half of the socket timeout + val controlKeepAliveTimeout = socketTimeout / 2 / 1000 + + ftpClient.apply { + soTimeout = socketTimeout + this.controlKeepAliveTimeout = controlKeepAliveTimeout.toLong() + controlKeepAliveReplyTimeout = controlKeepAliveTimeout * 1000 + } + + UploadServiceLogger.debug(javaClass.simpleName) { + "Socket timeout set to ${socketTimeout}ms. " + + "Enabled control keep alive every ${controlKeepAliveTimeout}s" + } + } + + private fun setupConnection(compressedFileTransfer: Boolean) { + ftpClient.apply { + enterLocalPassiveMode() + setFileType(FTP.BINARY_FILE_TYPE) + setFileTransferMode(if (compressedFileTransfer) + FTP.COMPRESSED_TRANSFER_MODE + else + FTP.STREAM_TRANSFER_MODE) + } + } + + fun connect(server: String, + port: Int, + username: String, + password: String, + socketTimeout: Int, + compressedFileTransfer: Boolean) { + internalConnect(server, port) + internalLogin(server, port, username, password) + setupKeepAlive(socketTimeout) + setupConnection(compressedFileTransfer) + + UploadServiceLogger.debug(javaClass.simpleName) { + "Successfully connected to $server:$port as $username" + } + } + + val currentWorkingDirectory: String + get() = ftpClient.printWorkingDirectory() + + fun setPermission(remoteFileName: String, permissions: String): Boolean { + if (remoteFileName.isBlank() || permissions.isBlank()) + return false + + // http://stackoverflow.com/questions/12741938/how-can-i-change-permissions-of-a-file-on-a-ftp-server-using-apache-commons-net + try { + val success = ftpClient.sendSiteCommand("chmod $permissions $remoteFileName") + + if (success) { + UploadServiceLogger.debug(javaClass.simpleName) { + "Permissions for: $remoteFileName set to: $permissions" + } + } else { + UploadServiceLogger.error(javaClass.simpleName) { + "Error while setting permissions for $remoteFileName to: $permissions. " + + "Check if your FTP user can set file permissions!" + } + } + return success + + } catch (exc: Throwable) { + UploadServiceLogger.error(javaClass.simpleName, exc) { + "Error while setting permissions for $remoteFileName to: $permissions. " + + "Check if your FTP user can set file permissions!" + } + return false + } + } + + /** + * Creates a nested directory structure on a FTP server and enters into it. + * @param dirPath Path of the directory, i.e /projects/java/ftp/demo + * @param permissions UNIX permissions to apply to created directories. If null, the FTP + * server defaults will be applied, because no UNIX permissions will be + * explicitly set + * @throws IOException if any error occurred during client-server communication + */ + @Throws(IOException::class) + fun makeDirectories(dirPath: String, permissions: String? = null) { + if (!dirPath.contains("/")) return + + val pathElements = dirPath.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + if (pathElements.size == 1) return + + // if the string ends with / it means that the dir path contains only directories, + // otherwise if it does not contain /, the last element of the path is the file name, + // so it must be ignored when creating the directory structure + val lastElement = if (dirPath.endsWith("/")) + pathElements.size + else + pathElements.size - 1 + + for (i in 0 until lastElement) { + val singleDir = pathElements[i] + if (singleDir.isEmpty()) continue + + if (!ftpClient.changeWorkingDirectory(singleDir)) { + if (ftpClient.makeDirectory(singleDir)) { + UploadServiceLogger.debug(javaClass.simpleName) { + "Created remote directory: $singleDir" + } + permissions?.let { setPermission(singleDir, it) } + ftpClient.changeWorkingDirectory(singleDir) + } else { + throw IOException("Unable to create remote directory: $singleDir") + } + } + } + } + + @Throws(IOException::class) + fun uploadFile( + context: Context, + baseWorkingDir: String, + file: UploadFile, + permissions: String? = null + ) { + UploadServiceLogger.debug(javaClass.simpleName) { + "Starting FTP upload of: ${file.handler.name(context)} to: ${file.remotePath}" + } + + var remoteDestination = file.remotePath ?: run { + UploadServiceLogger.error(javaClass.simpleName) { + "Skipping ${file.path} because no remote path has been defined" + } + return + } + + if (remoteDestination.startsWith(baseWorkingDir)) { + remoteDestination = remoteDestination.substring(baseWorkingDir.length) + } + + makeDirectories(remoteDestination, permissions) + + file.handler.stream(context).use { localStream -> + val remoteFileName = file.getRemoteFileName(context) + ?: throw IOException("can't get remote file name for ${file.path}") + + if (!ftpClient.storeFile(remoteFileName, localStream)) { + throw IOException("Error while uploading: ${file.handler.name(context)} " + + "to: ${file.remotePath}") + } + + file.permissions?.let { setPermission(remoteFileName, it) } + } + + // get back to base working directory + if (!ftpClient.changeWorkingDirectory(baseWorkingDir)) { + UploadServiceLogger.error(javaClass.simpleName) { + "Can't change working directory to: $baseWorkingDir" + } + } + } + + override fun close() { + UploadServiceLogger.debug(javaClass.simpleName) { + "Closing FTP Client" + } + + if (ftpClient.isConnected) { + try { + UploadServiceLogger.debug(javaClass.simpleName) { "Logout from FTP server" } + ftpClient.logout() + } catch (exc: Throwable) { + UploadServiceLogger.error(javaClass.simpleName, exc) { + "Error while closing FTP connection" + } + } + + try { + UploadServiceLogger.debug(javaClass.simpleName) { "Disconnect from FTP server" } + ftpClient.disconnect() + } catch (exc: Throwable) { + UploadServiceLogger.error(javaClass.simpleName, exc) { + "Error while disconnecting from FTP connection" + } + } + + } + } +} diff --git a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadRequest.java b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadRequest.java deleted file mode 100644 index abb3b744..00000000 --- a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadRequest.java +++ /dev/null @@ -1,281 +0,0 @@ -package net.gotev.uploadservice.ftp; - -import android.content.Context; -import android.content.Intent; - -import net.gotev.uploadservice.UploadFile; -import net.gotev.uploadservice.UploadRequest; -import net.gotev.uploadservice.UploadServiceBroadcastReceiver; -import net.gotev.uploadservice.UploadTask; - -import java.io.File; -import java.io.FileNotFoundException; - -/** - * Creates a new FTP Upload Request. - * @author Aleksandar Gotev - */ -public class FTPUploadRequest extends UploadRequest { - - protected final FTPUploadTaskParameters ftpParams = new FTPUploadTaskParameters(); - - @Override - protected Class getTaskClass() { - return FTPUploadTask.class; - } - - /** - * Creates a new FTP upload request. - * - * @param context application context - * @param uploadId unique ID to assign to this upload request.
- * It can be whatever string you want, as long as it's unique. - * If you set it to null or an empty string, an UUID will be automatically - * generated.
It's advised to keep a reference to it in your code, - * so when you receive status updates in {@link UploadServiceBroadcastReceiver}, - * you know to which upload they refer to. - * @param serverUrl server IP address or hostname - * @param port FTP port - */ - public FTPUploadRequest(Context context, String uploadId, String serverUrl, int port) { - super(context, uploadId, serverUrl); - - if (port <= 0) { - throw new IllegalArgumentException("Specify valid FTP port!"); - } - - ftpParams.port = port; - } - - /** - * Creates a new FTP upload request and automatically generates an upload id that will - * be returned when you call {@link UploadRequest#startUpload()}. - * - * @param context application context - * @param serverUrl server IP address or hostname - * @param port FTP port - */ - public FTPUploadRequest(final Context context, final String serverUrl, int port) { - this(context, null, serverUrl, port); - } - - @Override - protected void initializeIntent(Intent intent) { - super.initializeIntent(intent); - intent.putExtra(FTPUploadTaskParameters.PARAM_FTP_TASK_PARAMETERS, ftpParams); - } - - /** - * Set the credentials used to login on the FTP Server. - * @param username account username - * @param password account password - * @return {@link FTPUploadRequest} - */ - public FTPUploadRequest setUsernameAndPassword(String username, String password) { - if (username == null || "".equals(username)) { - throw new IllegalArgumentException("Specify FTP account username!"); - } - - if (password == null || "".equals(password)) { - throw new IllegalArgumentException("Specify FTP account password!"); - } - - ftpParams.username = username; - ftpParams.password = password; - return this; - } - - /** - * Add a file to be uploaded. - * @param filePath path to the local file on the device - * @param remotePath absolute path (or relative path to the default remote working directory) - * of the file on the FTP server. Valid paths are for example: - * {@code /path/to/myfile.txt}, {@code relative/path/} or {@code myfile.zip}. - * If any of the directories of the specified remote path does not exist, - * they will be automatically created. You can also set with which permissions - * to create them by using - * {@link FTPUploadRequest#setCreatedDirectoriesPermissions(UnixPermissions)} - * method. - *

- * Remember that if the remote path ends with {@code /}, the remote file name - * will be the same as the local file, so for example if I'm uploading - * {@code /home/alex/photos.zip} into {@code images/} remote path, I will have - * {@code photos.zip} into the remote {@code images/} directory. - *

- * If the remote path does not end with {@code /}, the last path segment - * will be used as the remote file name, so for example if I'm uploading - * {@code /home/alex/photos.zip} into {@code images/vacations.zip}, I will - * have {@code vacations.zip} into the remote {@code images/} directory. - * @return {@link FTPUploadRequest} - * @throws FileNotFoundException if the local file does not exist - */ - public FTPUploadRequest addFileToUpload(String filePath, String remotePath) throws FileNotFoundException { - return addFileToUpload(filePath, remotePath, null); - } - - /** - * Add a file to be uploaded. - * @param filePath path to the local file on the device - * @param remotePath absolute path (or relative path to the default remote working directory) - * of the file on the FTP server. Valid paths are for example: - * {@code /path/to/myfile.txt}, {@code relative/path/} or {@code myfile.zip}. - * If any of the directories of the specified remote path does not exist, - * they will be automatically created. You can also set with which permissions - * to create them by using - * {@link FTPUploadRequest#setCreatedDirectoriesPermissions(UnixPermissions)} - * method. - *

- * Remember that if the remote path ends with {@code /}, the remote file name - * will be the same as the local file, so for example if I'm uploading - * {@code /home/alex/photos.zip} into {@code images/} remote path, I will have - * {@code photos.zip} into the remote {@code images/} directory. - *

- * If the remote path does not end with {@code /}, the last path segment - * will be used as the remote file name, so for example if I'm uploading - * {@code /home/alex/photos.zip} into {@code images/vacations.zip}, I will - * have {@code vacations.zip} into the remote {@code images/} directory. - * @param permissions UNIX permissions for the uploaded file - * @return {@link FTPUploadRequest} - * @throws FileNotFoundException if the local file does not exist - */ - public FTPUploadRequest addFileToUpload(String filePath, String remotePath, UnixPermissions permissions) - throws FileNotFoundException { - UploadFile file = new UploadFile(filePath); - - if (remotePath == null || remotePath.isEmpty()) { - throw new IllegalArgumentException("You have to specify a remote path"); - } - - file.setProperty(FTPUploadTask.PARAM_REMOTE_PATH, remotePath); - - if (permissions != null) { - file.setProperty(FTPUploadTask.PARAM_PERMISSIONS, permissions.toString()); - } - - params.files.add(file); - return this; - } - - /** - * Add a file to be uploaded in the default working directory of the account used to login - * into the FTP server. The uploaded file name will be the same as the local file name, so - * if you are uploading {@code /path/to/myfile.txt}, you will have {@code myfile.txt} - * inside the default remote working directory. - * If any of the directories of the specified remote path does not exist, - * they will be automatically created. You can also set with which permissions to create them - * by using {@link FTPUploadRequest#setCreatedDirectoriesPermissions(UnixPermissions)} - * method. - * @param filePath path to the local file on the device - * @return {@link FTPUploadRequest} - * @throws FileNotFoundException if the local file does not exist - */ - public FTPUploadRequest addFileToUpload(String filePath) throws FileNotFoundException { - UploadFile file = new UploadFile(filePath); - - file.setProperty(FTPUploadTask.PARAM_REMOTE_PATH, new File(filePath).getName()); - - params.files.add(file); - return this; - } - - /** - * Sets the FTP connection timeout. - * The default value is defined in {@link FTPUploadTaskParameters#DEFAULT_CONNECT_TIMEOUT}. - * @param milliseconds timeout in milliseconds - * @return {@link FTPUploadRequest} - */ - public FTPUploadRequest setConnectTimeout(int milliseconds) { - if (milliseconds < 2000) { - throw new IllegalArgumentException("Set at least 2000ms connect timeout!"); - } - - ftpParams.connectTimeout = milliseconds; - return this; - } - - /** - * Sets FTP socket timeout. This affects login, logout and change working directory timeout. - * The default value is defined in {@link FTPUploadTaskParameters#DEFAULT_SOCKET_TIMEOUT}. - * @param milliseconds timeout in milliseconds - * @return {@link FTPUploadRequest} - */ - public FTPUploadRequest setSocketTimeout(int milliseconds) { - if (milliseconds < 2000) { - throw new IllegalArgumentException("Set at least 2000ms socket timeout!"); - } - - ftpParams.socketTimeout = milliseconds; - return this; - } - - /** - * Sets if the compressed file transfer mode should be used. If your server supports it, this - * will allow you to use less bandwidth to transfer files, however some additional processing - * has to be made on your device. By default compressed file transfer mode is disabled and if - * enabled it works only if it's both supported and enabled on your FTP server. - * @param value true to enable compressed file transfer mode, false to disable it and use the - * default streaming mode - * @return {@link FTPUploadRequest} - */ - public FTPUploadRequest useCompressedFileTransferMode(boolean value) { - ftpParams.compressedFileTransfer = value; - return this; - } - - /** - * Sets the UNIX permissions to set to newly created directories (if any). This may happen if - * you upload files to directories which does not exist on your FTP server. They will be - * automatically created. If null is set here or you never call this method, - * the default permissions for new folders set on your FTP server will be applied. - * @param permissions UNIX permissions to set to newly created directories - * @return {@link FTPUploadRequest} - */ - public FTPUploadRequest setCreatedDirectoriesPermissions(UnixPermissions permissions) { - if (permissions == null) - return this; - - ftpParams.createdDirectoriesPermissions = permissions.toString(); - return this; - } - - /** - * Enables or disables FTP over SSL processing (FTPS). By default SSL is disabled. - * @param useSSL true to enable SSL, false to disable it - * @return {@link FTPUploadRequest} - */ - public FTPUploadRequest useSSL(boolean useSSL) { - ftpParams.useSSL = useSSL; - return this; - } - - /** - * Sets FTPS security mode. By default the security mode is explicit. This flag is used - * only if {@link FTPUploadRequest#useSSL(boolean)} is set to true. - * @see FTPS Security modes - * @param isImplicit true sets security mode to implicit, false sets it to explicit. - * @return {@link FTPUploadRequest} - */ - public FTPUploadRequest setSecurityModeImplicit(boolean isImplicit) { - ftpParams.implicitSecurity = isImplicit; - return this; - } - - /** - * Sets the secure socket protocol to use when {@link FTPUploadRequest#useSSL(boolean)} - * is set to true. The default protocol is TLS. Supported protocols are SSL and TLS. - * @param protocol TLS or SSL (TLS is the default) - * @return {@link FTPUploadRequest} - */ - public FTPUploadRequest setSecureSocketProtocol(String protocol) { - ftpParams.secureSocketProtocol = protocol; - return this; - } - - @Override - public String startUpload() { - if (params.files.isEmpty()) - throw new IllegalArgumentException("Add at least one file to start FTP upload!"); - - return super.startUpload(); - } -} diff --git a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadRequest.kt b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadRequest.kt new file mode 100644 index 00000000..398be3e4 --- /dev/null +++ b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadRequest.kt @@ -0,0 +1,182 @@ +package net.gotev.uploadservice.ftp + +import android.content.Context +import android.os.Parcelable +import net.gotev.uploadservice.UploadRequest +import net.gotev.uploadservice.UploadTask +import net.gotev.uploadservice.data.UploadFile +import java.io.File +import java.io.FileNotFoundException + +/** + * Creates a new FTP Upload Request. + * @param context application context + * @param serverUrl server IP address or hostname + * @param port FTP port + */ +class FTPUploadRequest(context: Context, serverUrl: String, port: Int) : UploadRequest(context, serverUrl) { + protected val ftpParams = FTPUploadTaskParameters(port) + + override val taskClass: Class + get() = FTPUploadTask::class.java + + init { + require(port > 0) { "Specify valid FTP port!" } + } + + override fun getAdditionalParameters(): Parcelable { + return ftpParams + } + + /** + * Set the credentials used to login on the FTP Server. + * @param username account username + * @param password account password + * @return [FTPUploadRequest] + */ + fun setUsernameAndPassword(username: String, password: String): FTPUploadRequest { + require(username.isNotBlank()) { "Specify FTP account username!" } + require(password.isNotBlank()) { "Specify FTP account password!" } + + ftpParams.username = username + ftpParams.password = password + return this + } + + /** + * Add a file to be uploaded. + * @param filePath path to the local file on the device + * @param remotePath if null, The uploaded file name will be the same as the local file name, so + * if you are uploading `/path/to/myfile.txt`, you will have `myfile.txt` + * inside the default remote working directory. + * + * If not null, it's the absolute path (or relative path to the default remote working directory) + * of the file on the FTP server. Valid paths are for example: + * `/path/to/myfile.txt`, `relative/path/` or `myfile.zip`. + * If any of the directories of the specified remote path does not exist, + * they will be automatically created. You can also set with which permissions + * to create them by using + * [FTPUploadRequest.setCreatedDirectoriesPermissions] + * method. + *



+ * Remember that if the remote path ends with `/`, the remote file name + * will be the same as the local file, so for example if I'm uploading + * `/home/alex/photos.zip` into `images/` remote path, I will have + * `photos.zip` into the remote `images/` directory. + *



+ * If the remote path does not end with `/`, the last path segment + * will be used as the remote file name, so for example if I'm uploading + * `/home/alex/photos.zip` into `images/vacations.zip`, I will + * have `vacations.zip` into the remote `images/` directory. + * @param permissions UNIX permissions for the uploaded file + * @return [FTPUploadRequest] + * @throws FileNotFoundException if the local file does not exist + */ + @Throws(FileNotFoundException::class) + @JvmOverloads + fun addFileToUpload(filePath: String, remotePath: String? = null, permissions: UnixPermissions? = null): FTPUploadRequest { + files.add(UploadFile(filePath).apply { + this.remotePath = if (remotePath.isNullOrBlank()) { + File(filePath).name + } else { + remotePath + } + + this.permissions = permissions?.toString() + }) + return this + } + + /** + * Sets the FTP connection timeout. + * The default value is defined in [FTPUploadTaskParameters.DEFAULT_CONNECT_TIMEOUT]. + * @param milliseconds timeout in milliseconds + * @return [FTPUploadRequest] + */ + fun setConnectTimeout(milliseconds: Int): FTPUploadRequest { + require(milliseconds >= 2000) { "Set at least 2000ms connect timeout!" } + + ftpParams.connectTimeout = milliseconds + return this + } + + /** + * Sets FTP socket timeout. This affects login, logout and change working directory timeout. + * The default value is defined in [FTPUploadTaskParameters.DEFAULT_SOCKET_TIMEOUT]. + * @param milliseconds timeout in milliseconds + * @return [FTPUploadRequest] + */ + fun setSocketTimeout(milliseconds: Int): FTPUploadRequest { + require(milliseconds >= 2000) { "Set at least 2000ms socket timeout!" } + + ftpParams.socketTimeout = milliseconds + return this + } + + /** + * Sets if the compressed file transfer mode should be used. If your server supports it, this + * will allow you to use less bandwidth to transfer files, however some additional processing + * has to be made on your device. By default compressed file transfer mode is disabled and if + * enabled it works only if it's both supported and enabled on your FTP server. + * @param value true to enable compressed file transfer mode, false to disable it and use the + * default streaming mode + * @return [FTPUploadRequest] + */ + fun useCompressedFileTransferMode(value: Boolean): FTPUploadRequest { + ftpParams.compressedFileTransfer = value + return this + } + + /** + * Sets the UNIX permissions to set to newly created directories (if any). This may happen if + * you upload files to directories which does not exist on your FTP server. They will be + * automatically created. If you never call this method, + * the default permissions for new folders set on your FTP server will be applied. + * @param permissions UNIX permissions to set to newly created directories + * @return [FTPUploadRequest] + */ + fun setCreatedDirectoriesPermissions(permissions: UnixPermissions): FTPUploadRequest { + ftpParams.createdDirectoriesPermissions = permissions.toString() + return this + } + + /** + * Enables or disables FTP over SSL processing (FTPS). By default SSL is disabled. + * @param useSSL true to enable SSL, false to disable it + * @return [FTPUploadRequest] + */ + fun useSSL(useSSL: Boolean): FTPUploadRequest { + ftpParams.useSSL = useSSL + return this + } + + /** + * Sets FTPS security mode. By default the security mode is explicit. This flag is used + * only if [FTPUploadRequest.useSSL] is set to true. + * @see [FTPS Security modes](https://en.wikipedia.org/wiki/FTPS.Methods_of_invoking_security) + * + * @param isImplicit true sets security mode to implicit, false sets it to explicit. + * @return [FTPUploadRequest] + */ + fun setSecurityModeImplicit(isImplicit: Boolean): FTPUploadRequest { + ftpParams.implicitSecurity = isImplicit + return this + } + + /** + * Sets the secure socket protocol to use when [FTPUploadRequest.useSSL] + * is set to true. The default protocol is TLS. Supported protocols are SSL and TLS. + * @param protocol TLS or SSL (TLS is the default) + * @return [FTPUploadRequest] + */ + fun setSecureSocketProtocol(protocol: String): FTPUploadRequest { + ftpParams.secureSocketProtocol = protocol + return this + } + + override fun startUpload(): String { + require(files.isNotEmpty()) { "Add at least one file to start FTP upload!" } + + return super.startUpload() + } +} diff --git a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTask.java b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTask.java deleted file mode 100644 index 44af3150..00000000 --- a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTask.java +++ /dev/null @@ -1,308 +0,0 @@ -package net.gotev.uploadservice.ftp; - -import android.content.Intent; - -import net.gotev.uploadservice.Logger; -import net.gotev.uploadservice.ServerResponse; -import net.gotev.uploadservice.UploadFile; -import net.gotev.uploadservice.UploadService; -import net.gotev.uploadservice.UploadTask; - -import org.apache.commons.net.ftp.FTP; -import org.apache.commons.net.ftp.FTPClient; -import org.apache.commons.net.ftp.FTPReply; -import org.apache.commons.net.ftp.FTPSClient; -import org.apache.commons.net.io.CopyStreamEvent; -import org.apache.commons.net.io.CopyStreamListener; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Iterator; - -/** - * Implements the FTP upload logic. - * @author Aleksandar Gotev - */ -public class FTPUploadTask extends UploadTask implements CopyStreamListener { - - private static final String LOG_TAG = FTPUploadTask.class.getSimpleName(); - - // properties associated to each file - protected static final String PARAM_REMOTE_PATH = "ftpRemotePath"; - protected static final String PARAM_PERMISSIONS = "ftpPermissions"; - - private FTPUploadTaskParameters ftpParams = null; - private FTPClient ftpClient = null; - - @Override - protected void init(UploadService service, Intent intent) throws IOException { - super.init(service, intent); - this.ftpParams = intent.getParcelableExtra(FTPUploadTaskParameters.PARAM_FTP_TASK_PARAMETERS); - } - - @Override - protected void upload() throws Exception { - try { - if (ftpParams.useSSL) { - String secureProtocol = ftpParams.secureSocketProtocol; - - if (secureProtocol == null || secureProtocol.isEmpty()) - secureProtocol = FTPUploadTaskParameters.DEFAULT_SECURE_SOCKET_PROTOCOL; - - FTPSClient ftpsClient = new FTPSClient(secureProtocol, ftpParams.implicitSecurity); - - // https://tools.ietf.org/html/rfc4217#page-17 - ftpsClient.execPBSZ(0); - ftpsClient.execPROT("P"); - - ftpClient = ftpsClient; - - Logger.debug(LOG_TAG, "Created FTP over SSL (FTPS) client with " - + secureProtocol + " protocol and " - + (ftpParams.implicitSecurity ? "implicit security" : "explicit security")); - - } else { - ftpClient = new FTPClient(); - } - - ftpClient.setBufferSize(UploadService.BUFFER_SIZE); - ftpClient.setCopyStreamListener(this); - ftpClient.setDefaultTimeout(ftpParams.connectTimeout); - ftpClient.setConnectTimeout(ftpParams.connectTimeout); - ftpClient.setAutodetectUTF8(true); - - Logger.debug(LOG_TAG, "Connect timeout set to " + ftpParams.connectTimeout + "ms"); - - Logger.debug(LOG_TAG, "Connecting to " + params.serverUrl - + ":" + ftpParams.port + " as " + ftpParams.username); - ftpClient.connect(params.serverUrl, ftpParams.port); - - if (!FTPReply.isPositiveCompletion(ftpClient.getReplyCode())) { - throw new Exception("Can't connect to " + params.serverUrl - + ":" + ftpParams.port - + ". The server response is: " + ftpClient.getReplyString()); - } - - if (!ftpClient.login(ftpParams.username, ftpParams.password)) { - throw new Exception("Error while performing login on " + params.serverUrl - + ":" + ftpParams.port - + " with username: " + ftpParams.username - + ". Check your credentials and try again."); - } - - // to prevent the socket timeout on the control socket during file transfer, - // set the control keep alive timeout to a half of the socket timeout - int controlKeepAliveTimeout = ftpParams.socketTimeout / 2 / 1000; - - ftpClient.setSoTimeout(ftpParams.socketTimeout); - ftpClient.setControlKeepAliveTimeout(controlKeepAliveTimeout); - ftpClient.setControlKeepAliveReplyTimeout(controlKeepAliveTimeout * 1000); - - Logger.debug(LOG_TAG, "Socket timeout set to " + ftpParams.socketTimeout - + "ms. Enabled control keep alive every " + controlKeepAliveTimeout + "s"); - - ftpClient.enterLocalPassiveMode(); - ftpClient.setFileType(FTP.BINARY_FILE_TYPE); - ftpClient.setFileTransferMode(ftpParams.compressedFileTransfer ? - FTP.COMPRESSED_TRANSFER_MODE : FTP.STREAM_TRANSFER_MODE); - - // this is needed to calculate the total bytes and the uploaded bytes, because if the - // request fails, the upload method will be called again - // (until max retries is reached) to retry the upload, so it's necessary to - // know at which status we left, to be able to properly notify firther progress. - calculateUploadedAndTotalBytes(); - - String baseWorkingDir = ftpClient.printWorkingDirectory(); - Logger.debug(LOG_TAG, "FTP default working directory is: " + baseWorkingDir); - - Iterator iterator = new ArrayList<>(params.files).iterator(); - while (iterator.hasNext()) { - UploadFile file = iterator.next(); - - if (!shouldContinue) - break; - - uploadFile(baseWorkingDir, file); - addSuccessfullyUploadedFile(file); - iterator.remove(); - } - - // Broadcast completion only if the user has not cancelled the operation. - if (shouldContinue) { - broadcastCompleted(new ServerResponse(UploadTask.TASK_COMPLETED_SUCCESSFULLY, - UploadTask.EMPTY_RESPONSE, null)); - } - - } finally { - if (ftpClient.isConnected()) { - try { - Logger.debug(LOG_TAG, "Logout and disconnect from FTP server: " - + params.serverUrl + ":" + ftpParams.port); - ftpClient.logout(); - ftpClient.disconnect(); - } catch (Exception exc) { - Logger.error(LOG_TAG, "Error while closing FTP connection to: " - + params.serverUrl + ":" + ftpParams.port, exc); - } - } - ftpClient = null; - } - } - - /** - * Calculates the total bytes of this upload task. - * This the sum of all the lengths of the successfully uploaded files and also the pending - * ones. - */ - private void calculateUploadedAndTotalBytes() { - uploadedBytes = 0; - - for (String filePath : getSuccessfullyUploadedFiles()) { - uploadedBytes += new File(filePath).length(); - } - - totalBytes = uploadedBytes; - - for (UploadFile file : params.files) { - totalBytes += file.length(service); - } - } - - private void uploadFile(String baseWorkingDir, UploadFile file) throws IOException { - Logger.debug(LOG_TAG, "Starting FTP upload of: " + file.getName(service) - + " to: " + file.getProperty(PARAM_REMOTE_PATH)); - - String remoteDestination = file.getProperty(PARAM_REMOTE_PATH); - - if (remoteDestination.startsWith(baseWorkingDir)) { - remoteDestination = remoteDestination.substring(baseWorkingDir.length()); - } - - makeDirectories(remoteDestination, ftpParams.createdDirectoriesPermissions); - - InputStream localStream = file.getStream(service); - try { - String remoteFileName = getRemoteFileName(file); - if (!ftpClient.storeFile(remoteFileName, localStream)) { - throw new IOException("Error while uploading: " + file.getName(service) - + " to: " + file.getProperty(PARAM_REMOTE_PATH)); - } - - setPermission(remoteFileName, file.getProperty(PARAM_PERMISSIONS)); - - } finally { - localStream.close(); - } - - // get back to base working directory - if (!ftpClient.changeWorkingDirectory(baseWorkingDir)) { - Logger.info(LOG_TAG, "Can't change working directory to: " + baseWorkingDir); - } - } - - private void setPermission(String remoteFileName, String permissions) { - if (permissions == null || "".equals(permissions)) - return; - - // http://stackoverflow.com/questions/12741938/how-can-i-change-permissions-of-a-file-on-a-ftp-server-using-apache-commons-net - try { - if (ftpClient.sendSiteCommand("chmod " + permissions + " " + remoteFileName)) { - Logger.error(LOG_TAG, "Error while setting permissions for: " - + remoteFileName + " to: " + permissions - + ". Check if your FTP user can set file permissions!"); - } else { - Logger.debug(LOG_TAG, "Permissions for: " + remoteFileName + " set to: " + permissions); - } - } catch (IOException exc) { - Logger.error(LOG_TAG, "Error while setting permissions for: " - + remoteFileName + " to: " + permissions - + ". Check if your FTP user can set file permissions!", exc); - } - } - - @Override - public void bytesTransferred(CopyStreamEvent event) { - } - - @Override - public void bytesTransferred(long totalBytesTransferred, int bytesTransferred, long streamSize) { - uploadedBytes += bytesTransferred; - broadcastProgress(uploadedBytes, totalBytes); - - if (!shouldContinue) { - try { - ftpClient.disconnect(); - } catch (Exception exc) { - Logger.error(LOG_TAG, "Failed to abort current file transfer", exc); - } - } - } - - /** - * Creates a nested directory structure on a FTP server and enters into it. - * @param dirPath Path of the directory, i.e /projects/java/ftp/demo - * @param permissions UNIX permissions to apply to created directories. If null, the FTP - * server defaults will be applied, because no UNIX permissions will be - * explicitly set - * @throws IOException if any error occurred during client-server communication - */ - private void makeDirectories(String dirPath, String permissions) throws IOException { - if (!dirPath.contains("/")) return; - - String[] pathElements = dirPath.split("/"); - - if (pathElements.length == 1) return; - - // if the string ends with / it means that the dir path contains only directories, - // otherwise if it does not contain /, the last element of the path is the file name, - // so it must be ignored when creating the directory structure - int lastElement = dirPath.endsWith("/") ? pathElements.length : pathElements.length - 1; - - for (int i = 0; i < lastElement; i++) { - String singleDir = pathElements[i]; - if (singleDir.isEmpty()) continue; - - if (!ftpClient.changeWorkingDirectory(singleDir)) { - if (ftpClient.makeDirectory(singleDir)) { - Logger.debug(LOG_TAG, "Created remote directory: " + singleDir); - if (permissions != null) { - setPermission(singleDir, permissions); - } - ftpClient.changeWorkingDirectory(singleDir); - } else { - throw new IOException("Unable to create remote directory: " + singleDir); - } - } - } - } - - /** - * Checks if the remote file path contains also the remote file name. If it's not specified, - * the name of the local file will be used. - * @param file file to upload - * @return remote file name - */ - private String getRemoteFileName(UploadFile file) { - - // if the remote path ends with / - // it means that the remote path specifies only the directory structure, so - // get the remote file name from the local file - if (file.getProperty(PARAM_REMOTE_PATH).endsWith("/")) { - return file.getName(service); - } - - // if the remote path contains /, but it's not the last character - // it means that I have something like: /path/to/myfilename - // so the remote file name is the last path element (myfilename in this example) - if (file.getProperty(PARAM_REMOTE_PATH).contains("/")) { - String[] tmp = file.getProperty(PARAM_REMOTE_PATH).split("/"); - return tmp[tmp.length - 1]; - } - - // if the remote path does not contain /, it means that it specifies only - // the remote file name - return file.getProperty(PARAM_REMOTE_PATH); - } -} diff --git a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTask.kt b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTask.kt new file mode 100644 index 00000000..a0100c3b --- /dev/null +++ b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTask.kt @@ -0,0 +1,97 @@ +package net.gotev.uploadservice.ftp + +import net.gotev.uploadservice.UploadTask +import net.gotev.uploadservice.logger.UploadServiceLogger +import net.gotev.uploadservice.network.HttpStack +import net.gotev.uploadservice.network.ServerResponse + +/** + * Implements the FTP upload logic. + * @author Aleksandar Gotev + */ +class FTPUploadTask : UploadTask(), FTPClientWrapper.Observer { + + private val ftpParams: FTPUploadTaskParameters + get() = params.additionalParameters as FTPUploadTaskParameters + + @Throws(Exception::class) + override fun upload(httpStack: HttpStack) { + + val ftpParams = ftpParams + + FTPClientWrapper( + useSSL = ftpParams.useSSL, + sslProtocol = ftpParams.secureSocketProtocol, + implicitSecurity = ftpParams.implicitSecurity, + connectTimeout = ftpParams.connectTimeout, + observer = this + ).use { ftpClient -> + ftpClient.connect( + server = params.serverUrl, + port = ftpParams.port, + username = ftpParams.username, + password = ftpParams.password, + socketTimeout = ftpParams.socketTimeout, + compressedFileTransfer = ftpParams.compressedFileTransfer + ) + + // this is needed to calculate the total bytes and the uploaded bytes, because if the + // request fails, the upload method will be called again + // (until max retries is reached) to retry the upload, so it's necessary to + // know at which status we left, to be able to properly notify further progress. + calculateUploadedAndTotalBytes() + + val baseWorkingDir = ftpClient.currentWorkingDirectory + UploadServiceLogger.debug(javaClass.simpleName) { + "FTP default working directory is: $baseWorkingDir" + } + + for (file in params.files) { + if (!shouldContinue) + break + + if (file.successfullyUploaded) + continue + + ftpClient.uploadFile(context, baseWorkingDir, file, ftpParams.createdDirectoriesPermissions) + file.successfullyUploaded = true + } + + // Broadcast completion only if the user has not cancelled the operation. + if (shouldContinue) { + onResponseReceived(ServerResponse.successfulEmpty()) + } + } + } + + /** + * Calculates the total bytes of this upload task. + * This the sum of all the lengths of the successfully uploaded files and also the pending + * ones. + */ + private fun calculateUploadedAndTotalBytes() { + resetUploadedBytes() + + var totalUploaded: Long = 0 + + for (file in successfullyUploadedFiles) { + totalUploaded += file.handler.size(context) + } + + totalBytes = totalUploaded + + for (file in params.files) { + totalBytes += file.handler.size(context) + } + + onProgress(totalUploaded) + } + + override fun onTransfer(client: FTPClientWrapper, totalBytesTransferred: Long, bytesTransferred: Int, streamSize: Long) { + onProgress(bytesTransferred.toLong()) + + if (!shouldContinue) { + client.close() + } + } +} diff --git a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTaskParameters.java b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTaskParameters.java deleted file mode 100644 index 5dc411d7..00000000 --- a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTaskParameters.java +++ /dev/null @@ -1,90 +0,0 @@ -package net.gotev.uploadservice.ftp; - -import android.os.Parcel; -import android.os.Parcelable; - -/** - * FTP upload parameters. - * @author Aleksandar Gotev - */ -public class FTPUploadTaskParameters implements Parcelable { - - protected static final String PARAM_FTP_TASK_PARAMETERS = "ftpTaskParameters"; - - /** - * The default FTP connection timeout in milliseconds. - */ - public static final int DEFAULT_CONNECT_TIMEOUT = 15000; - - /** - * The default FTP socket timeout in milliseconds. - */ - public static final int DEFAULT_SOCKET_TIMEOUT = 30000; - - /** - * The default protocol to use when FTP over SSL is enabled (FTPS). - */ - public static final String DEFAULT_SECURE_SOCKET_PROTOCOL = "TLS"; - - public int port; - public String username; - public String password; - public int connectTimeout = DEFAULT_CONNECT_TIMEOUT; - public int socketTimeout = DEFAULT_SOCKET_TIMEOUT; - public boolean compressedFileTransfer; - public String createdDirectoriesPermissions; - public boolean useSSL; - public boolean implicitSecurity; - public String secureSocketProtocol = DEFAULT_SECURE_SOCKET_PROTOCOL; - - public FTPUploadTaskParameters() { - - } - - // This is used to regenerate the object. - // All Parcelables must have a CREATOR that implements these two methods - public static final Creator CREATOR = - new Creator() { - @Override - public FTPUploadTaskParameters createFromParcel(final Parcel in) { - return new FTPUploadTaskParameters(in); - } - - @Override - public FTPUploadTaskParameters[] newArray(final int size) { - return new FTPUploadTaskParameters[size]; - } - }; - - @Override - public void writeToParcel(Parcel parcel, int arg1) { - parcel.writeInt(port); - parcel.writeString(username); - parcel.writeString(password); - parcel.writeInt(connectTimeout); - parcel.writeInt(socketTimeout); - parcel.writeByte((byte) (compressedFileTransfer ? 1 : 0)); - parcel.writeString(createdDirectoriesPermissions); - parcel.writeByte((byte) (useSSL ? 1 : 0)); - parcel.writeByte((byte) (implicitSecurity ? 1 : 0)); - parcel.writeString(secureSocketProtocol); - } - - private FTPUploadTaskParameters(Parcel in) { - port = in.readInt(); - username = in.readString(); - password = in.readString(); - connectTimeout = in.readInt(); - socketTimeout = in.readInt(); - compressedFileTransfer = in.readByte() == 1; - createdDirectoriesPermissions = in.readString(); - useSSL = in.readByte() == 1; - implicitSecurity = in.readByte() == 1; - secureSocketProtocol = in.readString(); - } - - @Override - public int describeContents() { - return 0; - } -} diff --git a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTaskParameters.kt b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTaskParameters.kt new file mode 100644 index 00000000..7ea3a2c1 --- /dev/null +++ b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/FTPUploadTaskParameters.kt @@ -0,0 +1,41 @@ +package net.gotev.uploadservice.ftp + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +/** + * FTP upload parameters. + * @author Aleksandar Gotev + */ +@Parcelize +data class FTPUploadTaskParameters( + var port: Int, + var username: String = "anonymous", + var password: String = "", + var connectTimeout: Int = DEFAULT_CONNECT_TIMEOUT, + var socketTimeout: Int = DEFAULT_SOCKET_TIMEOUT, + @get:JvmName("isCompressedFileTransfer") + var compressedFileTransfer: Boolean = false, + var createdDirectoriesPermissions: String? = null, + var useSSL: Boolean = false, + @get:JvmName("isImplicitSecurity") + var implicitSecurity: Boolean = false, + var secureSocketProtocol: String = DEFAULT_SECURE_SOCKET_PROTOCOL +) : Parcelable { + companion object { + /** + * The default FTP connection timeout in milliseconds. + */ + const val DEFAULT_CONNECT_TIMEOUT = 15000 + + /** + * The default FTP socket timeout in milliseconds. + */ + const val DEFAULT_SOCKET_TIMEOUT = 30000 + + /** + * The default protocol to use when FTP over SSL is enabled (FTPS). + */ + const val DEFAULT_SECURE_SOCKET_PROTOCOL = "TLS" + } +} diff --git a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UnixPermissions.java b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UnixPermissions.java deleted file mode 100644 index befab261..00000000 --- a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UnixPermissions.java +++ /dev/null @@ -1,183 +0,0 @@ -package net.gotev.uploadservice.ftp; - -import java.util.Locale; - -/** - * Utility class to work with UNIX permissions. - * - * @author Aleksandar Gotev - */ -public class UnixPermissions { - - private boolean ownerCanRead; - private boolean ownerCanWrite; - private boolean ownerCanExecute; - - private boolean groupCanRead; - private boolean groupCanWrite; - private boolean groupCanExecute; - - private boolean worldCanRead; - private boolean worldCanWrite; - private boolean worldCanExecute; - - /** - * Creates a new UNIX permissions object. - * The default permissions set is 644 (Owner can read and write. Group and World can only read) - */ - public UnixPermissions() { - ownerCanRead = true; - ownerCanWrite = true; - groupCanRead = true; - worldCanRead = true; - } - - /** - * Creates a new UNIX permissions object. - * @param string UNIX permissions string (e.g. 700, 400, 644, 777) - */ - public UnixPermissions(String string) { - if (string == null || string.length() != 3) { - throw new IllegalArgumentException("UNIX permissions string length must be 3!"); - } - - for (int i = 0; i < 3; i++) { - if (!Character.isDigit(string.charAt(i))) - throw new IllegalArgumentException("UNIX permissions string must be numeric"); - } - - for (int i = 0; i < string.length(); i++) { - char ch = string.charAt(i); - boolean read = false, write = false, execute = false; - - if (ch == '1') { - execute = true; - - } else if (ch == '2') { - write = true; - - } else if (ch == '3') { - write = execute = true; - - } else if (ch == '4') { - read = true; - - } else if (ch == '5') { - read = execute = true; - - } else if (ch == '6') { - read = write = true; - - } else if (ch == '7') { - read = write = execute = true; - - } - - if (i == 0) { - ownerCanRead = read; - ownerCanWrite = write; - ownerCanExecute = execute; - - } else if (i == 1) { - groupCanRead = read; - groupCanWrite = write; - groupCanExecute = execute; - - } else { - worldCanRead = read; - worldCanWrite = write; - worldCanExecute = execute; - } - } - } - - @Override - public String toString() { - int owner = (ownerCanRead ? 4 : 0) + (ownerCanWrite ? 2 : 0) + (ownerCanExecute ? 1 : 0); - int group = (groupCanRead ? 4 : 0) + (groupCanWrite ? 2 : 0) + (groupCanExecute ? 1 : 0); - int world = (worldCanRead ? 4 : 0) + (worldCanWrite ? 2 : 0) + (worldCanExecute ? 1 : 0); - - return String.format(Locale.getDefault(), "%1$d%2$d%3$d", owner, group, world); - } - - public boolean isOwnerCanRead() { - return ownerCanRead; - } - - public UnixPermissions setOwnerCanRead(boolean ownerCanRead) { - this.ownerCanRead = ownerCanRead; - return this; - } - - public boolean isOwnerCanWrite() { - return ownerCanWrite; - } - - public UnixPermissions setOwnerCanWrite(boolean ownerCanWrite) { - this.ownerCanWrite = ownerCanWrite; - return this; - } - - public boolean isOwnerCanExecute() { - return ownerCanExecute; - } - - public UnixPermissions setOwnerCanExecute(boolean ownerCanExecute) { - this.ownerCanExecute = ownerCanExecute; - return this; - } - - public boolean isGroupCanRead() { - return groupCanRead; - } - - public UnixPermissions setGroupCanRead(boolean groupCanRead) { - this.groupCanRead = groupCanRead; - return this; - } - - public boolean isGroupCanWrite() { - return groupCanWrite; - } - - public UnixPermissions setGroupCanWrite(boolean groupCanWrite) { - this.groupCanWrite = groupCanWrite; - return this; - } - - public boolean isGroupCanExecute() { - return groupCanExecute; - } - - public UnixPermissions setGroupCanExecute(boolean groupCanExecute) { - this.groupCanExecute = groupCanExecute; - return this; - } - - public boolean isWorldCanRead() { - return worldCanRead; - } - - public UnixPermissions setWorldCanRead(boolean worldCanRead) { - this.worldCanRead = worldCanRead; - return this; - } - - public boolean isWorldCanWrite() { - return worldCanWrite; - } - - public UnixPermissions setWorldCanWrite(boolean worldCanWrite) { - this.worldCanWrite = worldCanWrite; - return this; - } - - public boolean isWorldCanExecute() { - return worldCanExecute; - } - - public UnixPermissions setWorldCanExecute(boolean worldCanExecute) { - this.worldCanExecute = worldCanExecute; - return this; - } -} diff --git a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UnixPermissions.kt b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UnixPermissions.kt new file mode 100644 index 00000000..4e32daa8 --- /dev/null +++ b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UnixPermissions.kt @@ -0,0 +1,63 @@ +package net.gotev.uploadservice.ftp + +/** + * Utility class to work with UNIX permissions. + * + * The default permissions set are 644 (Owner can read and write. Group and World can only read) + */ +class UnixPermissions @JvmOverloads constructor(value: String = "644") { + + data class RolePermissions( + var read: Boolean = false, + var write: Boolean = false, + var execute: Boolean = false + ) { + companion object { + fun fromChar(char: Char): RolePermissions { + var mutableValue = char.toString().toInt() + + val read = mutableValue >= 4 + if (read) mutableValue -= 4 + + val write = mutableValue >= 2 + if (write) mutableValue -= 2 + + val execute = mutableValue == 1 + if (execute) mutableValue -= 1 + + return RolePermissions(read, write, execute) + } + } + + override fun toString(): String { + var value = 0 + + if (read) value += 4 + if (write) value += 2 + if (execute) value += 1 + + return value.toString() + } + } + + val owner: RolePermissions + val group: RolePermissions + val world: RolePermissions + + init { + require(value.isNotBlank() && value.length == 3) { + "UNIX permissions value length must be 3!" + } + + val permissions = value.map { + require(it.isDigit()) { "UNIX permissions value must be numeric" } + RolePermissions.fromChar(it) + } + + owner = permissions[0] + group = permissions[1] + world = permissions[2] + } + + override fun toString() = "$owner$group$world" +} diff --git a/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UploadFileExtensions.kt b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UploadFileExtensions.kt new file mode 100644 index 00000000..462ed60a --- /dev/null +++ b/uploadservice-ftp/src/main/java/net/gotev/uploadservice/ftp/UploadFileExtensions.kt @@ -0,0 +1,47 @@ +package net.gotev.uploadservice.ftp + +import android.content.Context +import net.gotev.uploadservice.data.UploadFile +import net.gotev.uploadservice.extensions.setOrRemove + +// properties associated to each file +private const val PROPERTY_REMOTE_PATH = "ftpRemotePath" +private const val PROPERTY_PERMISSIONS = "ftpPermissions" + +internal var UploadFile.remotePath: String? + get() = properties[PROPERTY_REMOTE_PATH] + set(value) { properties.setOrRemove(PROPERTY_REMOTE_PATH, value) } + +internal var UploadFile.permissions: String? + get() = properties[PROPERTY_PERMISSIONS] + set(value) { properties.setOrRemove(PROPERTY_PERMISSIONS, value) } + +/** + * Checks if the remote file path contains also the remote file name. If it's not specified, + * the name of the local file will be used. + * @param file file to upload + * @return remote file name + */ +internal fun UploadFile.getRemoteFileName(context: Context): String? { + val remotePath = remotePath ?: return null + + // if the remote path ends with / + // it means that the remote path specifies only the directory structure, so + // get the remote file name from the local file + if (remotePath.endsWith("/")) { + return handler.name(context) + } + + // if the remote path contains /, but it's not the last character + // it means that I have something like: /path/to/myfilename + // so the remote file name is the last path element (myfilename in this example) + if (remotePath.contains("/")) { + // TODO check if this is correct + val tmp = remotePath.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + return tmp[tmp.size - 1] + } + + // if the remote path does not contain /, it means that it specifies only + // the remote file name + return remotePath +} diff --git a/uploadservice-ftp/src/test/java/net/gotev/uploadservice/ftp/TestRolePermissions.kt b/uploadservice-ftp/src/test/java/net/gotev/uploadservice/ftp/TestRolePermissions.kt new file mode 100644 index 00000000..0a173057 --- /dev/null +++ b/uploadservice-ftp/src/test/java/net/gotev/uploadservice/ftp/TestRolePermissions.kt @@ -0,0 +1,205 @@ +package net.gotev.uploadservice.ftp + +import org.junit.Assert.* +import org.junit.Test + +class TestRolePermissions { + + @Test + fun `no permissions`() { + val permissions = UnixPermissions.RolePermissions.fromChar('0') + assertFalse(permissions.read) + assertFalse(permissions.write) + assertFalse(permissions.execute) + } + + @Test + fun `no permissions serialization`() { + val permissions = UnixPermissions.RolePermissions() + assertEquals("0", permissions.toString()) + } + + @Test + fun `only execute`() { + val permissions = UnixPermissions.RolePermissions.fromChar('1') + assertFalse(permissions.read) + assertFalse(permissions.write) + assertTrue(permissions.execute) + } + + @Test + fun `only execute serialization`() { + val permissions = UnixPermissions.RolePermissions(execute = true) + assertEquals("1", permissions.toString()) + } + + @Test + fun `only write`() { + val permissions = UnixPermissions.RolePermissions.fromChar('2') + assertFalse(permissions.read) + assertTrue(permissions.write) + assertFalse(permissions.execute) + } + + @Test + fun `only write serialization`() { + val permissions = UnixPermissions.RolePermissions(write = true) + assertEquals("2", permissions.toString()) + } + + @Test + fun `write and execute`() { + val permissions = UnixPermissions.RolePermissions.fromChar('3') + assertFalse(permissions.read) + assertTrue(permissions.write) + assertTrue(permissions.execute) + } + + @Test + fun `write and execute serialization`() { + val permissions = UnixPermissions.RolePermissions(write = true, execute = true) + assertEquals("3", permissions.toString()) + } + + @Test + fun `read only`() { + val permissions = UnixPermissions.RolePermissions.fromChar('4') + assertTrue(permissions.read) + assertFalse(permissions.write) + assertFalse(permissions.execute) + } + + @Test + fun `read only serialization`() { + val permissions = UnixPermissions.RolePermissions(read = true) + assertEquals("4", permissions.toString()) + } + + @Test + fun `read and execute`() { + val permissions = UnixPermissions.RolePermissions.fromChar('5') + assertTrue(permissions.read) + assertFalse(permissions.write) + assertTrue(permissions.execute) + } + + @Test + fun `read and execute serialization`() { + val permissions = UnixPermissions.RolePermissions(read = true, execute = true) + assertEquals("5", permissions.toString()) + } + + @Test + fun `read and write`() { + val permissions = UnixPermissions.RolePermissions.fromChar('6') + assertTrue(permissions.read) + assertTrue(permissions.write) + assertFalse(permissions.execute) + } + + @Test + fun `read and write serialization`() { + val permissions = UnixPermissions.RolePermissions(read = true, write = true) + assertEquals("6", permissions.toString()) + } + + @Test + fun `all permissions`() { + val permissions = UnixPermissions.RolePermissions.fromChar('7') + assertTrue(permissions.read) + assertTrue(permissions.write) + assertTrue(permissions.execute) + } + + @Test + fun `all permissions serialization`() { + val permissions = UnixPermissions.RolePermissions(read = true, write = true, execute = true) + assertEquals("7", permissions.toString()) + } + + @Test + fun `default UnixPermissions`() { + val permissions = UnixPermissions() + assertEquals("644", permissions.toString()) + + assertTrue(permissions.owner.read) + assertTrue(permissions.owner.write) + assertFalse(permissions.owner.execute) + + assertTrue(permissions.group.read) + assertFalse(permissions.group.write) + assertFalse(permissions.group.execute) + + assertTrue(permissions.world.read) + assertFalse(permissions.world.write) + assertFalse(permissions.world.execute) + } + + @Test + fun `permission 754`() { + val permissions = UnixPermissions("754") + assertEquals("754", permissions.toString()) + + assertTrue(permissions.owner.read) + assertTrue(permissions.owner.write) + assertTrue(permissions.owner.execute) + + assertTrue(permissions.group.read) + assertFalse(permissions.group.write) + assertTrue(permissions.group.execute) + + assertTrue(permissions.world.read) + assertFalse(permissions.world.write) + assertFalse(permissions.world.execute) + } + + @Test + fun `invalid string`() { + try { + UnixPermissions("7541") + fail("this should throw an exception") + } catch (exc: Throwable) { + assertTrue(exc is IllegalArgumentException) + } + } + + @Test + fun `invalid string 2`() { + try { + UnixPermissions("75") + fail("this should throw an exception") + } catch (exc: Throwable) { + assertTrue(exc is IllegalArgumentException) + } + } + + @Test + fun `invalid string 3`() { + try { + UnixPermissions("7") + fail("this should throw an exception") + } catch (exc: Throwable) { + assertTrue(exc is IllegalArgumentException) + } + } + + @Test + fun `invalid string 4`() { + try { + UnixPermissions("asdmn2") + fail("this should throw an exception") + } catch (exc: Throwable) { + assertTrue(exc is IllegalArgumentException) + } + } + + @Test + fun `invalid string 5`() { + try { + UnixPermissions("") + fail("this should throw an exception") + } catch (exc: Throwable) { + assertTrue(exc is IllegalArgumentException) + } + } +} diff --git a/uploadservice-ftp/test-server/linux/Vagrantfile b/uploadservice-ftp/test-server/linux/Vagrantfile deleted file mode 100644 index b878da27..00000000 --- a/uploadservice-ftp/test-server/linux/Vagrantfile +++ /dev/null @@ -1,119 +0,0 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : - -# All Vagrant configuration is done below. The "2" in Vagrant.configure -# configures the configuration version (we support older styles for -# backwards compatibility). Please don't change it unless you know what -# you're doing. -Vagrant.configure(2) do |config| - # The most common configuration options are documented and commented below. - # For a complete reference, please see the online documentation at - # https://docs.vagrantup.com. - - # Every Vagrant development environment requires a box. You can search for - # boxes at https://atlas.hashicorp.com/search. - config.vm.box = "centos/7" - - config.vm.box_check_update = true - - # Un-comment the next line to port-forward port 21 on the VM to your host - # config.vm.network "forwarded_port", guest: 21, host: 2121 - - # Create a public network, which generally matched to bridged network. - # Bridged networks make the machine appear as another physical device on - # your network. - config.vm.network "public_network" - - # Share an additional folder to the guest VM. The first argument is - # the path on the host to the actual folder. The second argument is - # the path on the guest to mount the folder. And the optional third - # argument is a set of non-required options. - # config.vm.synced_folder "../data", "/vagrant_data" - - # Provider-specific configuration so you can fine-tune various - # backing providers for Vagrant. These expose provider-specific options. - # Example for VirtualBox: - # - # config.vm.provider "virtualbox" do |vb| - # # Display the VirtualBox GUI when booting the machine - # vb.gui = true - # - # # Customize the amount of memory on the VM: - # vb.memory = "1024" - # end - # - # View the documentation for the provider you are using for more - # information on available options. - - # Define a Vagrant Push strategy for pushing to Atlas. Other push strategies - # such as FTP and Heroku are also available. See the documentation at - # https://docs.vagrantup.com/v2/push/atlas.html for more information. - # config.push.define "atlas" do |push| - # push.app = "YOUR_ATLAS_USERNAME/YOUR_APPLICATION_NAME" - # end - - # Enable provisioning with a shell script. Additional provisioners such as - # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the - # documentation for more information about their specific syntax and use. - # - # Provision script prepared by following this guide: - # http://www.krizna.com/centos/setup-ftp-server-centos-7-vsftp/ - config.vm.provision "shell", inline: <<-SHELL - FTPDCONF="/etc/vsftpd/vsftpd.conf" - FTPUSER="ftpuser" - FTPPASSWORD="testpassword" - - echo "Install required packages..." - sudo yum -y install vim vsftpd - - echo "Configure FTP server..." - sudo sed -i 's/anonymous_enable=YES/anonymous_enable=NO/g' ${FTPDCONF} - sudo sed -i 's/#chroot_local_user=YES/chroot_local_user=YES/g' ${FTPDCONF} - echo "allow_writeable_chroot=YES" | sudo tee -a ${FTPDCONF} > /dev/null - echo "pasv_enable=Yes" | sudo tee -a ${FTPDCONF} > /dev/null - echo "pasv_min_port=40000" | sudo tee -a ${FTPDCONF} > /dev/null - echo "pasv_max_port=40100" | sudo tee -a ${FTPDCONF} > /dev/null - - echo "Restart FTP server and enable it on boot" - sudo systemctl restart vsftpd.service - sudo systemctl enable vsftpd.service - - echo "Configure firewall" - sudo firewall-cmd --permanent --add-service=ftp - sudo firewall-cmd --reload - - echo "Configure SELinux" - sudo setsebool -P ftp_home_dir on - - echo "Create FTP test user" - sudo useradd -m "$FTPUSER" -s /sbin/nologin - echo "$FTPPASSWORD" | sudo passwd "$FTPUSER" --stdin - - echo "Add handy 'showips' command to show all the IPs which this VM has" - echo '#!/bin/bash' | sudo tee /usr/bin/showips > /dev/null - echo "ip addr show | grep 'inet ' | tr '/' ' ' | cut -f6 -d ' '" | sudo tee -a /usr/bin/showips > /dev/null - sudo chmod +x /usr/bin/showips - - echo "Finished!" - echo - echo - echo -e "SSH credentials are\n Username: vagrant\n Password: vagrant" | sudo tee -a /etc/issue - echo - echo "You can also access with: 'vagrant ssh' from your host," | sudo tee -a /etc/issue - echo "but you have to be in the same directory where the Vagrantfile is" | sudo tee -a /etc/issue - echo - echo "You can login with your FTP client with the following credentials:" | sudo tee -a /etc/motd - echo -e " Username: ${FTPUSER}\n Password: ${FTPPASSWORD}\n Port: 21 (passive mode)\n" | sudo tee -a /etc/motd - echo -e "Use 'showips' command to get all the IPs which this VM has\n" | sudo tee -a /etc/motd > /dev/null - echo -e "Your files will be uploaded into /home/${FTPUSER}" | sudo tee -a /etc/motd > /dev/null - echo -e "Execute 'sudo su' and then 'cd /home/ftpuser/' to go there\n" | sudo tee -a /etc/motd > /dev/null - - echo "On one of the following IP addresses:" - showips - echo "And your files will be uploaded into /home/${FTPUSER}" - - # clear history - rm -rf .bash_history - history -c - SHELL -end diff --git a/uploadservice-ftp/test-server/osx/start b/uploadservice-ftp/test-server/osx/start deleted file mode 100755 index d7410049..00000000 --- a/uploadservice-ftp/test-server/osx/start +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -sudo -s launchctl load -w /System/Library/LaunchDaemons/ftp.plist -echo -e "Now type:\n\nftp localhost\n\nto check that the FTP server is running." -echo "You can login with your system's user credentials" -echo "FTP server is listening on the following IP addresses:" -ifconfig | grep "inet " | awk '{print $2":21"}' diff --git a/uploadservice-ftp/test-server/osx/stop b/uploadservice-ftp/test-server/osx/stop deleted file mode 100755 index 850d92b8..00000000 --- a/uploadservice-ftp/test-server/osx/stop +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -sudo -s launchctl unload -w /System/Library/LaunchDaemons/ftp.plist -echo "FTP server stopped" diff --git a/uploadservice-ftp/test-server/vsftpd-osx b/uploadservice-ftp/test-server/vsftpd-osx new file mode 100755 index 00000000..1d06ea7f --- /dev/null +++ b/uploadservice-ftp/test-server/vsftpd-osx @@ -0,0 +1,13 @@ +#!/bin/bash +docker pull fauria/vsftpd + +IPADDR=$(ifconfig en0 | grep "inet " | awk '{print $2}') +PORT=21 + +echo "Starting vsftpd on ${IPADDR}:${PORT}" + +docker run -t -i -v $(pwd):/home/vsftpd \ + -p 20:20 -p 21:21 -p 21100-21110:21100-21110 \ + -e FTP_USER=myuser -e FTP_PASS=mypass -e LOG_STDOUT=yes \ + -e PASV_ADDRESS=$IPADDR -e PASV_MIN_PORT=21100 -e PASV_MAX_PORT=21110 \ + fauria/vsftpd diff --git a/uploadservice-okhttp/build.gradle b/uploadservice-okhttp/build.gradle index a2e66ed0..6518ff14 100644 --- a/uploadservice-okhttp/build.gradle +++ b/uploadservice-okhttp/build.gradle @@ -1,20 +1,32 @@ apply plugin: 'com.android.library' -apply plugin: 'com.github.dcendents.android-maven' +apply plugin: 'kotlin-android' apply plugin: 'com.jfrog.bintray' apply plugin: 'com.github.ben-manes.versions' -// start - do not modify this if your project is on github -def siteUrl = "https://github.com/${github_username}/${github_repository_name}" -def gitUrl = siteUrl + '.git' -def bugTrackerUrl = siteUrl + '/issues/' -def projectName = "android-upload-service-okhttp" -// end - do not modify this if your project is on github +Properties properties = new Properties() +if (project.rootProject.file("local.properties").exists()) { + properties.load(project.rootProject.file('local.properties').newDataInputStream()) +} // start - module specific overrides of default values written in manifest.gradle +def bintray_project_name = "android-upload-service-okhttp" def library_description = "OkHttp stack implementation for Android Upload Service." def library_keywords = ['android', 'upload', 'service', 'okhttp', 'stack'] // end - module specific overrides +// start - do not modify this if your project is on github +project.ext{ + mavDevelopers = [(properties.getProperty("bintray.user")):(maintainer)] + mavSiteUrl = "https://github.com/${github_username}/${github_repository_name}" + mavGitUrl = mavSiteUrl + '.git' + bugTrackerUrl = mavSiteUrl + '/issues/' + mavProjectName = bintray_project_name + mavLibraryLicenses = ["Apache-2.0": 'http://www.apache.org/licenses/LICENSE-2.0.txt'] + mavLibraryDescription = library_description + mavVersion = library_version +} +// end - do not modify this if your project is on github + group = library_project_group version = library_version @@ -65,58 +77,24 @@ dependencies { // Espresso dependencies androidTestImplementation "androidx.test.espresso:espresso-core:$androidx_test_espresso_version" - api 'com.squareup.okhttp3:okhttp:3.14.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + + api 'com.squareup.okhttp3:okhttp:4.0.1' //api "net.gotev:uploadservice:${version}" //comment the previous line and uncomment the next line for development (it uses the local lib) api project(':uploadservice') } -Properties properties = new Properties() -if (project.rootProject.file("local.properties").exists()) { - properties.load(project.rootProject.file('local.properties').newDataInputStream()) -} - -install { - repositories.mavenInstaller { - pom.project { - name projectName - description library_description - packaging 'aar' - groupId library_project_group - version version - url siteUrl - licenses { - license { - name library_licenses[0] - url library_licenses_url - } - } - developers { - developer { - id properties.getProperty("bintray.user") - name maintainer - } - } - scm { - connection gitUrl - developerConnection gitUrl - url siteUrl - - } - } - } -} - bintray { user = properties.getProperty("bintray.user") key = properties.getProperty("bintray.apikey") - configurations = ['archives'] + publications = ['mavenPublish'] pkg { repo = "maven" - name = projectName + name = mavProjectName desc = library_description - websiteUrl = siteUrl - vcsUrl = gitUrl + websiteUrl = mavSiteUrl + vcsUrl = mavGitUrl issueTrackerUrl = bugTrackerUrl licenses = library_licenses labels = library_keywords @@ -125,37 +103,4 @@ bintray { } } -task sourcesJar(type: Jar) { - from android.sourceSets.main.java.srcDirs - classifier = 'sources' -} - -task javadoc(type: Javadoc) { - excludes = ['**/*.kt'] // < ---- Exclude all kotlin files from javadoc file. - - title = "$projectName $project.version API" - description "Generates Javadoc" - source = android.sourceSets.main.java.srcDirs - classpath += files(android.bootClasspath) - exclude '**/BuildConfig.java', '**/R.java' - options { - windowTitle("$projectName $project.version Reference") - locale = 'en_US' - encoding = 'UTF-8' - charSet = 'UTF-8' - links("http://docs.oracle.com/javase/7/docs/api/") - linksOffline("http://d.android.com/reference", "${android.sdkDirectory}/docs/reference") - setMemberLevel(JavadocMemberLevel.PUBLIC) - addStringOption('Xdoclint:none', '-quiet') - } -} - -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' - from javadoc.destinationDir -} - -artifacts { - archives javadocJar - archives sourcesJar -} +apply from: 'https://raw.githubusercontent.com/sky-uk/gradle-maven-plugin/master/gradle-mavenizer.gradle' diff --git a/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpBodyWriter.java b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpBodyWriter.java deleted file mode 100644 index 87ebdd1a..00000000 --- a/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpBodyWriter.java +++ /dev/null @@ -1,35 +0,0 @@ -package net.gotev.uploadservice.okhttp; - -import net.gotev.uploadservice.http.BodyWriter; - -import java.io.IOException; - -import okio.BufferedSink; - -/** - * @author Aleksandar Gotev - */ - -public class OkHttpBodyWriter extends BodyWriter { - - private BufferedSink mSink; - - protected OkHttpBodyWriter(BufferedSink sink) { - mSink = sink; - } - - @Override - public void write(byte[] bytes) throws IOException { - mSink.write(bytes); - } - - @Override - public void write(byte[] bytes, int lengthToWriteFromStart) throws IOException { - mSink.write(bytes, 0, lengthToWriteFromStart); - } - - @Override - public void flush() throws IOException { - mSink.flush(); - } -} diff --git a/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpBodyWriter.kt b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpBodyWriter.kt new file mode 100644 index 00000000..b39d18b5 --- /dev/null +++ b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpBodyWriter.kt @@ -0,0 +1,31 @@ +package net.gotev.uploadservice.okhttp + +import net.gotev.uploadservice.network.BodyWriter +import okio.BufferedSink +import java.io.IOException + +/** + * @author Aleksandar Gotev + */ + +class OkHttpBodyWriter(private val sink: BufferedSink, listener: OnStreamWriteListener) : BodyWriter(listener) { + @Throws(IOException::class) + override fun internalWrite(bytes: ByteArray) { + sink.write(bytes) + } + + @Throws(IOException::class) + override fun internalWrite(bytes: ByteArray, lengthToWriteFromStart: Int) { + sink.write(bytes, 0, lengthToWriteFromStart) + } + + @Throws(IOException::class) + override fun flush() { + sink.flush() + } + + @Throws(IOException::class) + override fun close() { + sink.close() + } +} diff --git a/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpExtensions.kt b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpExtensions.kt new file mode 100644 index 00000000..e3dcfedd --- /dev/null +++ b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpExtensions.kt @@ -0,0 +1,23 @@ +package net.gotev.uploadservice.okhttp + +import net.gotev.uploadservice.network.ServerResponse +import okhttp3.Response + +/** + * @author Aleksandar Gotev + */ +private fun String.requiresRequestBody() = this == "POST" || this == "PUT" || this == "PATCH" || this == "PROPPATCH" || this == "REPORT" + +private fun String.permitsRequestBody() = !(this == "GET" || this == "HEAD") + +internal fun String.hasBody(): Boolean { + val method = trim().toUpperCase() + return method.permitsRequestBody() || method.requiresRequestBody() +} + +private fun Response.headersHashMap() = LinkedHashMap(headers.toMap()) + +private fun Response.bodyBytes() = body?.bytes() ?: ByteArray(0) + +internal fun Response.asServerResponse() = ServerResponse(code, bodyBytes(), headersHashMap()) + diff --git a/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStack.java b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStack.java deleted file mode 100644 index cd8bcda4..00000000 --- a/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStack.java +++ /dev/null @@ -1,39 +0,0 @@ -package net.gotev.uploadservice.okhttp; - -import net.gotev.uploadservice.http.HttpConnection; -import net.gotev.uploadservice.http.HttpStack; - -import java.io.IOException; -import java.util.concurrent.TimeUnit; - -import okhttp3.OkHttpClient; - -/** - * Implementation of the OkHttp Stack. - * @author Aleksandar Gotev - */ -public class OkHttpStack implements HttpStack { - - private OkHttpClient mClient; - - public OkHttpStack() { - mClient = new OkHttpClient.Builder() - .followRedirects(true) - .followSslRedirects(true) - .retryOnConnectionFailure(true) - .connectTimeout(15, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .cache(null) - .build(); - } - - public OkHttpStack(OkHttpClient client) { - mClient = client; - } - - @Override - public HttpConnection createNewConnection(String method, String url) throws IOException { - return new OkHttpStackConnection(mClient, method, url); - } -} diff --git a/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStack.kt b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStack.kt new file mode 100644 index 00000000..f9b817c5 --- /dev/null +++ b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStack.kt @@ -0,0 +1,17 @@ +package net.gotev.uploadservice.okhttp + +import net.gotev.uploadservice.network.HttpRequest +import net.gotev.uploadservice.network.HttpStack +import okhttp3.OkHttpClient +import java.io.IOException + +/** + * Implementation of the OkHttp Stack. + * @author Aleksandar Gotev + */ +class OkHttpStack(private val client: OkHttpClient = OkHttpClient()) : HttpStack { + @Throws(IOException::class) + override fun newRequest(uploadId: String, method: String, url: String): HttpRequest { + return OkHttpStackRequest(uploadId, client, method, url) + } +} diff --git a/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStackConnection.java b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStackConnection.java deleted file mode 100644 index 80fce12a..00000000 --- a/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStackConnection.java +++ /dev/null @@ -1,136 +0,0 @@ -package net.gotev.uploadservice.okhttp; - -import net.gotev.uploadservice.Logger; -import net.gotev.uploadservice.NameValue; -import net.gotev.uploadservice.ServerResponse; -import net.gotev.uploadservice.http.HttpConnection; - -import java.io.IOException; -import java.net.URL; -import java.util.LinkedHashMap; -import java.util.List; - -import okhttp3.Headers; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.internal.http.HttpMethod; -import okio.BufferedSink; - -/** - * {@link HttpConnection} implementation using OkHttpClient. - * @author Aleksandar Gotev - */ -public class OkHttpStackConnection implements HttpConnection { - - private static final String LOG_TAG = OkHttpStackConnection.class.getSimpleName(); - - private OkHttpClient mClient; - private Request.Builder mRequestBuilder; - private String mMethod; - private long mBodyLength; - private String mContentType; - private Response mResponse; - - public OkHttpStackConnection(OkHttpClient client, String method, String url) throws IOException { - Logger.debug(getClass().getSimpleName(), "creating new connection"); - - mResponse = null; - mClient = client; - mMethod = method; - - mRequestBuilder = new Request.Builder().url(new URL(url)); - } - - @Override - public HttpConnection setHeaders(List requestHeaders) throws IOException { - for (final NameValue param : requestHeaders) { - if ("Content-Type".equalsIgnoreCase(param.getName())) - mContentType = param.getValue(); - - mRequestBuilder.header(param.getName(), param.getValue()); - } - - return this; - } - - @Override - public HttpConnection setTotalBodyBytes(long totalBodyBytes, boolean isFixedLengthStreamingMode) { - if (isFixedLengthStreamingMode) { - if (android.os.Build.VERSION.SDK_INT < 19 && totalBodyBytes > Integer.MAX_VALUE) - throw new RuntimeException("You need Android API version 19 or newer to " - + "upload more than 2GB in a single request using " - + "fixed size content length. Try switching to " - + "chunked mode instead, but make sure your server side supports it!"); - - mBodyLength = totalBodyBytes; - - } else { - // http://stackoverflow.com/questions/33921894/how-do-i-enable-disable-chunked-transfer-encoding-for-a-multi-part-post-that-inc#comment55679982_33921894 - mBodyLength = -1; - } - - return this; - } - - private LinkedHashMap getServerResponseHeaders(Headers headers) throws IOException { - LinkedHashMap out = new LinkedHashMap<>(headers.size()); - - for (String headerName : headers.names()) { - out.put(headerName, headers.get(headerName)); - } - - return out; - } - - @Override - public ServerResponse getResponse(final RequestBodyDelegate delegate) throws IOException { - if (HttpMethod.permitsRequestBody(mMethod) || HttpMethod.requiresRequestBody(mMethod)) { - RequestBody body = new RequestBody() { - @Override - public long contentLength() throws IOException { - return mBodyLength; - } - - @Override - public MediaType contentType() { - if (mContentType == null) - return null; - return MediaType.parse(mContentType); - } - - @Override - public void writeTo(BufferedSink sink) throws IOException { - final OkHttpBodyWriter bodyWriter = new OkHttpBodyWriter(sink); - delegate.onBodyReady(bodyWriter); - bodyWriter.flush(); - } - }; - - mRequestBuilder.method(mMethod, body); - } else { - mRequestBuilder.method(mMethod, null); - } - - mResponse = mClient.newCall(mRequestBuilder.build()).execute(); - - return new ServerResponse(mResponse.code(), - mResponse.body().bytes(), - getServerResponseHeaders(mResponse.headers())); - } - - @Override - public void close() { - Logger.debug(getClass().getSimpleName(), "closing connection"); - - if (mResponse != null) { - try { - mResponse.close(); - } catch (Throwable exc) { - Logger.error(LOG_TAG, "Error while closing connection", exc); - } - } - } -} diff --git a/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStackRequest.kt b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStackRequest.kt new file mode 100644 index 00000000..9a7a01b6 --- /dev/null +++ b/uploadservice-okhttp/src/main/java/net/gotev/uploadservice/okhttp/OkHttpStackRequest.kt @@ -0,0 +1,91 @@ +package net.gotev.uploadservice.okhttp + +import net.gotev.uploadservice.data.NameValue +import net.gotev.uploadservice.logger.UploadServiceLogger +import net.gotev.uploadservice.network.BodyWriter +import net.gotev.uploadservice.network.HttpRequest +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.IOException +import java.net.URL +import java.util.* + +/** + * [HttpRequest] implementation using OkHttpClient. + * + * @author Aleksandar Gotev + */ +class OkHttpStackRequest( + private val uploadId: String, + private val httpClient: OkHttpClient, + private val httpMethod: String, url: String +) : HttpRequest { + + private val requestBuilder = Request.Builder().url(URL(url)) + private var bodyLength = 0L + private var contentType: MediaType? = null + private val uuid = UUID.randomUUID().toString() + + init { + UploadServiceLogger.debug(javaClass.simpleName) { + "(uploadID: $uploadId) creating new OkHttp connection (uuid: $uuid)" + } + } + + @Throws(IOException::class) + override fun setHeaders(requestHeaders: List): HttpRequest { + for (param in requestHeaders) { + if ("content-type" == param.name.trim().toLowerCase()) + contentType = param.value.trim().toMediaTypeOrNull() + + requestBuilder.header(param.name.trim(), param.value.trim()) + } + + return this + } + + override fun setTotalBodyBytes(totalBodyBytes: Long, isFixedLengthStreamingMode: Boolean): HttpRequest { + // http://stackoverflow.com/questions/33921894/how-do-i-enable-disable-chunked-transfer-encoding-for-a-multi-part-post-that-inc#comment55679982_33921894 + bodyLength = if (isFixedLengthStreamingMode) totalBodyBytes else -1 + + return this + } + + private fun createBody(delegate: HttpRequest.RequestBodyDelegate, listener: BodyWriter.OnStreamWriteListener): RequestBody? { + if (!httpMethod.hasBody()) return null + + return object : RequestBody() { + override fun contentLength() = bodyLength + + override fun contentType() = contentType + + override fun writeTo(sink: BufferedSink) { + OkHttpBodyWriter(sink, listener).use { + delegate.onWriteRequestBody(it) + } + } + } + } + + private fun request(delegate: HttpRequest.RequestBodyDelegate, listener: BodyWriter.OnStreamWriteListener) = requestBuilder + .method(httpMethod, createBody(delegate, listener)) + .build() + + @Throws(IOException::class) + override fun getResponse(delegate: HttpRequest.RequestBodyDelegate, listener: BodyWriter.OnStreamWriteListener) = use { + httpClient.newCall(request(delegate, listener)) + .execute() + .use { it.asServerResponse() } + } + + override fun close() { + // Resources are automatically freed after usage. Log only. + UploadServiceLogger.debug(javaClass.simpleName) { + "(uploadID: $uploadId) closing OkHttp connection (uuid: $uuid)" + } + } +} diff --git a/uploadservice/build.gradle b/uploadservice/build.gradle index bcb7f288..27b5a90e 100644 --- a/uploadservice/build.gradle +++ b/uploadservice/build.gradle @@ -1,18 +1,34 @@ apply plugin: 'com.android.library' -apply plugin: 'com.github.dcendents.android-maven' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' apply plugin: 'com.jfrog.bintray' apply plugin: 'com.github.ben-manes.versions' +Properties properties = new Properties() +if (project.rootProject.file("local.properties").exists()) { + properties.load(project.rootProject.file('local.properties').newDataInputStream()) +} + // start - do not modify this if your project is on github -def siteUrl = "https://github.com/${github_username}/${github_repository_name}" -def gitUrl = siteUrl + '.git' -def bugTrackerUrl = siteUrl + '/issues/' -def projectName = github_repository_name +project.ext{ + mavDevelopers = [(properties.getProperty("bintray.user")):(maintainer)] + mavSiteUrl = "https://github.com/${github_username}/${github_repository_name}" + mavGitUrl = mavSiteUrl + '.git' + bugTrackerUrl = mavSiteUrl + '/issues/' + mavProjectName = bintray_project_name + mavLibraryLicenses = ["Apache-2.0": 'http://www.apache.org/licenses/LICENSE-2.0.txt'] + mavLibraryDescription = library_description + mavVersion = library_version +} // end - do not modify this if your project is on github group = library_project_group version = library_version +androidExtensions { + experimental = true +} + android { compileSdkVersion target_sdk @@ -62,62 +78,20 @@ dependencies { // Support implementation "androidx.appcompat:appcompat:$androidx_appcompat_version" -} - -// add the following information to the file: local.properties situated in the parent directory of -// where this file is: -// -// bintray.user=gotev -// bintray.apikey=api key got from the bintray profile -// -// be sure to add local.properties to the .gitignore! -Properties properties = new Properties() -if (project.rootProject.file("local.properties").exists()) { - properties.load(project.rootProject.file('local.properties').newDataInputStream()) -} - -install { - repositories.mavenInstaller { - pom.project { - name projectName - description library_description - packaging 'aar' - groupId library_project_group - version version - url siteUrl - licenses { - license { - name library_licenses[0] - url library_licenses_url - } - } - developers { - developer { - id properties.getProperty("bintray.user") - name maintainer - } - } - scm { - connection gitUrl - developerConnection gitUrl - url siteUrl - - } - } - } + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } bintray { user = properties.getProperty("bintray.user") key = properties.getProperty("bintray.apikey") - configurations = ['archives'] + publications = ['mavenPublish'] pkg { repo = "maven" - name = projectName + name = mavProjectName desc = library_description - websiteUrl = siteUrl - vcsUrl = gitUrl + websiteUrl = mavSiteUrl + vcsUrl = mavGitUrl issueTrackerUrl = bugTrackerUrl licenses = library_licenses labels = library_keywords @@ -126,37 +100,4 @@ bintray { } } -task sourcesJar(type: Jar) { - from android.sourceSets.main.java.srcDirs - classifier = 'sources' -} - -task javadoc(type: Javadoc) { - excludes = ['**/*.kt'] // < ---- Exclude all kotlin files from javadoc file. - - title = "$projectName $project.version API" - description "Generates Javadoc" - source = android.sourceSets.main.java.srcDirs - classpath += files(android.bootClasspath) - exclude '**/BuildConfig.java', '**/R.java' - options { - windowTitle("$projectName $project.version Reference") - locale = 'en_US' - encoding = 'UTF-8' - charSet = 'UTF-8' - links("http://docs.oracle.com/javase/7/docs/api/") - linksOffline("http://d.android.com/reference", "${android.sdkDirectory}/docs/reference") - setMemberLevel(JavadocMemberLevel.PUBLIC) - addStringOption('Xdoclint:none', '-quiet') - } -} - -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' - from javadoc.destinationDir -} - -artifacts { - archives javadocJar - archives sourcesJar -} +apply from: 'https://raw.githubusercontent.com/sky-uk/gradle-maven-plugin/master/gradle-mavenizer.gradle' diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/BinaryUploadRequest.java b/uploadservice/src/main/java/net/gotev/uploadservice/BinaryUploadRequest.java deleted file mode 100644 index b06f13b6..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/BinaryUploadRequest.java +++ /dev/null @@ -1,101 +0,0 @@ -package net.gotev.uploadservice; - -import android.content.Context; - -import java.io.FileNotFoundException; -import java.net.MalformedURLException; -import java.util.List; - -/** - * Binary file upload request. The binary upload uses a single file as the raw body of the - * upload request. - * - * @author cankov - * @author gotev (Aleksandar Gotev) - */ -public class BinaryUploadRequest extends HttpUploadRequest { - - /** - * Creates a binary file upload request. - * - * @param context application context - * @param uploadId unique ID to assign to this upload request.
- * It can be whatever string you want, as long as it's unique. - * If you set it to null or an empty string, an UUID will be automatically - * generated.
It's advised to keep a reference to it in your code, - * so when you receive status updates in {@link UploadServiceBroadcastReceiver}, - * you know to which upload they refer to. - * @param serverUrl URL of the server side script that will handle the multipart form upload. - * E.g.: http://www.yourcompany.com/your/script - * @throws IllegalArgumentException if one or more arguments are not valid - * @throws MalformedURLException if the server URL is not valid - */ - public BinaryUploadRequest(final Context context, final String uploadId, final String serverUrl) - throws IllegalArgumentException, MalformedURLException { - super(context, uploadId, serverUrl); - } - - /** - * Creates a new binaryupload request and automatically generates an upload id, that will - * be returned when you call {@link HttpUploadRequest#startUpload()}. - * - * @param context application context - * @param serverUrl URL of the server side script that will handle the multipart form upload. - * E.g.: http://www.yourcompany.com/your/script - * @throws IllegalArgumentException if one or more arguments are not valid - * @throws MalformedURLException if the server URL is not valid - */ - public BinaryUploadRequest(final Context context, final String serverUrl) - throws MalformedURLException, IllegalArgumentException { - this(context, null, serverUrl); - } - - @Override - protected Class getTaskClass() { - return BinaryUploadTask.class; - } - - /** - * Sets the file used as raw body of the upload request. - * - * @param path path to the file that you want to upload - * @throws FileNotFoundException if the file to upload does not exist - * @return {@link BinaryUploadRequest} - */ - public BinaryUploadRequest setFileToUpload(String path) throws FileNotFoundException { - params.files.clear(); - params.files.add(new UploadFile(path)); - return this; - } - - @Override - public BinaryUploadRequest addParameter(String paramName, String paramValue) { - logDoesNotSupportParameters(); - return this; - } - - @Override - public BinaryUploadRequest addArrayParameter(String paramName, String... array) { - logDoesNotSupportParameters(); - return this; - } - - @Override - public BinaryUploadRequest addArrayParameter(String paramName, List list) { - logDoesNotSupportParameters(); - return this; - } - - @Override - public String startUpload() { - if (params.files.isEmpty()) - throw new IllegalArgumentException("Set the file to be used in the request body first!"); - - return super.startUpload(); - } - - private void logDoesNotSupportParameters() { - Logger.error(getClass().getSimpleName(), - "This upload method does not support adding parameters"); - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/BinaryUploadTask.java b/uploadservice/src/main/java/net/gotev/uploadservice/BinaryUploadTask.java deleted file mode 100644 index 979e199e..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/BinaryUploadTask.java +++ /dev/null @@ -1,30 +0,0 @@ -package net.gotev.uploadservice; - -import net.gotev.uploadservice.http.BodyWriter; - -import java.io.IOException; -import java.io.UnsupportedEncodingException; - -/** - * Implements a binary file upload task. - * - * @author cankov - * @author gotev (Aleksandar Gotev) - */ -public class BinaryUploadTask extends HttpUploadTask { - - @Override - protected long getBodyLength() throws UnsupportedEncodingException { - return params.files.get(0).length(service); - } - - @Override - public void onBodyReady(BodyWriter bodyWriter) throws IOException { - bodyWriter.writeStream(params.files.get(0).getStream(service), this); - } - - @Override - protected void onSuccessfulUpload() { - addSuccessfullyUploadedFile(params.files.get(0)); - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/BroadcastData.java b/uploadservice/src/main/java/net/gotev/uploadservice/BroadcastData.java deleted file mode 100644 index fd7e392e..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/BroadcastData.java +++ /dev/null @@ -1,113 +0,0 @@ -package net.gotev.uploadservice; - -import android.content.Intent; -import android.os.Parcel; -import android.os.Parcelable; - -/** - * Class which contains all the data passed in broadcast intents to notify task progress, errors, - * completion or cancellation. - * - * @author gotev (Aleksandar Gotev) - */ -class BroadcastData implements Parcelable { - - public enum Status { - IN_PROGRESS, - ERROR, - COMPLETED, - CANCELLED - } - - private Status status; - private Exception exception; - private UploadInfo uploadInfo; - private ServerResponse serverResponse; - - public BroadcastData() { - - } - - public Intent getIntent() { - Intent intent = new Intent(UploadService.getActionBroadcast()); - intent.setPackage(UploadService.NAMESPACE); - intent.putExtra(UploadService.PARAM_BROADCAST_DATA, this); - return intent; - } - - // This is used to regenerate the object. - // All Parcelables must have a CREATOR that implements these two methods - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { - @Override - public BroadcastData createFromParcel(final Parcel in) { - return new BroadcastData(in); - } - - @Override - public BroadcastData[] newArray(final int size) { - return new BroadcastData[size]; - } - }; - - @Override - public void writeToParcel(Parcel parcel, int flags) { - parcel.writeInt(status.ordinal()); - parcel.writeSerializable(exception); - parcel.writeParcelable(uploadInfo, flags); - parcel.writeParcelable(serverResponse, flags); - } - - private BroadcastData(Parcel in) { - status = Status.values()[in.readInt()]; - exception = (Exception) in.readSerializable(); - uploadInfo = in.readParcelable(UploadInfo.class.getClassLoader()); - serverResponse = in.readParcelable(ServerResponse.class.getClassLoader()); - } - - @Override - public int describeContents() { - return 0; - } - - public Status getStatus() { - if (status == null) { - Logger.error(getClass().getSimpleName(), "Status not defined! Returning " + Status.CANCELLED); - return Status.CANCELLED; - } - - return status; - } - - public BroadcastData setStatus(Status status) { - this.status = status; - return this; - } - - public Exception getException() { - return exception; - } - - public BroadcastData setException(Exception exception) { - this.exception = exception; - return this; - } - - public UploadInfo getUploadInfo() { - return uploadInfo; - } - - public BroadcastData setUploadInfo(UploadInfo uploadInfo) { - this.uploadInfo = uploadInfo; - return this; - } - - public ServerResponse getServerResponse() { - return serverResponse; - } - - public BroadcastData setServerResponse(ServerResponse serverResponse) { - this.serverResponse = serverResponse; - return this; - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/ContentType.java b/uploadservice/src/main/java/net/gotev/uploadservice/ContentType.java deleted file mode 100644 index 7cae097f..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/ContentType.java +++ /dev/null @@ -1,198 +0,0 @@ -package net.gotev.uploadservice; - -import android.webkit.MimeTypeMap; - -/** - * Static class containing string constants for the most common - * Internet content types. - * It's not meant to be a complete enumeration of all the known content types, - * so don't worry if you don't find what you're looking for. - * - * A complete and official list can be found here: - * https://www.iana.org/assignments/media-types - * - * - * @author gotev (Aleksandar Gotev) - * - */ -public final class ContentType { - - /** - * Private constructor to avoid instatiation. - */ - private ContentType() { } - - /** - * Tries to auto-detect the content type (MIME type) of a specific file. - * @param absolutePath absolute path to the file - * @return content type (MIME type) of the file, or application/octet-stream if no content - * type could be determined automatically - */ - public static String autoDetect(String absolutePath) { - String extension = null; - - int index = absolutePath.lastIndexOf(".") + 1; - - if (index >= 0 && index <= absolutePath.length()) { - extension = absolutePath.substring(index); - } - - if (extension == null || extension.isEmpty()) { - return APPLICATION_OCTET_STREAM; - } - - String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase()); - - if (mimeType == null) { - // mp4 does not always get recognized automatically - if ("mp4".equalsIgnoreCase(extension)) - return VIDEO_MPEG4; - - return APPLICATION_OCTET_STREAM; - } - - return mimeType; - } - - private static final String APPLICATION = "application/"; - - public static final String APPLICATION_ENVOY = APPLICATION + "envoy"; - public static final String APPLICATION_FRACTALS = APPLICATION + "fractals"; - public static final String APPLICATION_FUTURESPLASH = APPLICATION + "futuresplash"; - public static final String APPLICATION_HTA = APPLICATION + "hta"; - public static final String APPLICATION_INTERNET_PROPERTY_STREAM = APPLICATION + "internet-property-stream"; - public static final String APPLICATION_MAC_BINHEX40 = APPLICATION + "mac-binhex40"; - public static final String APPLICATION_MS_WORD = APPLICATION + "msword"; - public static final String APPLICATION_OCTET_STREAM = APPLICATION + "octet-stream"; - public static final String APPLICATION_ODA = APPLICATION + "oda"; - public static final String APPLICATION_OLESCRIPT = APPLICATION + "olescript"; - public static final String APPLICATION_PDF = APPLICATION + "pdf"; - public static final String APPLICATION_PICS_RULES = APPLICATION + "pics-rules"; - public static final String APPLICATION_PKCS10 = APPLICATION + "pkcs10"; - public static final String APPLICATION_PKIX_CRL = APPLICATION + "pkix-crl"; - public static final String APPLICATION_POSTSCRIPT = APPLICATION + "postscript"; - public static final String APPLICATION_RTF = APPLICATION + "rtf"; - public static final String APPLICATION_SETPAY = APPLICATION + "set-payment-initiation"; - public static final String APPLICATION_SETREG = APPLICATION + "set-registration-initiation"; - public static final String APPLICATION_MS_EXCEL = APPLICATION + "vnd.ms-excel"; - public static final String APPLICATION_MS_OUTLOOK = APPLICATION + "vnd.ms-outlook"; - public static final String APPLICATION_MS_PKICERTSTORE = APPLICATION + "vnd.ms-pkicertstore"; - public static final String APPLICATION_MS_PKISECCAT = APPLICATION + "vnd.ms-pkiseccat"; - public static final String APPLICATION_MS_PKISTL = APPLICATION + "vnd.ms-pkistl"; - public static final String APPLICATION_MS_POWERPOINT = APPLICATION + "vnd.ms-powerpoint"; - public static final String APPLICATION_MS_PROJECT = APPLICATION + "vnd.ms-project"; - public static final String APPLICATION_MS_WORKS = APPLICATION + "vnd.ms-works"; - public static final String APPLICATION_WINHLP = APPLICATION + "winhlp"; - public static final String APPLICATION_BCPIO = APPLICATION + "x-bcpio"; - public static final String APPLICATION_CDF = APPLICATION + "x-cdf"; - public static final String APPLICATION_Z = APPLICATION + "x-compress"; - public static final String APPLICATION_TGZ = APPLICATION + "x-compressed"; - public static final String APPLICATION_CPIO = APPLICATION + "x-cpio"; - public static final String APPLICATION_CSH = APPLICATION + "x-csh"; - public static final String APPLICATION_DIRECTOR = APPLICATION + "x-director"; - public static final String APPLICATION_DVI = APPLICATION + "x-dvi"; - public static final String APPLICATION_GTAR = APPLICATION + "x-gtar"; - public static final String APPLICATION_GZIP = APPLICATION + "x-gzip"; - public static final String APPLICATION_HDF = APPLICATION + "x-hdf"; - public static final String APPLICATION_INTERNET_SIGNUP = APPLICATION + "x-internet-signup"; - public static final String APPLICATION_IPHONE = APPLICATION + "x-iphone"; - public static final String APPLICATION_JAVASCRIPT = APPLICATION + "x-javascript"; - public static final String APPLICATION_LATEX = APPLICATION + "x-latex"; - public static final String APPLICATION_MS_ACCESS = APPLICATION + "x-msaccess"; - public static final String APPLICATION_MS_CARD_FILE = APPLICATION + "x-mscardfile"; - public static final String APPLICATION_MS_CLIP = APPLICATION + "x-msclip"; - public static final String APPLICATION_MS_DOWNLOAD = APPLICATION + "x-msdownload"; - public static final String APPLICATION_MS_MEDIAVIEW = APPLICATION + "x-msmediaview"; - public static final String APPLICATION_MS_METAFILE = APPLICATION + "x-msmetafile"; - public static final String APPLICATION_MS_MONEY = APPLICATION + "x-msmoney"; - public static final String APPLICATION_MS_PUBLISHER = APPLICATION + "x-mspublisher"; - public static final String APPLICATION_MS_SCHEDULE = APPLICATION + "x-msschedule"; - public static final String APPLICATION_MS_TERMINAL = APPLICATION + "x-msterminal"; - public static final String APPLICATION_MS_WRITE = APPLICATION + "x-mswrite"; - public static final String APPLICATION_NET_CDF = APPLICATION + "x-netcdf"; - public static final String APPLICATION_PERFMON = APPLICATION + "x-perfmon"; - public static final String APPLICATION_PKCS_12 = APPLICATION + "x-pkcs12"; - public static final String APPLICATION_PKCS_7_CERTIFICATES = APPLICATION + "x-pkcs7-certificates"; - public static final String APPLICATION_PKCS_7_CERTREQRESP = APPLICATION + "x-pkcs7-certreqresp"; - public static final String APPLICATION_PKCS_7_MIME = APPLICATION + "x-pkcs7-mime"; - public static final String APPLICATION_PKCS_7_SIGNATURE = APPLICATION + "x-pkcs7-signature"; - public static final String APPLICATION_SH = APPLICATION + "x-sh"; - public static final String APPLICATION_SHAR = APPLICATION + "x-shar"; - public static final String APPLICATION_SHOCKWAVE_FLASH = APPLICATION + "x-shockwave-flash"; - public static final String APPLICATION_STUFFIT = APPLICATION + "x-stuffit"; - public static final String APPLICATION_SV4CPIO = APPLICATION + "x-sv4cpio"; - public static final String APPLICATION_SV4CRC = APPLICATION + "x-sv4crc"; - public static final String APPLICATION_TAR = APPLICATION + "x-tar"; - public static final String APPLICATION_TCL = APPLICATION + "x-tcl"; - public static final String APPLICATION_TEX = APPLICATION + "x-tex"; - public static final String APPLICATION_TEXINFO = APPLICATION + "x-texinfo"; - public static final String APPLICATION_TROFF = APPLICATION + "x-troff"; - public static final String APPLICATION_TROFF_MAN = APPLICATION + "x-troff-man"; - public static final String APPLICATION_TROFF_ME = APPLICATION + "x-troff-me"; - public static final String APPLICATION_TROFF_MS = APPLICATION + "x-troff-ms"; - public static final String APPLICATION_USTAR = APPLICATION + "x-ustar"; - public static final String APPLICATION_WAIS_SOURCE = APPLICATION + "x-wais-source"; - public static final String APPLICATION_X509_CA_CERT = APPLICATION + "x-x509-ca-cert"; - public static final String APPLICATION_PKO = APPLICATION + "ynd.ms-pkipko"; - public static final String APPLICATION_ZIP = APPLICATION + "zip"; - public static final String APPLICATION_XML = APPLICATION + "xml"; - - private static final String AUDIO = "audio/"; - - public static final String AUDIO_BASIC = AUDIO + "basic"; - public static final String AUDIO_MID = AUDIO + "mid"; - public static final String AUDIO_MPEG = AUDIO + "mpeg"; - public static final String AUDIO_AIFF = AUDIO + "x-aiff"; - public static final String AUDIO_M3U = AUDIO + "x-mpegurl"; - public static final String AUDIO_REAL_AUDIO = AUDIO + "x-pn-realaudio"; - public static final String AUDIO_WAV = AUDIO + "x-wav"; - - private static final String IMAGE = "image/"; - - public static final String IMAGE_BMP = IMAGE + "bmp"; - public static final String IMAGE_COD = IMAGE + "cod"; - public static final String IMAGE_GIF = IMAGE + "gif"; - public static final String IMAGE_IEF = IMAGE + "ief"; - public static final String IMAGE_JPEG = IMAGE + "jpeg"; - public static final String IMAGE_PIPEG = IMAGE + "pipeg"; - public static final String IMAGE_SVG = IMAGE + "svg+xml"; - public static final String IMAGE_TIFF = IMAGE + "tiff"; - public static final String IMAGE_CMU_RASTER = IMAGE + "x-cmu-raster"; - public static final String IMAGE_CMX = IMAGE + "x-cmx"; - public static final String IMAGE_ICO = IMAGE + "x-icon"; - public static final String IMAGE_PORTABLE_ANYMAP = IMAGE + "x-portable-anymap"; - public static final String IMAGE_PORTABLE_BITMAP = IMAGE + "x-portable-bitmap"; - public static final String IMAGE_PORTABLE_GRAYMAP = IMAGE + "x-portable-graymap"; - public static final String IMAGE_PORTABLE_PIXMAP = IMAGE + "x-portable-pixmap"; - public static final String IMAGE_XRGB = IMAGE + "x-rgb"; - public static final String IMAGE_XBITMAP = IMAGE + "x-xbitmap"; - public static final String IMAGE_XPIXMAP = IMAGE + "x-xpixmap"; - public static final String IMAGE_XWINDOWDUMP = IMAGE + "x-xwindowdump"; - - private static final String TEXT = "text/"; - - public static final String TEXT_CSS = TEXT + "css"; - public static final String TEXT_CSV = TEXT + "csv"; - public static final String TEXT_H323 = TEXT + "h323"; - public static final String TEXT_HTML = TEXT + "html"; - public static final String TEXT_IULS = TEXT + "iuls"; - public static final String TEXT_PLAIN = TEXT + "plain"; - public static final String TEXT_RICHTEXT = TEXT + "richtext"; - public static final String TEXT_SCRIPTLET = TEXT + "scriptlet"; - public static final String TEXT_TAB_SEPARATED_VALUES = TEXT + "tab-separated-values"; - public static final String TEXT_VIEWVIEW = TEXT + "webviewhtml"; - public static final String TEXT_COMPONENT = TEXT + "x-component"; - public static final String TEXT_SETEXT = TEXT + "x-setext"; - public static final String TEXT_VCARD = TEXT + "x-vcard"; - public static final String TEXT_XML = TEXT + "xml"; - - private static final String VIDEO = "video/"; - - public static final String VIDEO_MPEG = VIDEO + "mpeg"; - public static final String VIDEO_MPEG4 = VIDEO + "mp4"; - public static final String VIDEO_QUICKTIME = VIDEO + "quicktime"; - public static final String VIDEO_LA_ASF = VIDEO + "x-la-asf"; - public static final String VIDEO_MS_ASF = VIDEO + "x-ms-asf"; - public static final String VIDEO_AVI = VIDEO + "avi"; - public static final String VIDEO_MOVIE = VIDEO + "x-sgi-movie"; -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/DefaultLoggerDelegate.java b/uploadservice/src/main/java/net/gotev/uploadservice/DefaultLoggerDelegate.java deleted file mode 100644 index 996c4af2..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/DefaultLoggerDelegate.java +++ /dev/null @@ -1,33 +0,0 @@ -package net.gotev.uploadservice; - -import android.util.Log; - -/** - * Default logger delegate implementation which logs in LogCat with {@link Log}. - * Log tag is set to UploadService for all the logs. - * @author gotev (Aleksandar Gotev) - */ -public class DefaultLoggerDelegate implements Logger.LoggerDelegate { - - private static final String TAG = "UploadService"; - - @Override - public void error(String tag, String message) { - Log.e(TAG, tag + " - " + message); - } - - @Override - public void error(String tag, String message, Throwable exception) { - Log.e(TAG, tag + " - " + message, exception); - } - - @Override - public void debug(String tag, String message) { - Log.d(TAG, tag + " - " + message); - } - - @Override - public void info(String tag, String message) { - Log.i(TAG, tag + " - " + message); - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadRequest.java b/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadRequest.java deleted file mode 100644 index 5fff5219..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadRequest.java +++ /dev/null @@ -1,157 +0,0 @@ -package net.gotev.uploadservice; - -import android.content.Context; -import android.content.Intent; -import android.util.Base64; - -import java.net.MalformedURLException; -import java.net.URL; -import java.util.List; - -/** - * Represents a generic HTTP upload request.
- * Subclass to create your own custom HTTP upload request. - * - * @author gotev (Aleksandar Gotev) - * @author eliasnaur - * @author cankov - */ -public abstract class HttpUploadRequest> - extends UploadRequest { - - protected final HttpUploadTaskParameters httpParams = new HttpUploadTaskParameters(); - - /** - * Creates a new http upload request. - * - * @param context application context - * @param uploadId unique ID to assign to this upload request. If is null or empty, a random - * UUID will be automatically generated. It's used in the broadcast receiver - * when receiving updates. - * @param serverUrl URL of the server side script that handles the request - * @throws IllegalArgumentException if one or more arguments are not valid - * @throws MalformedURLException if the server URL is not valid - */ - public HttpUploadRequest(final Context context, final String uploadId, final String serverUrl) - throws MalformedURLException, IllegalArgumentException{ - super(context, uploadId, serverUrl); - - if (!params.serverUrl.startsWith("http://") - && !params.serverUrl.startsWith("https://")) { - throw new IllegalArgumentException("Specify either http:// or https:// as protocol"); - } - - // Check if the URL is valid - new URL(params.serverUrl); - } - - @Override - protected void initializeIntent(Intent intent) { - super.initializeIntent(intent); - intent.putExtra(HttpUploadTaskParameters.PARAM_HTTP_TASK_PARAMETERS, httpParams); - } - - /** - * Adds a header to this upload request. - * - * @param headerName header name - * @param headerValue header value - * @return self instance - */ - public B addHeader(final String headerName, final String headerValue) { - httpParams.addHeader(headerName, headerValue); - return self(); - } - - /** - * Sets the HTTP Basic Authentication header. - * @param username HTTP Basic Auth username - * @param password HTTP Basic Auth password - * @return self instance - */ - public B setBasicAuth(final String username, final String password) { - String auth = Base64.encodeToString((username + ":" + password).getBytes(), Base64.NO_WRAP); - httpParams.addHeader("Authorization", "Basic " + auth); - return self(); - } - - /** - * Adds a parameter to this upload request. - * - * @param paramName parameter name - * @param paramValue parameter value - * @return self instance - */ - public B addParameter(final String paramName, final String paramValue) { - httpParams.addParameter(paramName, paramValue); - return self(); - } - - /** - * Adds a parameter with multiple values to this upload request. - * - * @param paramName parameter name - * @param array values - * @return self instance - */ - public B addArrayParameter(final String paramName, final String... array) { - for (String value : array) { - httpParams.addParameter(paramName, value); - } - return self(); - } - - /** - * Adds a parameter with multiple values to this upload request. - * - * @param paramName parameter name - * @param list values - * @return self instance - */ - public B addArrayParameter(final String paramName, final List list) { - for (String value : list) { - httpParams.addParameter(paramName, value); - } - return self(); - } - - /** - * Sets the HTTP method to use. By default it's set to POST. - * - * @param method new HTTP method to use - * @return self instance - */ - public B setMethod(final String method) { - httpParams.method = method.toUpperCase(); - return self(); - } - - /** - * Sets the custom user agent to use for this upload request. - * Note! If you set the "User-Agent" header by using the "addHeader" method, - * that setting will be overwritten by the value set with this method. - * - * @param customUserAgent custom user agent string - * @return self instance - */ - public B setCustomUserAgent(String customUserAgent) { - if (customUserAgent != null && !customUserAgent.isEmpty()) { - httpParams.customUserAgent = customUserAgent; - } - return self(); - } - - /** - * Sets if this upload request is using fixed length streaming mode. - * If it uses fixed length streaming mode, then the value returned by - * {@link HttpUploadTask#getBodyLength()} will be automatically used to properly set the - * underlying {@link java.net.HttpURLConnection}, otherwise chunked streaming mode will be used. - * @param fixedLength true to use fixed length streaming mode (this is the default setting) or - * false to use chunked streaming mode. - * @return self instance - */ - public B setUsesFixedLengthStreamingMode(boolean fixedLength) { - httpParams.usesFixedLengthStreamingMode = fixedLength; - return self(); - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadRequest.kt b/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadRequest.kt new file mode 100644 index 00000000..a90de84a --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadRequest.kt @@ -0,0 +1,141 @@ +package net.gotev.uploadservice + +import android.content.Context +import android.os.Parcelable +import android.util.Base64 +import net.gotev.uploadservice.data.HttpUploadTaskParameters +import net.gotev.uploadservice.data.NameValue +import net.gotev.uploadservice.extensions.addHeader +import net.gotev.uploadservice.extensions.isValidHttpUrl +import java.util.* + +/** + * Represents a generic HTTP upload request.

+ * Subclass to create your own custom HTTP upload request. + * @param context application context + * @param serverUrl URL of the server side script that handles the request + */ +abstract class HttpUploadRequest>(context: Context, serverUrl: String) : UploadRequest(context, serverUrl) { + + protected val httpParams = HttpUploadTaskParameters() + + init { + require(serverUrl.isValidHttpUrl()) { "Specify either http:// or https:// as protocol" } + } + + override fun getAdditionalParameters(): Parcelable { + return httpParams + } + + /** + * Adds a header to this upload request. + * + * @param headerName header name + * @param headerValue header value + * @return self instance + */ + fun addHeader(headerName: String, headerValue: String): B { + httpParams.requestHeaders.addHeader(headerName, headerValue) + return self() + } + + /** + * Sets the HTTP Basic Authentication header. + * @param username HTTP Basic Auth username + * @param password HTTP Basic Auth password + * @return self instance + */ + fun setBasicAuth(username: String, password: String): B { + val auth = Base64.encodeToString("$username:$password".toByteArray(), Base64.NO_WRAP) + return addHeader("Authorization", "Basic $auth") + } + + /** + * Sets HTTP Bearer authentication with a token. + * @param bearerToken bearer authorization token + * @return self instance + */ + fun setBearerAuth(bearerToken: String): B { + return addHeader("Authorization", "Bearer $bearerToken") + } + + /** + * Adds a parameter to this upload request. + * + * @param paramName parameter name + * @param paramValue parameter value + * @return self instance + */ + open fun addParameter(paramName: String, paramValue: String): B { + httpParams.requestParameters.add(NameValue(paramName, paramValue)) + return self() + } + + /** + * Adds a parameter with multiple values to this upload request. + * + * @param paramName parameter name + * @param array values + * @return self instance + */ + open fun addArrayParameter(paramName: String, vararg array: String): B { + for (value in array) { + httpParams.requestParameters.add(NameValue(paramName, value)) + } + return self() + } + + /** + * Adds a parameter with multiple values to this upload request. + * + * @param paramName parameter name + * @param list values + * @return self instance + */ + open fun addArrayParameter(paramName: String, list: List): B { + for (value in list) { + httpParams.requestParameters.add(NameValue(paramName, value)) + } + return self() + } + + /** + * Sets the HTTP method to use. By default it's set to POST. + * + * @param method new HTTP method to use + * @return self instance + */ + fun setMethod(method: String): B { + httpParams.method = method.toUpperCase(Locale.ROOT) + return self() + } + + /** + * Sets the custom user agent to use for this upload request. + * Note! If you set the "User-Agent" header by using the "addHeader" method, + * that setting will be overwritten by the value set with this method. + * + * @param customUserAgent custom user agent string + * @return self instance + */ + fun setCustomUserAgent(customUserAgent: String): B { + if (customUserAgent.isNotBlank()) { + httpParams.customUserAgent = customUserAgent + } + return self() + } + + /** + * Sets if this upload request is using fixed length streaming mode. + * If it uses fixed length streaming mode, then the value returned by + * [HttpUploadTask.getBodyLength] will be automatically used to properly set the + * underlying [java.net.HttpURLConnection], otherwise chunked streaming mode will be used. + * @param fixedLength true to use fixed length streaming mode (this is the default setting) or + * false to use chunked streaming mode. + * @return self instance + */ + fun setUsesFixedLengthStreamingMode(fixedLength: Boolean): B { + httpParams.usesFixedLengthStreamingMode = fixedLength + return self() + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTask.java b/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTask.java deleted file mode 100644 index d55b3725..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTask.java +++ /dev/null @@ -1,112 +0,0 @@ -package net.gotev.uploadservice; - -import android.annotation.SuppressLint; -import android.content.Intent; - -import net.gotev.uploadservice.http.BodyWriter; -import net.gotev.uploadservice.http.HttpConnection; - -import java.io.IOException; -import java.io.UnsupportedEncodingException; - -/** - * Generic HTTP Upload Task.
- * Subclass to create your custom upload task. - * - * @author cankov - * @author gotev (Aleksandar Gotev) - * @author mabdurrahman - */ -public abstract class HttpUploadTask extends UploadTask - implements HttpConnection.RequestBodyDelegate, BodyWriter.OnStreamWriteListener { - - private static final String LOG_TAG = HttpUploadTask.class.getSimpleName(); - - /** - * Contains all the parameters set in {@link HttpUploadRequest}. - */ - protected HttpUploadTaskParameters httpParams = null; - - /** - * {@link HttpConnection} used to perform the upload task. - */ - private HttpConnection connection; - - @Override - protected void init(UploadService service, Intent intent) throws IOException { - super.init(service, intent); - this.httpParams = intent.getParcelableExtra(HttpUploadTaskParameters.PARAM_HTTP_TASK_PARAMETERS); - } - - /** - * Implementation of the upload logic.
- * If you want to take advantage of the automations which Android Upload Service provides, - * do not override or change the implementation of this method in your subclasses. If you do, - * you have full control on how the upload is done, so for example you can use your custom - * http stack, but you have to manually setup the request to the server with everything you - * set in your {@link HttpUploadRequest} subclass and to get the response from the server. - * - * @throws Exception if an error occurs - */ - @SuppressLint("NewApi") - protected void upload() throws Exception { - - Logger.debug(LOG_TAG, "Starting upload task with ID " + params.id); - - try { - getSuccessfullyUploadedFiles().clear(); - uploadedBytes = 0; - totalBytes = getBodyLength(); - - if (httpParams.isCustomUserAgentDefined()) { - httpParams.addHeader("User-Agent", httpParams.customUserAgent); - } else { - httpParams.addHeader("User-Agent", "AndroidUploadService/" + BuildConfig.VERSION_NAME); - } - - connection = UploadService.HTTP_STACK - .createNewConnection(httpParams.method, params.serverUrl) - .setHeaders(httpParams.getRequestHeaders()) - .setTotalBodyBytes(totalBytes, httpParams.usesFixedLengthStreamingMode); - - final ServerResponse response = connection.getResponse(this); - Logger.debug(LOG_TAG, "Server responded with HTTP " + response.getHttpCode() - + " to upload with ID: " + params.id); - - // Broadcast completion only if the user has not cancelled the operation. - // It may happen that when the body is not completely written and the client - // closes the connection, no exception is thrown here, and the server responds - // with an HTTP status code. Without this, what happened was that completion was - // broadcasted and then the cancellation. That behaviour was not desirable as the - // library user couldn't execute code on user cancellation. - if (shouldContinue) { - broadcastCompleted(response); - } - - } finally { - if (connection != null) - connection.close(); - } - } - - /** - * Implement in subclasses to provide the expected upload in the progress notifications. - * @return The expected size of the http request body. - * @throws UnsupportedEncodingException - */ - protected abstract long getBodyLength() throws UnsupportedEncodingException; - - // BodyWriter.OnStreamWriteListener methods implementation - - @Override - public boolean shouldContinueWriting() { - return shouldContinue; - } - - @Override - public void onBytesWritten(int bytesWritten) { - uploadedBytes += bytesWritten; - broadcastProgress(uploadedBytes, totalBytes); - } - -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTask.kt b/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTask.kt new file mode 100644 index 00000000..9e9bf481 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTask.kt @@ -0,0 +1,88 @@ +package net.gotev.uploadservice + +import android.annotation.SuppressLint +import net.gotev.uploadservice.data.HttpUploadTaskParameters +import net.gotev.uploadservice.extensions.addHeader +import net.gotev.uploadservice.logger.UploadServiceLogger +import net.gotev.uploadservice.network.BodyWriter +import net.gotev.uploadservice.network.HttpRequest +import net.gotev.uploadservice.network.HttpStack +import java.io.UnsupportedEncodingException + +/** + * Generic HTTP Upload Task. + * Subclass to create your custom upload task. + */ +abstract class HttpUploadTask : UploadTask(), HttpRequest.RequestBodyDelegate, BodyWriter.OnStreamWriteListener { + + protected val httpParams: HttpUploadTaskParameters + get() = params.additionalParameters as HttpUploadTaskParameters + + /** + * Implement in subclasses to provide the expected upload in the progress notifications. + * @return The expected size of the http request body. + * @throws UnsupportedEncodingException + */ + abstract val bodyLength: Long + + /** + * Implementation of the upload logic.

+ * If you want to take advantage of the automations which Android Upload Service provides, + * do not override or change the implementation of this method in your subclasses. If you do, + * you have full control on how the upload is done, so for example you can use your custom + * http stack, but you have to manually setup the request to the server with everything you + * set in your [HttpUploadRequest] subclass and to get the response from the server. + * + * @throws Exception if an error occurs + */ + @SuppressLint("NewApi") + @Throws(Exception::class) + override fun upload(httpStack: HttpStack) { + UploadServiceLogger.debug(javaClass.simpleName) { "Starting upload task with ID ${params.id}" } + + val httpParams = httpParams.apply { + val userAgent = httpParams.customUserAgent + + requestHeaders.addHeader( + name = "User-Agent", + value = if (userAgent.isNullOrBlank()) { + "AndroidUploadService/" + BuildConfig.VERSION_NAME + } else { + userAgent + } + ) + } + + setAllFilesHaveBeenSuccessfullyUploaded(false) + totalBytes = bodyLength + + val response = httpStack.newRequest(params.id, httpParams.method, params.serverUrl) + .setHeaders(httpParams.requestHeaders.map { it.validateAsHeader() }) + .setTotalBodyBytes(totalBytes, httpParams.usesFixedLengthStreamingMode) + .getResponse(this, this) + + UploadServiceLogger.debug(javaClass.simpleName) { + "Server responded with code ${response.code} " + + "and body ${response.bodyString} to upload with ID: ${params.id}" + } + + // Broadcast completion only if the user has not cancelled the operation. + // It may happen that when the body is not completely written and the client + // closes the connection, no exception is thrown here, and the server responds + // with an HTTP status code. Without this, what happened was that completion was + // broadcasted and then the cancellation. That behaviour was not desirable as the + // library user couldn't execute code on user cancellation. + if (shouldContinue) { + if (response.isSuccessful) { + setAllFilesHaveBeenSuccessfullyUploaded() + } + onResponseReceived(response) + } + } + + override fun shouldContinueWriting() = shouldContinue + + final override fun onBytesWritten(bytesWritten: Int) { + onProgress(bytesWritten.toLong()) + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTaskParameters.java b/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTaskParameters.java deleted file mode 100644 index dbc1ec24..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/HttpUploadTaskParameters.java +++ /dev/null @@ -1,85 +0,0 @@ -package net.gotev.uploadservice; - -import android.os.Parcel; -import android.os.Parcelable; - -import java.util.ArrayList; - -/** - * Class which contains specific parameters for HTTP uploads. - * - * @author gotev (Aleksandar Gotev) - */ -public final class HttpUploadTaskParameters implements Parcelable { - - protected static final String PARAM_HTTP_TASK_PARAMETERS = "httpTaskParameters"; - - public String customUserAgent; - public String method = "POST"; - public boolean usesFixedLengthStreamingMode = true; - private ArrayList requestHeaders = new ArrayList<>(10); - private ArrayList requestParameters = new ArrayList<>(10); - - public HttpUploadTaskParameters() { - - } - - // This is used to regenerate the object. - // All Parcelables must have a CREATOR that implements these two methods - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { - @Override - public HttpUploadTaskParameters createFromParcel(final Parcel in) { - return new HttpUploadTaskParameters(in); - } - - @Override - public HttpUploadTaskParameters[] newArray(final int size) { - return new HttpUploadTaskParameters[size]; - } - }; - - @Override - public void writeToParcel(Parcel parcel, int arg1) { - parcel.writeString(method); - parcel.writeString(customUserAgent); - parcel.writeByte((byte) (usesFixedLengthStreamingMode ? 1 : 0)); - parcel.writeList(requestHeaders); - parcel.writeList(requestParameters); - } - - private HttpUploadTaskParameters(Parcel in) { - method = in.readString(); - customUserAgent = in.readString(); - usesFixedLengthStreamingMode = in.readByte() == 1; - in.readList(requestHeaders, NameValue.class.getClassLoader()); - in.readList(requestParameters, NameValue.class.getClassLoader()); - } - - @Override - public int describeContents() { - return 0; - } - - public boolean isCustomUserAgentDefined() { - return customUserAgent != null && !"".equals(customUserAgent); - } - - public HttpUploadTaskParameters addHeader(String name, String value) { - requestHeaders.add(NameValue.header(name, value)); - return this; - } - - public ArrayList getRequestHeaders() { - return requestHeaders; - } - - public HttpUploadTaskParameters addParameter(String name, String value) { - requestParameters.add(new NameValue(name, value)); - return this; - } - - public ArrayList getRequestParameters() { - return requestParameters; - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/Logger.java b/uploadservice/src/main/java/net/gotev/uploadservice/Logger.java deleted file mode 100644 index 2d64c96b..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/Logger.java +++ /dev/null @@ -1,87 +0,0 @@ -package net.gotev.uploadservice; - -import java.lang.ref.WeakReference; - -/** - * Android Upload Service library logger. - * You can provide your own logger delegate implementation, to be able to log in a different way. - * By default the log level is set to DEBUG when the build type is debug, and OFF in release. - * The default logger implementation logs in Android's LogCat. - * @author gotev (Aleksandar Gotev) - */ -public class Logger { - - public enum LogLevel { - DEBUG, - INFO, - ERROR, - OFF - } - - public interface LoggerDelegate { - void error(String tag, String message); - void error(String tag, String message, Throwable exception); - void debug(String tag, String message); - void info(String tag, String message); - } - - private LogLevel mLogLevel = BuildConfig.DEBUG ? LogLevel.DEBUG : LogLevel.OFF; - private static LoggerDelegate mDefaultLogger = new DefaultLoggerDelegate(); - private WeakReference mDelegate = new WeakReference<>(mDefaultLogger); - - private Logger() { } - - private static class SingletonHolder { - private static final Logger instance = new Logger(); - } - - public static void resetLoggerDelegate() { - synchronized (Logger.class) { - SingletonHolder.instance.mDelegate = new WeakReference<>(mDefaultLogger); - } - } - - public static void setLoggerDelegate(LoggerDelegate delegate) { - if (delegate == null) - throw new IllegalArgumentException("delegate MUST not be null!"); - - synchronized (Logger.class) { - SingletonHolder.instance.mDelegate = new WeakReference<>(delegate); - } - } - - public static void setLogLevel(LogLevel level) { - synchronized (Logger.class) { - SingletonHolder.instance.mLogLevel = level; - } - } - - private static boolean delegateIsDefinedAndLogLevelIsAtLeast(LogLevel level) { - return SingletonHolder.instance.mDelegate.get() != null - && SingletonHolder.instance.mLogLevel.compareTo(level) <= 0; - } - - public static void error(String tag, String message) { - if (delegateIsDefinedAndLogLevelIsAtLeast(LogLevel.ERROR)) { - SingletonHolder.instance.mDelegate.get().error(tag, message); - } - } - - public static void error(String tag, String message, Throwable exception) { - if (delegateIsDefinedAndLogLevelIsAtLeast(LogLevel.ERROR)) { - SingletonHolder.instance.mDelegate.get().error(tag, message, exception); - } - } - - public static void info(String tag, String message) { - if (delegateIsDefinedAndLogLevelIsAtLeast(LogLevel.INFO)) { - SingletonHolder.instance.mDelegate.get().info(tag, message); - } - } - - public static void debug(String tag, String message) { - if (delegateIsDefinedAndLogLevelIsAtLeast(LogLevel.DEBUG)) { - SingletonHolder.instance.mDelegate.get().debug(tag, message); - } - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/MultipartUploadRequest.java b/uploadservice/src/main/java/net/gotev/uploadservice/MultipartUploadRequest.java deleted file mode 100644 index 5b8bcca3..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/MultipartUploadRequest.java +++ /dev/null @@ -1,167 +0,0 @@ -package net.gotev.uploadservice; - -import android.content.Context; -import android.content.Intent; - -import java.io.FileNotFoundException; -import java.net.MalformedURLException; - -/** - * HTTP/Multipart upload request. This is the most common way to upload files on a server. - * It's the same kind of request that browsers do when you use the <form> tag - * with one or more files. - * - * @author gotev (Aleksandar Gotev) - * @author eliasnaur - * - */ -public class MultipartUploadRequest extends HttpUploadRequest { - - private static final String LOG_TAG = MultipartUploadRequest.class.getSimpleName(); - private boolean isUtf8Charset = false; - - /** - * Creates a new multipart upload request. - * - * @param context application context - * @param uploadId unique ID to assign to this upload request.
- * It can be whatever string you want, as long as it's unique. - * If you set it to null or an empty string, an UUID will be automatically - * generated.
It's advised to keep a reference to it in your code, - * so when you receive status updates in {@link UploadServiceBroadcastReceiver}, - * you know to which upload they refer to. - * @param serverUrl URL of the server side script that will handle the multipart form upload. - * E.g.: http://www.yourcompany.com/your/script - * @throws IllegalArgumentException if one or more arguments are not valid - * @throws MalformedURLException if the server URL is not valid - */ - public MultipartUploadRequest(final Context context, final String uploadId, final String serverUrl) - throws IllegalArgumentException, MalformedURLException { - super(context, uploadId, serverUrl); - } - - /** - * Creates a new multipart upload request and automatically generates an upload id, that will - * be returned when you call {@link UploadRequest#startUpload()}. - * - * @param context application context - * @param serverUrl URL of the server side script that will handle the multipart form upload. - * E.g.: http://www.yourcompany.com/your/script - * @throws IllegalArgumentException if one or more arguments are not valid - * @throws MalformedURLException if the server URL is not valid - */ - public MultipartUploadRequest(final Context context, final String serverUrl) - throws MalformedURLException, IllegalArgumentException { - this(context, null, serverUrl); - } - - @Override - protected void initializeIntent(Intent intent) { - super.initializeIntent(intent); - intent.putExtra(MultipartUploadTask.PARAM_UTF8_CHARSET, isUtf8Charset); - } - - @Override - protected Class getTaskClass() { - return MultipartUploadTask.class; - } - - /** - * Adds a file to this upload request. - * - * @param filePath path to the file that you want to upload - * @param parameterName Name of the form parameter that will contain file's data - * @param fileName File name seen by the server side script. If null, the original file name - * will be used - * @param contentType Content type of the file. You can use constants defined in - * {@link ContentType} class. Set this to null or empty string to try to - * automatically detect the mime type from the file. If the mime type can't - * be detected, {@code application/octet-stream} will be used by default - * @throws FileNotFoundException if the file does not exist at the specified path - * @throws IllegalArgumentException if one or more parameters are not valid - * @return {@link MultipartUploadRequest} - */ - public MultipartUploadRequest addFileToUpload(String filePath, - String parameterName, - String fileName, String contentType) - throws FileNotFoundException, IllegalArgumentException { - - UploadFile file = new UploadFile(filePath); - filePath = file.getPath(); - - if (parameterName == null || "".equals(parameterName)) { - throw new IllegalArgumentException("Please specify parameterName value for file: " - + filePath); - } - - file.setProperty(MultipartUploadTask.PROPERTY_PARAM_NAME, parameterName); - - if (contentType == null || contentType.isEmpty()) { - contentType = file.getContentType(context); - Logger.debug(LOG_TAG, "Auto-detected MIME type for " + filePath - + " is: " + contentType); - } else { - Logger.debug(LOG_TAG, "Content Type set for " + filePath - + " is: " + contentType); - } - - file.setProperty(MultipartUploadTask.PROPERTY_CONTENT_TYPE, contentType); - - if (fileName == null || "".equals(fileName)) { - fileName = file.getName(context); - Logger.debug(LOG_TAG, "Using original file name: " + fileName); - } else { - Logger.debug(LOG_TAG, "Using custom file name: " + fileName); - } - - file.setProperty(MultipartUploadTask.PROPERTY_REMOTE_FILE_NAME, fileName); - - params.files.add(file); - return this; - } - - /** - * Adds a file to this upload request, without setting the content type, which will be - * automatically detected from the file extension. If you want to - * manually set the content type, use {@link #addFileToUpload(String, String, String, String)}. - * @param path Absolute path to the file that you want to upload - * @param parameterName Name of the form parameter that will contain file's data - * @param fileName File name seen by the server side script. If null, the original file name - * will be used - * @return {@link MultipartUploadRequest} - * @throws FileNotFoundException if the file does not exist at the specified path - * @throws IllegalArgumentException if one or more parameters are not valid - */ - public MultipartUploadRequest addFileToUpload(final String path, final String parameterName, - final String fileName) - throws FileNotFoundException, IllegalArgumentException { - return addFileToUpload(path, parameterName, fileName, null); - } - - /** - * Adds a file to this upload request, without setting file name and content type. - * The original file name will be used instead. If you want to manually set the file name seen - * by the server side script and the content type, use - * {@link #addFileToUpload(String, String, String, String)} - * - * @param path Absolute path to the file that you want to upload - * @param parameterName Name of the form parameter that will contain file's data - * @throws FileNotFoundException if the file does not exist at the specified path - * @throws IllegalArgumentException if one or more parameters are not valid - * @return {@link MultipartUploadRequest} - */ - public MultipartUploadRequest addFileToUpload(final String path, final String parameterName) - throws FileNotFoundException, IllegalArgumentException { - return addFileToUpload(path, parameterName, null, null); - } - - /** - * Sets the charset for this multipart request to UTF-8. If not set, the standard US-ASCII - * charset will be used. - * @return request instance - */ - public MultipartUploadRequest setUtf8Charset() { - isUtf8Charset = true; - return this; - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/MultipartUploadTask.java b/uploadservice/src/main/java/net/gotev/uploadservice/MultipartUploadTask.java deleted file mode 100644 index e638a2fb..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/MultipartUploadTask.java +++ /dev/null @@ -1,156 +0,0 @@ -package net.gotev.uploadservice; - -import android.content.Intent; - -import net.gotev.uploadservice.http.BodyWriter; - -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.nio.charset.Charset; - -/** - * Implements an HTTP Multipart upload task. - * - * @author gotev (Aleksandar Gotev) - * @author eliasnaur - * @author cankov - */ -public class MultipartUploadTask extends HttpUploadTask { - - protected static final String PARAM_UTF8_CHARSET = "multipartUtf8Charset"; - - private static final String BOUNDARY_SIGNATURE = "-------AndroidUploadService"; - private static final Charset US_ASCII = Charset.forName("US-ASCII"); - private static final String NEW_LINE = "\r\n"; - private static final String TWO_HYPHENS = "--"; - - // properties associated to each file - protected static final String PROPERTY_REMOTE_FILE_NAME = "httpRemoteFileName"; - protected static final String PROPERTY_CONTENT_TYPE = "httpContentType"; - protected static final String PROPERTY_PARAM_NAME = "httpParamName"; - - private byte[] boundaryBytes; - private byte[] trailerBytes; - private Charset charset; - - @Override - protected void init(UploadService service, Intent intent) throws IOException { - super.init(service, intent); - - String boundary = BOUNDARY_SIGNATURE + System.nanoTime(); - boundaryBytes = (TWO_HYPHENS + boundary + NEW_LINE).getBytes(US_ASCII); - trailerBytes = (TWO_HYPHENS + boundary + TWO_HYPHENS + NEW_LINE).getBytes(US_ASCII); - charset = intent.getBooleanExtra(PARAM_UTF8_CHARSET, false) ? - Charset.forName("UTF-8") : US_ASCII; - - if (params.files.size() <= 1) { - httpParams.addHeader("Connection", "close"); - } else { - httpParams.addHeader("Connection", "Keep-Alive"); - } - - httpParams.addHeader("Content-Type", "multipart/form-data; boundary=" + boundary); - } - - @Override - protected long getBodyLength() throws UnsupportedEncodingException { - return (getRequestParametersLength() + getFilesLength() + trailerBytes.length); - } - - @Override - public void onBodyReady(BodyWriter bodyWriter) throws IOException { - //reset uploaded bytes when the body is ready to be written - //because sometimes this gets invoked when network changes - uploadedBytes = 0; - writeRequestParameters(bodyWriter); - writeFiles(bodyWriter); - bodyWriter.write(trailerBytes); - uploadedBytes += trailerBytes.length; - broadcastProgress(uploadedBytes, totalBytes); - } - - private long getFilesLength() throws UnsupportedEncodingException { - long total = 0; - - for (UploadFile file : params.files) { - total += getTotalMultipartBytes(file); - } - - return total; - } - - private long getRequestParametersLength() throws UnsupportedEncodingException { - long parametersBytes = 0; - - if (!httpParams.getRequestParameters().isEmpty()) { - for (final NameValue parameter : httpParams.getRequestParameters()) { - // the bytes needed for every parameter are the sum of the boundary bytes - // and the bytes occupied by the parameter - parametersBytes += boundaryBytes.length + getMultipartBytes(parameter).length; - } - } - - return parametersBytes; - } - - private byte[] getMultipartBytes(NameValue parameter) throws UnsupportedEncodingException { - return ("Content-Disposition: form-data; name=\"" + parameter.getName() + "\"" - + NEW_LINE + NEW_LINE + parameter.getValue() + NEW_LINE).getBytes(charset); - } - - private byte[] getMultipartHeader(UploadFile file) - throws UnsupportedEncodingException { - String header = "Content-Disposition: form-data; name=\"" + - file.getProperty(PROPERTY_PARAM_NAME) + "\"; filename=\"" + - file.getProperty(PROPERTY_REMOTE_FILE_NAME) + "\"" + NEW_LINE + - "Content-Type: " + file.getProperty(PROPERTY_CONTENT_TYPE) + - NEW_LINE + NEW_LINE; - - return header.getBytes(charset); - } - - private long getTotalMultipartBytes(UploadFile file) - throws UnsupportedEncodingException { - return boundaryBytes.length + getMultipartHeader(file).length + file.length(service) - + NEW_LINE.getBytes(charset).length; - } - - private void writeRequestParameters(BodyWriter bodyWriter) throws IOException { - if (!httpParams.getRequestParameters().isEmpty()) { - for (final NameValue parameter : httpParams.getRequestParameters()) { - bodyWriter.write(boundaryBytes); - byte[] formItemBytes = getMultipartBytes(parameter); - bodyWriter.write(formItemBytes); - - uploadedBytes += boundaryBytes.length + formItemBytes.length; - broadcastProgress(uploadedBytes, totalBytes); - } - } - } - - private void writeFiles(BodyWriter bodyWriter) throws IOException { - for (UploadFile file : params.files) { - if (!shouldContinue) - break; - - bodyWriter.write(boundaryBytes); - byte[] headerBytes = getMultipartHeader(file); - bodyWriter.write(headerBytes); - - uploadedBytes += boundaryBytes.length + headerBytes.length; - broadcastProgress(uploadedBytes, totalBytes); - - bodyWriter.writeStream(file.getStream(service), this); - - byte[] newLineBytes = NEW_LINE.getBytes(charset); - bodyWriter.write(newLineBytes); - uploadedBytes += newLineBytes.length; - } - } - - @Override - protected void onSuccessfulUpload() { - addAllFilesToSuccessfullyUploadedFiles(); - } - -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/NameValue.java b/uploadservice/src/main/java/net/gotev/uploadservice/NameValue.java deleted file mode 100644 index ed3cb4ef..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/NameValue.java +++ /dev/null @@ -1,96 +0,0 @@ -package net.gotev.uploadservice; - -import android.os.Parcel; -import android.os.Parcelable; - -/** - * Represents a request parameter. - * - * @author gotev (Aleksandar Gotev) - * - */ -public final class NameValue implements Parcelable { - - private final String name; - private final String value; - - public static NameValue header(final String name, final String value) { - if (!isAllASCII(name) || !isAllASCII(value)) - throw new IllegalArgumentException("Header " + name + " must be ASCII only! Read http://stackoverflow.com/a/4410331"); - - return new NameValue(name, value); - } - - public NameValue(final String name, final String value) { - this.name = name; - this.value = value; - } - - public final String getName() { - return name; - } - - public final String getValue() { - return value; - } - - @Override - public boolean equals(Object object) { - final boolean areEqual; - - if (object instanceof NameValue) { - final NameValue other = (NameValue) object; - areEqual = this.name.equals(other.name) && this.value.equals(other.value); - } else { - areEqual = false; - } - - return areEqual; - } - - // This is used to regenerate the object. - // All Parcelables must have a CREATOR that implements these two methods - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { - @Override - public NameValue createFromParcel(final Parcel in) { - return new NameValue(in); - } - - @Override - public NameValue[] newArray(final int size) { - return new NameValue[size]; - } - }; - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel parcel, int arg1) { - parcel.writeString(name); - parcel.writeString(value); - } - - private NameValue(Parcel in) { - name = in.readString(); - value = in.readString(); - } - - private static boolean isAllASCII(String input) { - if (input == null || input.isEmpty()) - return false; - - boolean isASCII = true; - for (int i = 0; i < input.length(); i++) { - int c = input.charAt(i); - if (c > 127) { - isASCII = false; - break; - } - } - return isASCII; - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/Placeholders.java b/uploadservice/src/main/java/net/gotev/uploadservice/Placeholders.java deleted file mode 100644 index ac91b3ff..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/Placeholders.java +++ /dev/null @@ -1,57 +0,0 @@ -package net.gotev.uploadservice; - -/** - * Contains all the placeholders that is possible to use in the notification text strings. - * @author Aleksandar Gotev - */ -public class Placeholders { - - /** - * Placeholder to display the total elapsed upload time in minutes and seconds. - * E.g.: 34s, 4m 33s, 45m 21s - */ - public static final String ELAPSED_TIME = "[[ELAPSED_TIME]]"; - - /** - * Placeholder to display the average upload rate. E.g.: 6 Mbit/s, 634 Kbit/s, 232 bit/s - */ - public static final String UPLOAD_RATE = "[[UPLOAD_RATE]]"; - - /** - * Placeholder to display the integer progress percent from 0 to 100. E.g.: 75% - */ - public static final String PROGRESS = "[[PROGRESS]]"; - - /** - * Placeholder to display the number of successfully uploaded files. - * Bear in mind that in case of HTTP/Multipart or Binary uploads which does not support - * resume, if the request gets restarted due to an error, the number of uploaded files will - * be reset to zero. - */ - public static final String UPLOADED_FILES = "[[UPLOADED_FILES]]"; - - /** - * Placeholder to display the total number of files to upload. - */ - public static final String TOTAL_FILES = "[[TOTAL_FILES]]"; - - /** - * Replace placeholders in a string. - * @param string string in which to replace placeholders - * @param uploadInfo upload information data - * @return string with replaced placeholders - */ - public static String replace(String string, UploadInfo uploadInfo) { - if (string == null || string.isEmpty()) - return ""; - - String tmp; - tmp = string.replace(ELAPSED_TIME, uploadInfo.getElapsedTimeString()); - tmp = tmp.replace(PROGRESS, uploadInfo.getProgressPercent() + "%"); - tmp = tmp.replace(UPLOAD_RATE, uploadInfo.getUploadRateString()); - tmp = tmp.replace(UPLOADED_FILES, Integer.toString(uploadInfo.getSuccessfullyUploadedFiles().size())); - tmp = tmp.replace(TOTAL_FILES, Integer.toString(uploadInfo.getTotalFiles())); - - return tmp; - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/Placeholders.kt b/uploadservice/src/main/java/net/gotev/uploadservice/Placeholders.kt new file mode 100644 index 00000000..4896ff2d --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/Placeholders.kt @@ -0,0 +1,51 @@ +package net.gotev.uploadservice + +import net.gotev.uploadservice.data.UploadInfo + +object Placeholders { + + /** + * Placeholder to display the total elapsed upload time in minutes and seconds. + * E.g.: 34s, 4m 33s, 45m 21s + */ + const val ELAPSED_TIME = "[[ELAPSED_TIME]]" + + /** + * Placeholder to display the average upload rate. E.g.: 6 Mbit/s, 634 Kbit/s, 232 bit/s + */ + const val UPLOAD_RATE = "[[UPLOAD_RATE]]" + + /** + * Placeholder to display the integer progress percent from 0 to 100. E.g.: 75% + */ + const val PROGRESS = "[[PROGRESS]]" + + /** + * Placeholder to display the number of successfully uploaded files. + * Bear in mind that in case of HTTP/Multipart or Binary uploads which does not support + * resume, if the request gets restarted due to an error, the number of uploaded files will + * be reset to zero. + */ + const val UPLOADED_FILES = "[[UPLOADED_FILES]]" + + /** + * Placeholder to display the total number of files to upload. + */ + const val TOTAL_FILES = "[[TOTAL_FILES]]" + + /** + * Replace placeholders in a string. + * @param string string in which to replace placeholders + * @param uploadInfo upload information data + * @return string with replaced placeholders + */ + fun replace(string: String?, uploadInfo: UploadInfo): String { + val safeString = string ?: return "" + + return safeString.replace(ELAPSED_TIME, uploadInfo.elapsedTimeString) + .replace(PROGRESS, "${uploadInfo.progressPercent}%") + .replace(UPLOAD_RATE, uploadInfo.uploadRateString) + .replace(UPLOADED_FILES, uploadInfo.successfullyUploadedFiles.toString()) + .replace(TOTAL_FILES, uploadInfo.files.size.toString()) + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/ServerResponse.java b/uploadservice/src/main/java/net/gotev/uploadservice/ServerResponse.java deleted file mode 100644 index a31f69ca..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/ServerResponse.java +++ /dev/null @@ -1,116 +0,0 @@ -package net.gotev.uploadservice; - -import android.os.Parcel; -import android.os.Parcelable; - -import java.util.LinkedHashMap; - -/** - * Contains the server response. - * @author Aleksandar Gotev - */ -public class ServerResponse implements Parcelable { - - private int httpCode; - private byte[] body; - private LinkedHashMap headers; - - // This is used to regenerate the object. - // All Parcelables must have a CREATOR that implements these two methods - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { - @Override - public ServerResponse createFromParcel(final Parcel in) { - return new ServerResponse(in); - } - - @Override - public ServerResponse[] newArray(final int size) { - return new ServerResponse[size]; - } - }; - - /** - * Creates a new server response object. - * @param httpCode HTTP response code got from the server. If you are implementing another - * protocol, set this to {@link UploadTask#TASK_COMPLETED_SUCCESSFULLY} - * to inform that the task has been completed successfully. Integer values - * lower than 200 or greater that 299 indicates error response from server. - * @param body bytes read from server's response body. If your server does not - * return anything, set this to {@link UploadTask#EMPTY_RESPONSE}. - * @param headers contains all the headers sent by the server. Set this to null or - * an empty map if the server has not sent any response header. - */ - public ServerResponse(int httpCode, byte[] body, LinkedHashMap headers) { - this.httpCode = httpCode; - - if (body != null && body.length > 0) - this.body = body; - else - this.body = new byte[1]; - - if (headers != null && !headers.isEmpty()) - this.headers = headers; - else - this.headers = new LinkedHashMap<>(1); - } - - @SuppressWarnings("unchecked") - protected ServerResponse(Parcel in) { - httpCode = in.readInt(); - body = new byte[in.readInt()]; - in.readByteArray(body); - headers = (LinkedHashMap) in.readSerializable(); - } - - @Override - public void writeToParcel(Parcel parcel, int flags) { - parcel.writeInt(httpCode); - parcel.writeInt(body.length); - parcel.writeByteArray(body); - parcel.writeSerializable(headers); - } - - @Override - public int describeContents() { - return 0; - } - - /** - * Gets server HTTP response code. - * @return integer value - */ - public int getHttpCode() { - return httpCode; - } - - /** - * Gets server response body. - * If your server responds with a string, you can get it with - * {@link ServerResponse#getBodyAsString()}. - * If the string is a JSON, you can parse it using a library such as org.json - * (embedded in Android) or google's gson - * @return response bytes - */ - public byte[] getBody() { - return body; - } - - /** - * Gets server response body as string. - * If the string is a JSON, you can parse it using a library such as org.json - * (embedded in Android) or google's gson - * @return string - */ - public String getBodyAsString() { - return new String(body); - } - - /** - * Gets all the server response headers. - * @return map containing all the headers (key = header name, value = header value) - */ - public LinkedHashMap getHeaders() { - return headers; - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadFile.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadFile.java deleted file mode 100644 index 045ded2e..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadFile.java +++ /dev/null @@ -1,186 +0,0 @@ -package net.gotev.uploadservice; - -import android.content.Context; -import android.os.Parcel; -import android.os.Parcelable; - -import net.gotev.uploadservice.schemehandlers.SchemeHandler; -import net.gotev.uploadservice.schemehandlers.SchemeHandlerFactory; - -import java.io.FileNotFoundException; -import java.io.InputStream; -import java.util.LinkedHashMap; - -/** - * Represents a file to upload. - * - * @author cankov - * @author gotev (Aleksandar Gotev) - */ -public class UploadFile implements Parcelable { - - protected final String path; - private LinkedHashMap properties = new LinkedHashMap<>(); - protected final SchemeHandler handler; - - /** - * Creates a new UploadFile. - * - * @param path absolute path to a file or an Android content Uri string - * @throws FileNotFoundException if the file can't be found at the specified path - * @throws IllegalArgumentException if you passed invalid argument values - */ - public UploadFile(String path) throws FileNotFoundException { - - if (path == null || "".equals(path)) { - throw new IllegalArgumentException("Please specify a file path!"); - } - - if (!SchemeHandlerFactory.getInstance().isSupported(path)) - throw new UnsupportedOperationException("Unsupported scheme: " + path); - - this.path = path; - - try { - this.handler = SchemeHandlerFactory.getInstance().get(path); - } catch (Exception exc) { - throw new RuntimeException(exc); - } - } - - /** - * Gets the file length in bytes. - * @param context service context - * @return file length - */ - public long length(Context context) { - return handler.getLength(context); - } - - /** - * Gets the {@link InputStream} to read the content of this file. - * @param context service context - * @return file input stream - * @throws FileNotFoundException if the file can't be found at the path specified in the - * constructor - */ - public final InputStream getStream(Context context) throws FileNotFoundException { - return handler.getInputStream(context); - } - - /** - * Returns the content type for the file - * @param context service context - * @return content type - */ - public final String getContentType(Context context) { - return handler.getContentType(context); - } - - /** - * Returns the name of this file. - * @param context service context - * @return string - */ - public final String getName(Context context) { - return handler.getName(context); - } - - /** - * Returns the string this was initialized with, - * either an absolute file path or Android content URI - * @return String - */ - public final String getPath() { - return this.path; - } - - // This is used to regenerate the object. - // All Parcelables must have a CREATOR that implements these two methods - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { - @Override - public UploadFile createFromParcel(final Parcel in) { - return new UploadFile(in); - } - - @Override - public UploadFile[] newArray(final int size) { - return new UploadFile[size]; - } - }; - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel parcel, int arg1) { - parcel.writeString(path); - parcel.writeSerializable(properties); - } - - @SuppressWarnings("unchecked") - private UploadFile(Parcel in) { - this.path = in.readString(); - this.properties = (LinkedHashMap) in.readSerializable(); - - try { - this.handler = SchemeHandlerFactory.getInstance().get(path); - } catch (Exception exc) { - throw new RuntimeException(exc); - } - } - - /** - * Sets a property for this file. - * If you want to store objects, serialize them in JSON strings. - * @param key property key - * @param value property value - */ - public void setProperty(String key, String value) { - properties.put(key, value); - } - - /** - * Gets a property associated to this file. - * @param key property key - * @return property value or null if the value does not exist. - */ - public String getProperty(String key) { - return properties.get(key); - } - - /** - * Gets a property associated to this file. - * @param key property key - * @param defaultValue default value to use if the key does not exist or the value is null - * @return property value or the default value passed - */ - public String getProperty(String key, String defaultValue) { - String val = properties.get(key); - - if (val == null) { - val = defaultValue; - } - - return val; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof UploadFile)) return false; - - UploadFile that = (UploadFile) o; - - return path.equals(that.path); - - } - - @Override - public int hashCode() { - return path.hashCode(); - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadInfo.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadInfo.java deleted file mode 100644 index 4a4dff5a..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadInfo.java +++ /dev/null @@ -1,253 +0,0 @@ -package net.gotev.uploadservice; - -import android.os.Parcel; -import android.os.Parcelable; - -import java.util.ArrayList; -import java.util.Date; -import java.util.List; - -/** - * Contains upload information and statistics. - * @author Aleksandar Gotev - */ -public class UploadInfo implements Parcelable { - - private String uploadId; - private long startTime; - private long currentTime; - private long uploadedBytes; - private long totalBytes; - private int numberOfRetries; - private Integer notificationID; - private ArrayList filesLeft = new ArrayList<>(); - private ArrayList successfullyUploadedFiles = new ArrayList<>(); - - protected UploadInfo(String uploadId) { - this.uploadId = uploadId; - startTime = 0; - currentTime = 0; - uploadedBytes = 0; - totalBytes = 0; - numberOfRetries = 0; - notificationID = null; - } - - protected UploadInfo(String uploadId, long startTime, long uploadedBytes, long totalBytes, - int numberOfRetries, List uploadedFiles, List filesLeft) { - this.uploadId = uploadId; - this.startTime = startTime; - currentTime = new Date().getTime(); - this.uploadedBytes = uploadedBytes; - this.totalBytes = totalBytes; - this.numberOfRetries = numberOfRetries; - - if (filesLeft != null && !filesLeft.isEmpty()) { - this.filesLeft.addAll(filesLeft); - } - - if (uploadedFiles != null && !uploadedFiles.isEmpty()) - successfullyUploadedFiles.addAll(uploadedFiles); - } - - // This is used to regenerate the object. - // All Parcelables must have a CREATOR that implements these two methods - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { - @Override - public UploadInfo createFromParcel(final Parcel in) { - return new UploadInfo(in); - } - - @Override - public UploadInfo[] newArray(final int size) { - return new UploadInfo[size]; - } - }; - - @Override - public void writeToParcel(Parcel parcel, int arg1) { - parcel.writeString(uploadId); - parcel.writeLong(startTime); - parcel.writeLong(currentTime); - parcel.writeLong(uploadedBytes); - parcel.writeLong(totalBytes); - parcel.writeInt(numberOfRetries); - parcel.writeInt(notificationID == null ? -1 : notificationID); - parcel.writeStringList(filesLeft); - parcel.writeStringList(successfullyUploadedFiles); - } - - private UploadInfo(Parcel in) { - uploadId = in.readString(); - startTime = in.readLong(); - currentTime = in.readLong(); - uploadedBytes = in.readLong(); - totalBytes = in.readLong(); - numberOfRetries = in.readInt(); - - notificationID = in.readInt(); - if (notificationID == -1) { - notificationID = null; - } - - in.readStringList(filesLeft); - in.readStringList(successfullyUploadedFiles); - } - - @Override - public int describeContents() { - return 0; - } - - /** - * Returns the Upload ID. - * @return string - */ - public String getUploadId() { - return uploadId; - } - - /** - * Gets upload task's start timestamp in milliseconds. - * @return long value - */ - public long getStartTime() { - return startTime; - } - - /** - * Gets upload task's elapsed time in milliseconds. - * @return long value - */ - public long getElapsedTime() { - return (currentTime - startTime); - } - - /** - * Gets the elapsed time as a string, expressed in seconds if the value is {@code < 60}, - * or expressed in minutes:seconds if the value is {@code >=} 60. - * @return string representation of the elapsed time - */ - public String getElapsedTimeString() { - int elapsedSeconds = (int) (getElapsedTime() / 1000); - - if (elapsedSeconds == 0) - return "0s"; - - int minutes = elapsedSeconds / 60; - elapsedSeconds -= (60 * minutes); - - if (minutes == 0) { - return elapsedSeconds + "s"; - } - - return minutes + "m " + elapsedSeconds + "s"; - } - - /** - * Gets the average upload rate in Kbit/s. - * @return upload rate - */ - public double getUploadRate() { - long elapsedTime = getElapsedTime(); - - // wait at least a second to stabilize the upload rate a little bit - if (elapsedTime < 1000) - return 0; - - return (double) uploadedBytes / 1024 * 8 / (elapsedTime / 1000); - } - - /** - * Returns a string representation of the upload rate, expressed in the most convenient unit of - * measurement (Mbit/s if the value is {@code >=} 1024, B/s if the value is {@code < 1}, otherwise Kbit/s) - * @return string representation of the upload rate (e.g. 234 Kbit/s) - */ - public String getUploadRateString() { - double uploadRate = getUploadRate(); - - if (uploadRate < 1) { - return (int) (uploadRate * 1000) + " bit/s"; - - } else if (uploadRate >= 1024) { - return (int) (uploadRate / 1024) + " Mbit/s"; - - } - - return (int) uploadRate + " Kbit/s"; - } - - /** - * Gets the list of the successfully uploaded files. - * @return list of strings - */ - public ArrayList getSuccessfullyUploadedFiles() { - return successfullyUploadedFiles; - } - - /** - * Gets the list of all the files left to be uploaded. - * @return list of strings - */ - public ArrayList getFilesLeft() { - return filesLeft; - } - - /** - * Gets the uploaded bytes. - * @return long value - */ - public long getUploadedBytes() { - return uploadedBytes; - } - - /** - * Gets upload task's total bytes. - * @return long value - */ - public long getTotalBytes() { - return totalBytes; - } - - /** - * Gets the upload progress in percent (from 0 to 100). - * @return integer value - */ - public int getProgressPercent() { - if (totalBytes == 0) - return 0; - - return (int) (uploadedBytes * 100 / totalBytes); - } - - /** - * Gets the number of the retries that has been made during the upload process. - * If no retries has been made, this value will be zero. - * @return int value - */ - public int getNumberOfRetries() { - return numberOfRetries; - } - - /** - * Gets the total number of files added to the upload request. - * @return total number of files to upload - */ - public int getTotalFiles() { - return successfullyUploadedFiles.size() + filesLeft.size(); - } - - /** - * Gets the notification ID. - * @return Integer number or null if the upload task does not have a notification or the - * notification is not dismissable at the moment (for example during upload progress). - */ - public Integer getNotificationID() { - return notificationID; - } - - protected void setNotificationID(int id) { - notificationID = id; - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationAction.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationAction.java deleted file mode 100644 index 961afef4..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationAction.java +++ /dev/null @@ -1,112 +0,0 @@ -package net.gotev.uploadservice; - -import android.app.PendingIntent; -import android.os.Parcel; -import android.os.Parcelable; -import android.text.TextUtils; - -import androidx.core.app.NotificationCompat; - -/** - * Class which represents a notification action. - * It is necessary because NotificationCompat.Action is not serializable or Parcelable, thus it's - * not possible to pass it directly in the intents. - * - * @author Aleksandar Gotev - */ - -public class UploadNotificationAction implements Parcelable { - - private int icon; - private CharSequence title; - private PendingIntent actionIntent; - - /** - * Creates a new object from an existing NotificationCompat.Action object. - * - * @param action notification compat action - * @return new instance - */ - public static UploadNotificationAction from(NotificationCompat.Action action) { - return new UploadNotificationAction(action.icon, action.title, action.actionIntent); - } - - /** - * Creates a new {@link UploadNotificationAction} object. - * - * @param icon icon to show for this action - * @param title the title of the action - * @param intent the {@link PendingIntent} to fire when users trigger this action - */ - public UploadNotificationAction(int icon, CharSequence title, PendingIntent intent) { - this.icon = icon; - this.title = title; - this.actionIntent = intent; - } - - final NotificationCompat.Action toAction() { - return new NotificationCompat.Action.Builder(icon, title, actionIntent).build(); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeInt(this.icon); - - TextUtils.writeToParcel(title, dest, flags); - - if (actionIntent != null) { - dest.writeInt(1); - actionIntent.writeToParcel(dest, flags); - } else { - dest.writeInt(0); - } - } - - protected UploadNotificationAction(Parcel in) { - this.icon = in.readInt(); - - this.title = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in); - - if (in.readInt() == 1) { - actionIntent = PendingIntent.CREATOR.createFromParcel(in); - } - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - @Override - public UploadNotificationAction createFromParcel(Parcel source) { - return new UploadNotificationAction(source); - } - - @Override - public UploadNotificationAction[] newArray(int size) { - return new UploadNotificationAction[size]; - } - }; - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof UploadNotificationAction)) return false; - - UploadNotificationAction that = (UploadNotificationAction) o; - - if (icon != that.icon) return false; - if (!title.equals(that.title)) return false; - return actionIntent.equals(that.actionIntent); - - } - - @Override - public int hashCode() { - int result = icon; - result = 31 * result + title.hashCode(); - result = 31 * result + actionIntent.hashCode(); - return result; - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationConfig.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationConfig.java deleted file mode 100644 index e2b94b73..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationConfig.java +++ /dev/null @@ -1,251 +0,0 @@ -package net.gotev.uploadservice; - -import android.app.PendingIntent; -import android.graphics.Bitmap; -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; - -/** - * Contains the configuration of the upload notification. - * - * @author gotev (Aleksandar Gotev) - */ -public final class UploadNotificationConfig implements Parcelable { - - private boolean ringToneEnabled; - - /** - * Notification channel ID - */ - private String notificationChannelId; - - private UploadNotificationStatusConfig progress; - private UploadNotificationStatusConfig completed; - private UploadNotificationStatusConfig error; - private UploadNotificationStatusConfig cancelled; - - /** - * Creates a new upload notification configuration with default settings: - *
    - *
  • {@code android.R.drawable.ic_menu_upload} will be used as the icon
  • - *
  • If the user taps on the notification, nothing will happen
  • - *
  • Once the operation is completed (either successfully or with an error): - *
      - *
    • the default notification sound will be emitted (or the default notification vibration if the device is in silent mode)
    • - *
    • the notification will remain in the Notification Center until the user swipes it out
    • - *
    - *
  • - *
- */ - public UploadNotificationConfig() { - - // common configuration for all the notification statuses - ringToneEnabled = true; - - // progress notification configuration - progress = new UploadNotificationStatusConfig(); - progress.message = "Uploading at " + Placeholders.UPLOAD_RATE + " (" + Placeholders.PROGRESS + ")"; - - // completed notification configuration - completed = new UploadNotificationStatusConfig(); - completed.message = "Upload completed successfully in " + Placeholders.ELAPSED_TIME; - - // error notification configuration - error = new UploadNotificationStatusConfig(); - error.message = "Error during upload"; - - // cancelled notification configuration - cancelled = new UploadNotificationStatusConfig(); - cancelled.message = "Upload cancelled"; - } - - /** - * Sets the notification title for all the notification statuses. - * - * @param title Title to show in the notification icon - * @return {@link UploadNotificationConfig} - */ - public final UploadNotificationConfig setTitleForAllStatuses(String title) { - progress.title = title; - completed.title = title; - error.title = title; - cancelled.title = title; - return this; - } - - /** - * Sets the same notification icon for all the notification statuses. - * - * @param resourceID Resource ID of the icon to use - * @return {@link UploadNotificationConfig} - */ - public final UploadNotificationConfig setIconForAllStatuses(int resourceID) { - progress.iconResourceID = resourceID; - completed.iconResourceID = resourceID; - error.iconResourceID = resourceID; - cancelled.iconResourceID = resourceID; - return this; - } - - /** - * Sets the same notification icon for all the notification statuses. - * - * @param iconColorResourceID Resource ID of the color to use - * @return {@link UploadNotificationConfig} - */ - public final UploadNotificationConfig setIconColorForAllStatuses(int iconColorResourceID) { - progress.iconColorResourceID = iconColorResourceID; - completed.iconColorResourceID = iconColorResourceID; - error.iconColorResourceID = iconColorResourceID; - cancelled.iconColorResourceID = iconColorResourceID; - return this; - } - - /** - * Sets the same large notification icon for all the notification statuses. - * - * @param largeIcon Bitmap of the icon to use - * @return {@link UploadNotificationConfig} - */ - public final UploadNotificationConfig setLargeIconForAllStatuses(Bitmap largeIcon) { - progress.largeIcon = largeIcon; - completed.largeIcon = largeIcon; - error.largeIcon = largeIcon; - cancelled.largeIcon = largeIcon; - return this; - } - - /** - * Sets the same intent to be executed when the user taps on the notification - * for all the notification statuses. - * - * @param clickIntent {@link android.app.PendingIntent} containing the user's action - * @return {@link UploadNotificationConfig} - */ - public final UploadNotificationConfig setClickIntentForAllStatuses(PendingIntent clickIntent) { - progress.clickIntent = clickIntent; - completed.clickIntent = clickIntent; - error.clickIntent = clickIntent; - cancelled.clickIntent = clickIntent; - return this; - } - - /** - * Adds the same notification action for all the notification statuses. - * So for example, if you want to have the same action while the notification is in progress, - * cancelled, completed or with an error, this method will save you lines of code. - * - * @param action {@link UploadNotificationAction} action to add - * @return {@link UploadNotificationConfig} - */ - public final UploadNotificationConfig addActionForAllStatuses(UploadNotificationAction action) { - progress.actions.add(action); - completed.actions.add(action); - error.actions.add(action); - cancelled.actions.add(action); - return this; - } - - /** - * Sets whether or not to clear the notification when the user taps on it - * for all the notification statuses. - *

- * This would not affect progress notification, as it's ongoing and managed by the upload - * service. - * - * @param clearOnAction true to clear the notification, otherwise false - * @return {@link UploadNotificationConfig} - */ - public final UploadNotificationConfig setClearOnActionForAllStatuses(boolean clearOnAction) { - progress.clearOnAction = clearOnAction; - completed.clearOnAction = clearOnAction; - error.clearOnAction = clearOnAction; - cancelled.clearOnAction = clearOnAction; - return this; - } - - /** - * Sets whether or not to enable the notification sound when the upload gets completed with - * success or error. - * - * @param enabled true to enable the default ringtone - * @return {@link UploadNotificationConfig} - */ - public final UploadNotificationConfig setRingToneEnabled(Boolean enabled) { - this.ringToneEnabled = enabled; - return this; - } - - /** - * Sets notification channel ID - * - * @param channelId notification channel ID - * @return {@link UploadNotificationConfig} - */ - public final UploadNotificationConfig setNotificationChannelId(@NonNull String channelId) { - this.notificationChannelId = channelId; - return this; - } - - public boolean isRingToneEnabled() { - return ringToneEnabled; - } - - public UploadNotificationStatusConfig getProgress() { - return progress; - } - - public UploadNotificationStatusConfig getCompleted() { - return completed; - } - - public UploadNotificationStatusConfig getError() { - return error; - } - - public UploadNotificationStatusConfig getCancelled() { - return cancelled; - } - - public String getNotificationChannelId() { - return notificationChannelId; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(this.notificationChannelId); - dest.writeByte(this.ringToneEnabled ? (byte) 1 : (byte) 0); - dest.writeParcelable(this.progress, flags); - dest.writeParcelable(this.completed, flags); - dest.writeParcelable(this.error, flags); - dest.writeParcelable(this.cancelled, flags); - } - - protected UploadNotificationConfig(Parcel in) { - this.notificationChannelId = in.readString(); - this.ringToneEnabled = in.readByte() != 0; - this.progress = in.readParcelable(UploadNotificationStatusConfig.class.getClassLoader()); - this.completed = in.readParcelable(UploadNotificationStatusConfig.class.getClassLoader()); - this.error = in.readParcelable(UploadNotificationStatusConfig.class.getClassLoader()); - this.cancelled = in.readParcelable(UploadNotificationStatusConfig.class.getClassLoader()); - } - - public static final Creator CREATOR = new Creator() { - @Override - public UploadNotificationConfig createFromParcel(Parcel source) { - return new UploadNotificationConfig(source); - } - - @Override - public UploadNotificationConfig[] newArray(int size) { - return new UploadNotificationConfig[size]; - } - }; -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationStatusConfig.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationStatusConfig.java deleted file mode 100644 index ee002bfe..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadNotificationStatusConfig.java +++ /dev/null @@ -1,128 +0,0 @@ -package net.gotev.uploadservice; - -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.os.Parcel; -import android.os.Parcelable; - -import java.util.ArrayList; - -import androidx.core.app.NotificationCompat; - -/** - * @author Aleksandar Gotev - */ - -public class UploadNotificationStatusConfig implements Parcelable { - - /** - * Notification title. - */ - public String title = "File Upload"; - - /** - * Notification message. - */ - public String message; - - /** - * Clear the notification automatically. - * This would not affect progress notification, as it's ongoing and managed by upload service. - * It's used to be able to automatically dismiss cancelled, error or completed notifications. - */ - public boolean autoClear = false; - - /** - * Notification icon. - */ - public int iconResourceID = android.R.drawable.ic_menu_upload; - - /** - * Large notification icon. - */ - public Bitmap largeIcon = null; - - /** - * Icon color tint. - */ - public int iconColorResourceID = NotificationCompat.COLOR_DEFAULT; - - /** - * Intent to be performed when the user taps on the notification. - */ - public PendingIntent clickIntent = null; - - /** - * Clear the notification automatically when the clickIntent is performed. - * This would not affect progress notification, as it's ongoing and managed by upload service. - */ - public boolean clearOnAction = false; - - /** - * List of actions to be added to this notification. - */ - public ArrayList actions = new ArrayList<>(3); - - final PendingIntent getClickIntent(Context context) { - if (clickIntent == null) { - return PendingIntent.getBroadcast(context, 0, new Intent(), PendingIntent.FLAG_UPDATE_CURRENT); - } - - return clickIntent; - } - - final void addActionsToNotificationBuilder(NotificationCompat.Builder builder) { - if (actions != null && !actions.isEmpty()) { - for (UploadNotificationAction notificationAction : actions) { - builder.addAction(notificationAction.toAction()); - } - } - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(this.title); - dest.writeString(this.message); - dest.writeByte(this.autoClear ? (byte) 1 : (byte) 0); - dest.writeByte(this.clearOnAction ? (byte) 1 : (byte) 0); - dest.writeParcelable(this.largeIcon, flags); - dest.writeInt(this.iconResourceID); - dest.writeInt(this.iconColorResourceID); - dest.writeParcelable(this.clickIntent, flags); - dest.writeTypedList(this.actions); - } - - public UploadNotificationStatusConfig() { - } - - protected UploadNotificationStatusConfig(Parcel in) { - this.title = in.readString(); - this.message = in.readString(); - this.autoClear = in.readByte() != 0; - this.clearOnAction = in.readByte() != 0; - this.largeIcon = in.readParcelable(Bitmap.class.getClassLoader()); - this.iconResourceID = in.readInt(); - this.iconColorResourceID = in.readInt(); - this.clickIntent = in.readParcelable(PendingIntent.class.getClassLoader()); - this.actions = in.createTypedArrayList(UploadNotificationAction.CREATOR); - } - - public static final Creator CREATOR = new Creator() { - @Override - public UploadNotificationStatusConfig createFromParcel(Parcel source) { - return new UploadNotificationStatusConfig(source); - } - - @Override - public UploadNotificationStatusConfig[] newArray(int size) { - return new UploadNotificationStatusConfig[size]; - } - }; -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadRequest.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadRequest.java deleted file mode 100644 index 5202461a..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadRequest.java +++ /dev/null @@ -1,161 +0,0 @@ -package net.gotev.uploadservice; - -import android.content.Context; -import android.content.Intent; -import android.os.Build; - -import java.util.UUID; - -/** - * Base class to extend to create an upload request. If you are implementing an HTTP based upload, - * extend {@link HttpUploadRequest} instead. - * - * @author Aleksandar Gotev - */ -public abstract class UploadRequest> { - private static final String LOG_TAG = UploadRequest.class.getSimpleName(); - - protected final Context context; - protected final UploadTaskParameters params = new UploadTaskParameters(); - protected UploadStatusDelegate delegate; - - /** - * Creates a new upload request. - * - * @param context application context - * @param uploadId unique ID to assign to this upload request. If is null or empty, a random - * UUID will be automatically generated. It's used in the broadcast receiver - * when receiving updates. - * @param serverUrl URL of the server side script that handles the request - * @throws IllegalArgumentException if one or more arguments are not valid - */ - public UploadRequest(final Context context, final String uploadId, final String serverUrl) - throws IllegalArgumentException { - - if (context == null) - throw new IllegalArgumentException("Context MUST not be null!"); - - if (serverUrl == null || "".equals(serverUrl)) { - throw new IllegalArgumentException("Server URL cannot be null or empty"); - } - - this.context = context; - - if (uploadId == null || uploadId.isEmpty()) { - Logger.debug(LOG_TAG, "null or empty upload ID. Generating it"); - params.id = UUID.randomUUID().toString(); - } else { - Logger.debug(LOG_TAG, "setting provided upload ID"); - params.id = uploadId; - } - - params.serverUrl = serverUrl; - Logger.debug(LOG_TAG, "Created new upload request to " - + params.serverUrl + " with ID: " + params.id); - } - - /** - * Start the background file upload service. - * @return the uploadId string. If you have passed your own uploadId in the constructor, this - * method will return that same uploadId, otherwise it will return the automatically - * generated uploadId - */ - public String startUpload() { - UploadService.setUploadStatusDelegate(params.id, delegate); - - final Intent intent = new Intent(context, UploadService.class); - this.initializeIntent(intent); - intent.setAction(UploadService.getActionUpload()); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (params.notificationConfig == null) { - throw new IllegalArgumentException("Android Oreo requires a notification configuration for the service to run. https://developer.android.com/reference/android/content/Context.html#startForegroundService(android.content.Intent)"); - } - context.startForegroundService(intent); - } else { - context.startService(intent); - } - - return params.id; - } - - /** - * Write any upload request data to the intent used to start the upload service.
- * Override this method in subclasses to add your own custom parameters to the upload task. - * - * @param intent the intent used to start the upload service - */ - protected void initializeIntent(Intent intent) { - intent.putExtra(UploadService.PARAM_TASK_PARAMETERS, params); - - Class taskClass = getTaskClass(); - if (taskClass == null) - throw new RuntimeException("The request must specify a task class!"); - - intent.putExtra(UploadService.PARAM_TASK_CLASS, taskClass.getName()); - } - - @SuppressWarnings("unchecked") - protected final B self() { - return (B)this; - } - - /** - * Sets custom notification configuration. - * If you don't want to display a notification in Notification Center, either pass null - * as argument or don't call this method. - * - * @param config the upload configuration object or null if you don't want a notification - * to be displayed - * @return self instance - */ - public B setNotificationConfig(UploadNotificationConfig config) { - params.notificationConfig = config; - return self(); - } - - /** - * Sets the automatic file deletion after successful upload. - * @param autoDeleteFiles true to auto delete files included in the - * request when the upload is completed successfully. - * By default this setting is set to false, and nothing gets deleted. - * @return self instance - */ - public B setAutoDeleteFilesAfterSuccessfulUpload(boolean autoDeleteFiles) { - params.autoDeleteSuccessfullyUploadedFiles = autoDeleteFiles; - return self(); - } - - /** - * Sets the maximum number of retries that the library will try if an error occurs, - * before returning an error. - * - * @param maxRetries number of maximum retries on error - * @return self instance - */ - public B setMaxRetries(int maxRetries) { - params.setMaxRetries(maxRetries); - return self(); - } - - /** - * Sets the delegate which will receive the events for this upload request. - * The events will be sent only to the delegate and not in broadcast. Delegate methods will - * be called on your main thread, so you can safely update your UI from them. If you want to - * send events for this upload in broadcast, and handle them in the - * {@link UploadServiceBroadcastReceiver}, do not set the delegate or set it to null. - * @param delegate instance of the delegate which will receive the events - * @return self instance - */ - public B setDelegate(UploadStatusDelegate delegate) { - this.delegate = delegate; - return self(); - } - - /** - * Implement in subclasses to specify the class which will handle the the upload task. - * The class must be a subclass of {@link UploadTask}. - * @return class - */ - protected abstract Class getTaskClass(); -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadRequest.kt b/uploadservice/src/main/java/net/gotev/uploadservice/UploadRequest.kt new file mode 100644 index 00000000..fdd8225c --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/UploadRequest.kt @@ -0,0 +1,126 @@ +package net.gotev.uploadservice + +import android.content.Context +import android.os.Parcelable +import net.gotev.uploadservice.data.UploadFile +import net.gotev.uploadservice.data.UploadNotificationConfig +import net.gotev.uploadservice.data.UploadTaskParameters +import net.gotev.uploadservice.extensions.startNewUpload +import net.gotev.uploadservice.observer.request.SingleRequestObserver +import java.util.* + +/** + * Base class to extend to create an upload request. If you are implementing an HTTP based upload, + * extend [HttpUploadRequest] instead. + */ +abstract class UploadRequest> +/** + * Creates a new upload request. + * + * @param context application context + * @param serverUrl URL of the server side script that handles the request + * @throws IllegalArgumentException if one or more arguments are not valid + */ +@Throws(IllegalArgumentException::class) +constructor(protected val context: Context, protected var serverUrl: String) { + + private var uploadId: String? = null + protected var maxRetries = UploadServiceConfig.retryPolicy.defaultMaxRetries + protected var autoDeleteSuccessfullyUploadedFiles = false + protected var notificationConfig: UploadNotificationConfig? = null + protected val files = ArrayList() + + /** + * Implement in subclasses to specify the class which will handle the the upload task. + * The class must be a subclass of [UploadTask]. + * @return class + */ + protected abstract val taskClass: Class + + init { + require(serverUrl.isNotBlank()) { "Server URL cannot be empty" } + } + + /** + * Start the background file upload service. + * @return the uploadId string. If you have passed your own uploadId in the constructor, this + * method will return that same uploadId, otherwise it will return the automatically + * generated uploadId + */ + open fun startUpload(): String { + return context.startNewUpload(taskClass, UploadTaskParameters( + uploadId ?: UUID.randomUUID().toString(), + serverUrl, + maxRetries, + autoDeleteSuccessfullyUploadedFiles, + notificationConfig, + files, + getAdditionalParameters() + )) + } + + /** + * Subscribe to events of this upload request + * @param observer observer to listen for events. + */ + fun subscribe(observer: SingleRequestObserver) { + observer.subscribe(this) + } + + protected abstract fun getAdditionalParameters(): Parcelable + + @Suppress("UNCHECKED_CAST") + protected fun self(): B { + return this as B + } + + /** + * Sets custom notification configuration. + * If you don't want to display a notification in Notification Center, either pass null + * as argument or don't call this method. + * + * @param config the upload configuration object or null if you don't want a notification + * to be displayed + * @return self instance + */ + fun setNotificationConfig(config: UploadNotificationConfig): B { + this.notificationConfig = config + return self() + } + + /** + * Sets the automatic file deletion after successful upload. + * @param autoDeleteFiles true to auto delete files included in the + * request when the upload is completed successfully. + * By default this setting is set to false, and nothing gets deleted. + * @return self instance + */ + fun setAutoDeleteFilesAfterSuccessfulUpload(autoDeleteFiles: Boolean): B { + this.autoDeleteSuccessfullyUploadedFiles = autoDeleteFiles + return self() + } + + /** + * Sets the maximum number of retries that the library will try if an error occurs, + * before returning an error. + * + * @param maxRetries number of maximum retries on error + * @return self instance + */ + fun setMaxRetries(maxRetries: Int): B { + this.maxRetries = maxRetries + return self() + } + + /** + * Set Upload ID. + * + * @param uploadID unique ID to assign to this upload request. + * If it's null or empty, a random UUID will be automatically generated. + * It's used in the broadcast receiver when receiving updates. + */ + fun setUploadID(uploadID: String): B { + this.uploadId = uploadID + return self() + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadService.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadService.java deleted file mode 100644 index 3472ff70..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadService.java +++ /dev/null @@ -1,457 +0,0 @@ -package net.gotev.uploadservice; - -import android.app.Notification; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.Build; -import android.os.IBinder; -import android.os.PowerManager; - -import net.gotev.uploadservice.http.HttpStack; -import net.gotev.uploadservice.http.impl.HurlStack; - -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Timer; -import java.util.TimerTask; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -/** - * Service to upload files in background using HTTP POST with notification center progress - * display. - * - * @author gotev (Aleksandar Gotev) - * @author eliasnaur - * @author cankov - * @author mabdurrahman - */ -public final class UploadService extends Service { - - private static final String TAG = UploadService.class.getSimpleName(); - - // configurable values - /** - * Sets how many threads to use to handle concurrent uploads. - */ - public static int UPLOAD_POOL_SIZE = Runtime.getRuntime().availableProcessors(); - - /** - * When the number of threads is greater than UPLOAD_POOL_SIZE, this is the maximum time that - * excess idle threads will wait for new tasks before terminating. - */ - public static int KEEP_ALIVE_TIME_IN_SECONDS = 5; - - /** - * How many time to wait in idle (in milliseconds) before shutting down the service. - * The service is idle when is running, but no tasks are running. - */ - public static int IDLE_TIMEOUT = 10 * 1000; - - /** - * If set to true, the service will go in foreground mode when doing uploads, - * lowering the probability of being killed by the system on low memory. - * This setting is used only when your uploads have a notification configuration. - * It's not possible to run in foreground without notifications, as per Android policy - * constraints, so if you set this to true, but you do upload tasks without a - * notification configuration, the service will simply run in background mode. - * - * NOTE: As of Android Oreo, this setting is ignored as it always has to be true, - * because the service must run in the foreground and expose a notification to the user. - * https://developer.android.com/reference/android/content/Context.html#startForegroundService(android.content.Intent) - */ - public static boolean EXECUTE_IN_FOREGROUND = true; - - /** - * Sets the namespace used to broadcast events. Set this to your app namespace to avoid - * conflicts and unexpected behaviours. - */ - public static String NAMESPACE = "net.gotev"; - - /** - * Sets the HTTP Stack to use to perform HTTP based upload requests. - * By default {@link HurlStack} implementation is used. - */ - public static HttpStack HTTP_STACK = new HurlStack(); - - /** - * Buffer size in bytes used for data transfer by the upload tasks. - */ - public static int BUFFER_SIZE = 4096; - - /** - * Sets the time to wait in milliseconds before the next attempt when an upload fails - * for the first time. From the second time onwards, this value will be multiplied by - * {@link UploadService#BACKOFF_MULTIPLIER} to get the time to wait before the next attempt. - */ - public static int INITIAL_RETRY_WAIT_TIME = 1000; - - /** - * Sets the backoff timer multiplier. By default is set to 2, so every time that an upload - * fails, the time to wait between retries will be multiplied by 2. - * E.g. if the first time the wait time is 1s, the second time it will be 2s and the third - * time it will be 4s. - */ - public static int BACKOFF_MULTIPLIER = 2; - - /** - * Sets the maximum time to wait in milliseconds between two upload attempts. - * This is useful because every time an upload fails, the wait time gets multiplied by - * {@link UploadService#BACKOFF_MULTIPLIER} and it's not convenient that the value grows - * indefinitely. - */ - public static int MAX_RETRY_WAIT_TIME = 10 * 10 * 1000; - // end configurable values - - protected static final int UPLOAD_NOTIFICATION_BASE_ID = 1234; // Something unique - - /** - * The minimum interval between progress reports in milliseconds. - * If the upload Tasks report more frequently, we will throttle notifications. - * We aim for 6 updates per second. - */ - public static long PROGRESS_REPORT_INTERVAL = 166; - - // constants used in the intent which starts this service - private static final String ACTION_UPLOAD_SUFFIX = ".uploadservice.action.upload"; - protected static final String PARAM_TASK_PARAMETERS = "taskParameters"; - protected static final String PARAM_TASK_CLASS = "taskClass"; - - // constants used in broadcast intents - private static final String BROADCAST_ACTION_SUFFIX = ".uploadservice.broadcast.status"; - protected static final String PARAM_BROADCAST_DATA = "broadcastData"; - - // internal variables - private PowerManager.WakeLock wakeLock; - private static int notificationIncrementalId = 0; - private static final Map uploadTasksMap = new ConcurrentHashMap<>(); - private static final Map> uploadDelegates = new ConcurrentHashMap<>(); - private final BlockingQueue uploadTasksQueue = new LinkedBlockingQueue<>(); - private static volatile String foregroundUploadId = null; - private ThreadPoolExecutor uploadThreadPool; - private Timer idleTimer = null; - - protected static String getActionUpload() { - return NAMESPACE + ACTION_UPLOAD_SUFFIX; - } - - protected static String getActionBroadcast() { - return NAMESPACE + BROADCAST_ACTION_SUFFIX; - } - - /** - * Stops the upload task with the given uploadId. - * @param uploadId The unique upload id - */ - public static synchronized void stopUpload(final String uploadId) { - UploadTask removedTask = uploadTasksMap.get(uploadId); - if (removedTask != null) { - removedTask.cancel(); - } - } - - /** - * Gets the list of the currently active upload tasks. - * @return list of uploadIDs or an empty list if no tasks are currently running - */ - public static synchronized List getTaskList() { - List tasks; - - if (uploadTasksMap.isEmpty()) { - tasks = new ArrayList<>(1); - } else { - tasks = new ArrayList<>(uploadTasksMap.size()); - tasks.addAll(uploadTasksMap.keySet()); - } - - return tasks; - } - - /** - * Stop all the active uploads. - */ - public static synchronized void stopAllUploads() { - if (uploadTasksMap.isEmpty()) { - return; - } - - // using iterator instead for each loop, because it's faster on Android - Iterator iterator = uploadTasksMap.keySet().iterator(); - - while (iterator.hasNext()) { - UploadTask taskToCancel = uploadTasksMap.get(iterator.next()); - taskToCancel.cancel(); - } - } - - /** - * Stops the service if no upload tasks are currently running - * @param context application context - * @return true if the service is getting stopped, false otherwise - */ - public static synchronized boolean stop(final Context context) { - return stop(context, false); - } - - /** - * Stops the service. - * @param context application context - * @param forceStop stops the service no matter if some tasks are running - * @return true if the service is getting stopped, false otherwise - */ - public static synchronized boolean stop(final Context context, boolean forceStop) { - if (forceStop) { - return context.stopService(new Intent(context, UploadService.class)); - } - return uploadTasksMap.isEmpty() && context.stopService(new Intent(context, UploadService.class)); - } - - private boolean isExecuteInForeground() { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O || EXECUTE_IN_FOREGROUND; - } - - @Override - public void onCreate() { - super.onCreate(); - - PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); - wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); - wakeLock.setReferenceCounted(false); - - if (!wakeLock.isHeld()) - wakeLock.acquire(); - - if (UPLOAD_POOL_SIZE <= 0) { - UPLOAD_POOL_SIZE = Runtime.getRuntime().availableProcessors(); - } - - // Creates a thread pool manager - uploadThreadPool = new ThreadPoolExecutor( - UPLOAD_POOL_SIZE, // Initial pool size - UPLOAD_POOL_SIZE, // Max pool size - KEEP_ALIVE_TIME_IN_SECONDS, - TimeUnit.SECONDS, - uploadTasksQueue); - } - - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent == null || !getActionUpload().equals(intent.getAction())) { - return shutdownIfThereArentAnyActiveTasks(); - } - - if ("net.gotev".equals(NAMESPACE)) { - throw new IllegalArgumentException("Hey dude, please set the namespace for your app by following the setup instructions: https://github.com/gotev/android-upload-service/wiki/Setup"); - } - - Logger.info(TAG, String.format(Locale.getDefault(), "Starting service with namespace: %s, " + - "upload pool size: %d, %ds idle thread keep alive time. Foreground execution is %s", - NAMESPACE, UPLOAD_POOL_SIZE, KEEP_ALIVE_TIME_IN_SECONDS, - (isExecuteInForeground() ? "enabled" : "disabled"))); - - UploadTask currentTask = getTask(intent); - - if (currentTask == null) { - return shutdownIfThereArentAnyActiveTasks(); - } - - if (uploadTasksMap.containsKey(currentTask.params.id)) { - Logger.error(TAG, "Preventing upload with id: " + currentTask.params.id - + " to be uploaded twice! Please check your code and fix it!"); - return shutdownIfThereArentAnyActiveTasks(); - } - - clearIdleTimer(); - - // increment by 2 because the notificationIncrementalId + 1 is used internally - // in each UploadTask. Check its sources for more info about this. - notificationIncrementalId += 2; - currentTask.setLastProgressNotificationTime(0) - .setNotificationId(UPLOAD_NOTIFICATION_BASE_ID + notificationIncrementalId); - - uploadTasksMap.put(currentTask.params.id, currentTask); - uploadThreadPool.execute(currentTask); - - return START_STICKY; - } - - synchronized private void clearIdleTimer() { - if (idleTimer != null) { - Logger.info(TAG, "Clearing idle timer"); - idleTimer.cancel(); - idleTimer = null; - } - } - - synchronized private int shutdownIfThereArentAnyActiveTasks() { - if (uploadTasksMap.isEmpty()) { - clearIdleTimer(); - - Logger.info(TAG, "Service will be shut down in " + IDLE_TIMEOUT + "ms if no new tasks are received"); - idleTimer = new Timer(TAG + "IdleTimer"); - idleTimer.schedule(new TimerTask() { - @Override - public void run() { - Logger.info(TAG, "Service is about to be stopped because idle timeout of " - + IDLE_TIMEOUT + "ms has been reached"); - stopSelf(); - } - }, IDLE_TIMEOUT); - - return START_NOT_STICKY; - } - - return START_STICKY; - } - - @Override - public void onDestroy() { - super.onDestroy(); - - stopAllUploads(); - uploadThreadPool.shutdown(); - - if (isExecuteInForeground()) { - Logger.debug(TAG, "Stopping foreground execution"); - stopForeground(true); - } - - if (wakeLock.isHeld()) - wakeLock.release(); - - uploadTasksMap.clear(); - uploadDelegates.clear(); - - Logger.debug(TAG, "UploadService destroyed"); - } - - /** - * Creates a new task instance based on the requested task class in the intent. - * @param intent intent passed to the service - * @return task instance or null if the task class is not supported or invalid - */ - UploadTask getTask(Intent intent) { - String taskClass = intent.getStringExtra(PARAM_TASK_CLASS); - - if (taskClass == null) { - return null; - } - - UploadTask uploadTask = null; - - try { - Class task = Class.forName(taskClass); - - if (UploadTask.class.isAssignableFrom(task)) { - uploadTask = UploadTask.class.cast(task.newInstance()); - uploadTask.init(this, intent); - } else { - Logger.error(TAG, taskClass + " does not extend UploadTask!"); - } - - Logger.debug(TAG, "Successfully created new task with class: " + taskClass); - - } catch (Exception exc) { - Logger.error(TAG, "Error while instantiating new task", exc); - } - - return uploadTask; - } - - /** - * Check if the task is currently the one shown in the foreground notification. - * @param uploadId ID of the upload - * @return true if the current upload task holds the foreground notification, otherwise false - */ - protected synchronized boolean holdForegroundNotification(String uploadId, Notification notification) { - if (!isExecuteInForeground()) return false; - - if (foregroundUploadId == null) { - foregroundUploadId = uploadId; - Logger.debug(TAG, uploadId + " now holds the foreground notification"); - } - - if (uploadId.equals(foregroundUploadId)) { - startForeground(UPLOAD_NOTIFICATION_BASE_ID, notification); - return true; - } - - return false; - } - - /** - * Called by each task when it is completed (either successfully, with an error or due to - * user cancellation). - * @param uploadId the uploadID of the finished task - */ - protected synchronized void taskCompleted(String uploadId) { - UploadTask task = uploadTasksMap.remove(uploadId); - uploadDelegates.remove(uploadId); - - // un-hold foreground upload ID if it's been hold - if (isExecuteInForeground() && task != null && task.params.id.equals(foregroundUploadId)) { - Logger.debug(TAG, uploadId + " now un-holded the foreground notification"); - foregroundUploadId = null; - } - - if (isExecuteInForeground() && uploadTasksMap.isEmpty()) { - Logger.debug(TAG, "All tasks completed, stopping foreground execution"); - stopForeground(true); - shutdownIfThereArentAnyActiveTasks(); - } - } - - /** - * Sets the delegate which will receive the events for the given upload request. - * Those events will not be sent in broadcast, but only to the delegate. - * @param uploadId uploadID of the upload request - * @param delegate the delegate instance - */ - protected static void setUploadStatusDelegate(String uploadId, UploadStatusDelegate delegate) { - if (delegate == null) - return; - - uploadDelegates.put(uploadId, new WeakReference<>(delegate)); - } - - /** - * Gets the delegate for an upload request. - * @param uploadId uploadID of the upload request - * @return {@link UploadStatusDelegate} or null if no delegate has been set for the given - * uploadId - */ - protected static UploadStatusDelegate getUploadStatusDelegate(String uploadId) { - WeakReference reference = uploadDelegates.get(uploadId); - - if (reference == null) - return null; - - UploadStatusDelegate delegate = reference.get(); - - if (delegate == null) { - uploadDelegates.remove(uploadId); - Logger.info(TAG, "\n\n\nUpload delegate for upload with Id " + uploadId + " is gone!\n" + - "Probably you have set it in an activity and the user navigated away from it\n" + - "before the upload was completed. From now on, the events will be dispatched\n" + - "with broadcast intents. If you see this message, consider switching to the\n" + - "UploadServiceBroadcastReceiver registered globally in your manifest.\n" + - "Read this:\n" + - "https://github.com/gotev/android-upload-service/wiki/Monitoring-upload-status\n"); - } - - return delegate; - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadService.kt b/uploadservice/src/main/java/net/gotev/uploadservice/UploadService.kt new file mode 100644 index 00000000..743df5b2 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/UploadService.kt @@ -0,0 +1,287 @@ +package net.gotev.uploadservice + +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.os.PowerManager +import net.gotev.uploadservice.data.UploadTaskParameters +import net.gotev.uploadservice.extensions.acquirePartialWakeLock +import net.gotev.uploadservice.extensions.safeRelease +import net.gotev.uploadservice.logger.UploadServiceLogger +import net.gotev.uploadservice.observer.task.BroadcastEmitter +import net.gotev.uploadservice.observer.task.NotificationHandler +import net.gotev.uploadservice.observer.task.TaskCompletionNotifier +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + +class UploadService : Service() { + + companion object { + private val TAG = UploadService::class.java.simpleName + + // constants used in the intent which starts this service + internal const val taskParameters = "taskParameters" + internal const val taskClass = "taskClass" + + private const val UPLOAD_NOTIFICATION_BASE_ID = 1234 // Something unique + + private var notificationIncrementalId = 0 + private val uploadTasksMap = ConcurrentHashMap() + + @Volatile + private var foregroundUploadId: String? = null + + /** + * Stops the upload task with the given uploadId. + * @param uploadId The unique upload id + */ + @Synchronized + @JvmStatic + fun stopUpload(uploadId: String) { + uploadTasksMap[uploadId]?.cancel() + } + + /** + * Gets the list of the currently active upload tasks. + * @return list of uploadIDs or an empty list if no tasks are currently running + */ + @JvmStatic + val taskList: List + @Synchronized get() = if (uploadTasksMap.isEmpty()) { + emptyList() + } else { + uploadTasksMap.keys().toList() + } + + /** + * Stop all the active uploads. + */ + @Synchronized + @JvmStatic + fun stopAllUploads() { + val iterator = uploadTasksMap.keys.iterator() + + while (iterator.hasNext()) { + uploadTasksMap[iterator.next()]?.cancel() + } + } + + /** + * Stops the service. + * @param context application context + * @param forceStop if true stops the service no matter if some tasks are running, else + * stops only if there aren't any active tasks + * @return true if the service is getting stopped, false otherwise + */ + @Synchronized + @JvmOverloads + @JvmStatic + fun stop(context: Context, forceStop: Boolean = false) = if (forceStop) { + stopAllUploads() + context.stopService(Intent(context, UploadService::class.java)) + } else { + uploadTasksMap.isEmpty() && context.stopService(Intent(context, UploadService::class.java)) + } + } + + private var wakeLock: PowerManager.WakeLock? = null + private val uploadTasksQueue = LinkedBlockingQueue() + private val uploadThreadPool by lazy { + ThreadPoolExecutor( + UploadServiceConfig.uploadPoolSize, // Initial pool size + UploadServiceConfig.uploadPoolSize, // Max pool size + UploadServiceConfig.keepAliveTimeSeconds.toLong(), + TimeUnit.SECONDS, + uploadTasksQueue + ) + } + private var idleTimer: Timer? = null + + override fun onCreate() { + super.onCreate() + + wakeLock = acquirePartialWakeLock(wakeLock, TAG) + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null || UploadServiceConfig.uploadAction != intent.action) { + return shutdownIfThereArentAnyActiveTasks() + } + + UploadServiceLogger.debug(TAG) { "Starting UploadService. Debug info: $UploadServiceConfig" } + + val currentTask = getTask(intent) ?: return shutdownIfThereArentAnyActiveTasks() + + if (uploadTasksMap.containsKey(currentTask.params.id)) { + UploadServiceLogger.error(TAG) { + "Preventing upload with id: ${currentTask.params.id} to be uploaded twice! " + + "Please check your code and fix it!" + } + return shutdownIfThereArentAnyActiveTasks() + } + + clearIdleTimer() + + uploadTasksMap[currentTask.params.id] = currentTask + uploadThreadPool.execute(currentTask) + + return START_STICKY + } + + @Synchronized + private fun clearIdleTimer() { + idleTimer?.apply { + UploadServiceLogger.info(TAG) { "Clearing idle timer" } + cancel() + } + idleTimer = null + } + + @Synchronized + private fun shutdownIfThereArentAnyActiveTasks(): Int { + if (uploadTasksMap.isEmpty()) { + clearIdleTimer() + + UploadServiceLogger.info(TAG) { + "Service will be shut down in ${UploadServiceConfig.idleTimeoutSeconds}s " + + "if no new tasks are received" + } + + idleTimer = Timer(TAG + "IdleTimer").apply { + schedule(object : TimerTask() { + override fun run() { + UploadServiceLogger.info(TAG) { + "Service is about to be stopped because idle timeout of " + + "${UploadServiceConfig.idleTimeoutSeconds}s has been reached" + } + stopSelf() + } + }, (UploadServiceConfig.idleTimeoutSeconds * 1000).toLong()) + } + + return START_NOT_STICKY + } + + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + + stopAllUploads() + uploadThreadPool.shutdown() + + if (UploadServiceConfig.isForegroundService) { + UploadServiceLogger.debug(TAG) { "Stopping foreground execution" } + stopForeground(true) + } + + wakeLock.safeRelease() + + uploadTasksMap.clear() + + UploadServiceLogger.debug(TAG) { "UploadService destroyed" } + } + + /** + * Creates a new task instance based on the requested task class in the intent. + * @param intent intent passed to the service + * @return task instance or null if the task class is not supported or invalid + */ + fun getTask(intent: Intent): UploadTask? { + val taskClassString = intent.getStringExtra(taskClass) ?: run { + UploadServiceLogger.error(TAG) { "Error while instantiating new task. No task class defined in Intent." } + return null + } + + val params: UploadTaskParameters = intent.getParcelableExtra(taskParameters) ?: run { + UploadServiceLogger.error(TAG) { "Error while instantiating new task. Missing task parameters." } + return null + } + + return try { + val task = Class.forName(taskClassString) + + if (!UploadTask::class.java.isAssignableFrom(task)) { + UploadServiceLogger.error(TAG) { "$taskClassString does not extend UploadTask!" } + return null + } + + val uploadTask = UploadTask::class.java.cast(task.newInstance()) ?: return null + + // increment by 2 because the notificationIncrementalId + 1 is used internally + // in each UploadTask. Check its sources for more info about this. + notificationIncrementalId += 2 + + val observers = listOfNotNull( + BroadcastEmitter(this), + params.notificationConfig?.let { + NotificationHandler(this, UPLOAD_NOTIFICATION_BASE_ID + notificationIncrementalId, params.id, it) + }, + TaskCompletionNotifier(this) + ).toTypedArray() + + uploadTask.init(this, params, *observers) + + UploadServiceLogger.debug(TAG) { "Successfully created new task with class: $taskClassString" } + uploadTask + + } catch (exc: Throwable) { + UploadServiceLogger.error(TAG, exc) { "Error while instantiating new task" } + null + } + } + + /** + * Check if the task is currently the one shown in the foreground notification. + * @param uploadId ID of the upload + * @return true if the current upload task holds the foreground notification, otherwise false + */ + @Synchronized + fun holdForegroundNotification(uploadId: String, notification: Notification): Boolean { + if (!UploadServiceConfig.isForegroundService) return false + + if (foregroundUploadId == null) { + foregroundUploadId = uploadId + UploadServiceLogger.debug(TAG) { "$uploadId now holds the foreground notification" } + } + + if (uploadId == foregroundUploadId) { + startForeground(UPLOAD_NOTIFICATION_BASE_ID, notification) + return true + } + + return false + } + + /** + * Called by each task when it is completed (either successfully, with an error or due to + * user cancellation). + * @param uploadId the uploadID of the finished task + */ + @Synchronized + fun taskCompleted(uploadId: String) { + val task = uploadTasksMap.remove(uploadId) + + // un-hold foreground upload ID if it's been hold + if (UploadServiceConfig.isForegroundService && task != null && task.params.id == foregroundUploadId) { + UploadServiceLogger.debug(TAG) { "$uploadId now un-holded the foreground notification" } + foregroundUploadId = null + } + + if (UploadServiceConfig.isForegroundService && uploadTasksMap.isEmpty()) { + UploadServiceLogger.debug(TAG) { "All tasks completed, stopping foreground execution" } + stopForeground(true) + shutdownIfThereArentAnyActiveTasks() + } + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceBroadcastReceiver.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceBroadcastReceiver.java deleted file mode 100644 index fda9e4d5..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceBroadcastReceiver.java +++ /dev/null @@ -1,118 +0,0 @@ -package net.gotev.uploadservice; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; - -/** - * Broadcast receiver to subclass to create a receiver for {@link UploadService} events. - * - * It provides the boilerplate code to properly handle broadcast messages coming from the - * upload service and dispatch them to the proper handler method. - * - * @author gotev (Aleksandar Gotev) - * @author eliasnaur - * @author cankov - * @author mabdurrahman - * - */ -public class UploadServiceBroadcastReceiver extends BroadcastReceiver - implements UploadStatusDelegate { - - @Override - public void onReceive(Context context, Intent intent) { - if (intent == null || !UploadService.getActionBroadcast().equals(intent.getAction())) - return; - - BroadcastData data = intent.getParcelableExtra(UploadService.PARAM_BROADCAST_DATA); - - if (data == null) { - Logger.error(getClass().getSimpleName(), "Missing intent parameter: " + UploadService.PARAM_BROADCAST_DATA); - return; - } - - UploadInfo uploadInfo = data.getUploadInfo(); - - if (!shouldAcceptEventFrom(uploadInfo)) { - return; - } - - switch (data.getStatus()) { - case ERROR: - onError(context, uploadInfo, data.getServerResponse(), data.getException()); - break; - - case COMPLETED: - onCompleted(context, uploadInfo, data.getServerResponse()); - break; - - case IN_PROGRESS: - onProgress(context, uploadInfo); - break; - - case CANCELLED: - onCancelled(context, uploadInfo); - break; - - default: - break; - } - } - - /** - * Method called every time a new event arrives from an upload task, to decide whether or not - * to process it. This is useful if you want to filter events based on custom business logic. - * - * @param uploadInfo upload info to - * @return true to accept the event, false to discard it - */ - protected boolean shouldAcceptEventFrom(UploadInfo uploadInfo) { - return true; - } - - /** - * Register this upload receiver.
- * If you use this receiver in an {@link android.app.Activity}, you have to call this method inside - * {@link android.app.Activity#onResume()}, after {@code super.onResume();}.
- * If you use it in a {@link android.app.Service}, you have to - * call this method inside {@link android.app.Service#onCreate()}, after {@code super.onCreate();}. - * - * @param context context in which to register this receiver - */ - public void register(final Context context) { - final IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(UploadService.getActionBroadcast()); - context.registerReceiver(this, intentFilter); - } - - /** - * Unregister this upload receiver.
- * If you use this receiver in an {@link android.app.Activity}, you have to call this method inside - * {@link android.app.Activity#onPause()}, after {@code super.onPause();}.
- * If you use it in a {@link android.app.Service}, you have to - * call this method inside {@link android.app.Service#onDestroy()}. - * - * @param context context in which to unregister this receiver - */ - public void unregister(final Context context) { - context.unregisterReceiver(this); - } - - @Override - public void onProgress(final Context context, final UploadInfo uploadInfo) { - } - - @Override - public void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, Exception exception) { - - } - - @Override - public void onCompleted(final Context context, final UploadInfo uploadInfo, final ServerResponse serverResponse) { - } - - @Override - public void onCancelled(final Context context, final UploadInfo uploadInfo) { - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceConfig.kt b/uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceConfig.kt new file mode 100644 index 00000000..f42c45b3 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceConfig.kt @@ -0,0 +1,183 @@ +package net.gotev.uploadservice + +import android.content.IntentFilter +import android.os.Build +import net.gotev.uploadservice.data.RetryPolicyConfig +import net.gotev.uploadservice.network.HttpStack +import net.gotev.uploadservice.network.hurl.HurlStack +import net.gotev.uploadservice.schemehandlers.ContentResolverSchemeHandler +import net.gotev.uploadservice.schemehandlers.FileSchemeHandler +import net.gotev.uploadservice.schemehandlers.SchemeHandler +import java.lang.reflect.InvocationTargetException +import java.util.* + +object UploadServiceConfig { + + private const val uploadActionSuffix = ".uploadservice.action.upload" + private const val broadcastActionSuffix = ".uploadservice.broadcast.status" + + private const val fileScheme = "/" + private const val contentScheme = "content://" + + private val schemeHandlers by lazy { + LinkedHashMap>().apply { + this[fileScheme] = FileSchemeHandler::class.java + this[contentScheme] = ContentResolverSchemeHandler::class.java + } + } + + /** + * Namespace with which Upload Service is going to operate. This must be set in application + * subclass onCreate method before anything else. + */ + @JvmStatic + var namespace: String? = null + get() = if (field == null) + throw IllegalArgumentException("You have to set namespace to BuildConfig.APPLICATION_ID in your Application subclass") + else + field + + /** + * Sets how many threads to use to handle concurrent uploads. + */ + @JvmStatic + var uploadPoolSize = Runtime.getRuntime().availableProcessors() + set(value) { + require(value >= 1) { "Upload pool size min allowable value is 1. It cannot be $value" } + field = value + } + + /** + * When the number of threads is greater than [uploadPoolSize], this is the maximum time that + * excess idle threads will wait for new tasks before terminating. + */ + @JvmStatic + var keepAliveTimeSeconds = 5 + set(value) { + require(value >= 1) { "Keep alive time min allowable value is 1. It cannot be $value" } + field = value + } + + /** + * How many time to wait in idle before shutting down the service. + * The service is idle when is running, but no tasks are running. + */ + @JvmStatic + var idleTimeoutSeconds = 10 + set(value) { + require(value >= 1) { "Idle timeout min allowable value is 1. It cannot be $value" } + field = value + } + + /** + * Buffer size in bytes used for data transfer by the upload tasks. + */ + @JvmStatic + var bufferSizeBytes = 4096 + set(value) { + require(value >= 256) { "You can't set buffer size lower than 256 bytes" } + field = value + } + + /** + * Sets the HTTP Stack to use to perform HTTP based upload requests. + * By default [HurlStack] implementation is used. + */ + @JvmStatic + var httpStack: HttpStack = HurlStack() + + /** + * Interval between progress notifications in milliseconds. + * If the upload tasks report more frequently than this value, upload service will automatically apply throttling. + * Default is 6 updates per second + */ + @JvmStatic + var uploadProgressNotificationIntervalMillis: Long = 1000 / 6 + + /** + * Sets the Upload Service Retry Policy. Refer to [RetryPolicyConfig] docs for detailed + * explanation of each parameter. + */ + @JvmStatic + var retryPolicy = RetryPolicyConfig() + + /** + * If set to true, the service will go in foreground mode when doing uploads, + * lowering the probability of being killed by the system on low memory. + * This setting is used only when your uploads have a notification configuration. + * It's not possible to run in foreground without notifications, as per Android policy + * constraints, so if you set this to true, but you do upload tasks without a + * notification configuration, the service will simply run in background mode. + * + * NOTE: As of Android Oreo (API 26+), this setting is ignored as it always has to be true, + * because the service must run in the foreground and expose a notification to the user. + * https://developer.android.com/reference/android/content/Context.html#startForegroundService(android.content.Intent) + */ + @JvmStatic + var isForegroundService = true + get() = Build.VERSION.SDK_INT >= 26 || field + + @JvmStatic + val uploadAction: String + get() = "$namespace$uploadActionSuffix" + + @JvmStatic + val broadcastAction: String + get() = "$namespace$broadcastActionSuffix" + + /** + * Get the intent filter for Upload Service broadcast events + */ + @JvmStatic + val broadcastIntentFilter: IntentFilter + get() = IntentFilter(broadcastAction) + + /** + * Register a custom scheme handler. + * You cannot override existing File and content:// schemes. + * @param scheme scheme to support (e.g. content:// , yourCustomScheme://) + * @param handler scheme handler implementation + */ + @JvmStatic + fun addSchemeHandler(scheme: String, handler: Class) { + require(!(scheme == fileScheme || scheme == contentScheme)) { "Cannot override bundled scheme: $scheme! If you found a bug in a bundled scheme handler, please open an issue: https://github.com/gotev/android-upload-service" } + schemeHandlers[scheme] = handler + } + + @Throws(NoSuchMethodException::class, IllegalAccessException::class, InvocationTargetException::class, InstantiationException::class) + @JvmStatic + fun getSchemeHandler(path: String): SchemeHandler { + val trimmedPath = path.trim() + + for ((scheme, handler) in schemeHandlers) { + if (trimmedPath.startsWith(scheme, ignoreCase = true)) { + return handler.newInstance().apply { + init(trimmedPath) + } + } + } + + throw UnsupportedOperationException("Unsupported scheme for $path. Currently supported schemes are ${schemeHandlers.keys}") + } + + override fun toString(): String { + return """ + { + "uploadServiceVersion": "${BuildConfig.VERSION_NAME}", + "androidApiVesion": ${Build.VERSION.SDK_INT}, + "namespace": "$namespace", + "uploadPoolSize": $uploadPoolSize, + "deviceProcessors": ${Runtime.getRuntime().availableProcessors()}, + "keepAliveTimeSeconds": $keepAliveTimeSeconds, + "idleTimeoutSeconds": $idleTimeoutSeconds, + "bufferSizeBytes": $bufferSizeBytes, + "httpStack": "${httpStack::class.java.name}", + "uploadProgressNotificationIntervalMillis": $uploadProgressNotificationIntervalMillis, + "retryPolicy": $retryPolicy, + "isForegroundService": $isForegroundService, + "schemeHandlers": [${schemeHandlers.entries.joinToString { (key, value) -> "\"$key\": \"$value\"" }}] + } + """.trimIndent() + } + +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceSingleBroadcastReceiver.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceSingleBroadcastReceiver.java deleted file mode 100644 index 19357a2d..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadServiceSingleBroadcastReceiver.java +++ /dev/null @@ -1,57 +0,0 @@ -package net.gotev.uploadservice; - -import android.content.Context; - -import java.lang.ref.WeakReference; - -/** - * Utility broadcast receiver to receive only the events for a single uploadID. - * @author Aleksandar Gotev - */ - -public class UploadServiceSingleBroadcastReceiver extends UploadServiceBroadcastReceiver { - - private WeakReference mDelegate; - private String mUploadID = null; - - public UploadServiceSingleBroadcastReceiver(UploadStatusDelegate delegate) { - mDelegate = new WeakReference<>(delegate); - } - - public void setUploadID(String uploadID) { - mUploadID = uploadID; - } - - @Override - protected boolean shouldAcceptEventFrom(UploadInfo uploadInfo) { - return mUploadID != null && uploadInfo.getUploadId().equals(mUploadID); - } - - @Override - public final void onProgress(Context context, UploadInfo uploadInfo) { - if (mDelegate != null && mDelegate.get() != null) { - mDelegate.get().onProgress(context, uploadInfo); - } - } - - @Override - public final void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, Exception exception) { - if (mDelegate != null && mDelegate.get() != null) { - mDelegate.get().onError(context, uploadInfo, serverResponse, exception); - } - } - - @Override - public final void onCompleted(Context context, UploadInfo uploadInfo, ServerResponse serverResponse) { - if (mDelegate != null && mDelegate.get() != null) { - mDelegate.get().onCompleted(context, uploadInfo, serverResponse); - } - } - - @Override - public final void onCancelled(Context context, UploadInfo uploadInfo) { - if (mDelegate != null && mDelegate.get() != null) { - mDelegate.get().onCancelled(context, uploadInfo); - } - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadStatusDelegate.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadStatusDelegate.java deleted file mode 100644 index 8daf3c19..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadStatusDelegate.java +++ /dev/null @@ -1,50 +0,0 @@ -package net.gotev.uploadservice; - -import android.content.Context; - -/** - * Defines the methods that has to be implemented by a class who wants to listen for upload status - * events. - * - * @author Aleksandar Gotev - */ -public interface UploadStatusDelegate { - /** - * Called when the upload progress changes. Override this method to add your own logic. - * - * @param context context - * @param uploadInfo upload status information - */ - void onProgress(final Context context, final UploadInfo uploadInfo); - - /** - * Called when an error happens during the upload. Override this method to add your own logic. - * - * @param context context - * @param uploadInfo upload status information - * @param serverResponse response got from the server. It can be null if the server has not - * responded or if the request has not reached the server due to a - * networking problem. - * @param exception exception that caused the error. It can be null if the request successfully - * reached the server, but it responded with a 4xx or 5xx status code. - */ - void onError(final Context context, final UploadInfo uploadInfo, - final ServerResponse serverResponse, final Exception exception); - - /** - * Called when the upload is completed successfully. Override this method to add your own logic. - * - * @param context context - * @param uploadInfo upload status information - * @param serverResponse response got from the server - */ - void onCompleted(final Context context, final UploadInfo uploadInfo, final ServerResponse serverResponse); - - /** - * Called when the upload is cancelled. Override this method to add your own logic. - * - * @param context context - * @param uploadInfo upload status information - */ - void onCancelled(final Context context, final UploadInfo uploadInfo); -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadTask.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadTask.java deleted file mode 100644 index 50983b20..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadTask.java +++ /dev/null @@ -1,607 +0,0 @@ -package net.gotev.uploadservice; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.content.Context; -import android.content.Intent; -import android.media.RingtoneManager; -import android.net.Uri; -import android.os.Build; -import android.os.Handler; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.Charset; -import java.util.ArrayList; -import java.util.Date; -import java.util.Iterator; -import java.util.List; - -import androidx.core.app.NotificationCompat; - -/** - * Base class to subclass when creating upload tasks. It contains the logic common to all the tasks, - * such as notification management, status broadcast, retry logic and some utility methods. - * - * @author Aleksandar Gotev - */ -public abstract class UploadTask implements Runnable { - - private static final String LOG_TAG = UploadTask.class.getSimpleName(); - - /** - * Constant which indicates that the upload task has been completed successfully. - */ - protected static final int TASK_COMPLETED_SUCCESSFULLY = 200; - - /** - * Constant which indicates an empty response from the server. - */ - protected static final byte[] EMPTY_RESPONSE = "".getBytes(Charset.forName("UTF-8")); - - /** - * Reference to the upload service instance. - */ - protected UploadService service; - - /** - * Contains all the parameters set in {@link UploadRequest}. - */ - protected UploadTaskParameters params = null; - - /** - * Contains the absolute local path of the successfully uploaded files. - */ - private final List successfullyUploadedFiles = new ArrayList<>(); - - /** - * Flag indicating if the operation should continue or is cancelled. You should never - * explicitly set this value in your subclasses, as it's written by the Upload Service - * when you call {@link UploadService#stopUpload(String)}. If this value is false, you should - * terminate your upload task as soon as possible, so be sure to check the status when - * performing long running operations such as data transfer. As a rule of thumb, check this - * value at every step of the upload protocol you are implementing, and after that each chunk - * of data that has been successfully transferred. - */ - protected boolean shouldContinue = true; - - private int notificationId; - private long lastProgressNotificationTime; - private NotificationManager notificationManager; - private Handler mainThreadHandler; - private long notificationCreationTimeMillis; - - /** - * Total bytes to transfer. You should initialize this value in the - * {@link UploadTask#upload()} method of your subclasses, before starting the upload data - * transfer. - */ - protected long totalBytes; - - /** - * Total transferred bytes. You should update this value in your subclasses when you upload - * some data, and before calling {@link UploadTask#broadcastProgress(long, long)} - */ - protected long uploadedBytes; - - /** - * Start timestamp of this upload task. - */ - private final long startTime; - - /** - * Counter of the upload attempts that has been made; - */ - private int attempts; - - /** - * Implementation of the upload logic. - * - * @throws Exception if an error occurs - */ - abstract protected void upload() throws Exception; - - /** - * Implement in subclasses to be able to do something when the upload is successful. - */ - protected void onSuccessfulUpload() { - } - - public UploadTask() { - startTime = new Date().getTime(); - } - - /** - * Initializes the {@link UploadTask}.
- * Override this method in subclasses to perform custom task initialization and to get the - * custom parameters set in {@link UploadRequest#initializeIntent(Intent)} method. - * - * @param service Upload Service instance. You should use this reference as your context. - * @param intent intent sent to the service to start the upload - * @throws IOException if an I/O exception occurs while initializing - */ - protected void init(UploadService service, Intent intent) throws IOException { - this.notificationManager = (NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE); - this.params = intent.getParcelableExtra(UploadService.PARAM_TASK_PARAMETERS); - this.service = service; - this.mainThreadHandler = new Handler(service.getMainLooper()); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && params.notificationConfig != null) { - String notificationChannelId = params.notificationConfig.getNotificationChannelId(); - - if (notificationChannelId == null) { - params.notificationConfig.setNotificationChannelId(UploadService.NAMESPACE); - notificationChannelId = UploadService.NAMESPACE; - } - - if (notificationManager.getNotificationChannel(notificationChannelId) == null) { - NotificationChannel channel = new NotificationChannel(notificationChannelId, "Upload Service channel", NotificationManager.IMPORTANCE_LOW); - if (!params.notificationConfig.isRingToneEnabled()) { - channel.setSound(null, null); - } - notificationManager.createNotificationChannel(channel); - } - } - - } - - @Override - public final void run() { - - createNotification(new UploadInfo(params.id)); - - attempts = 0; - - int errorDelay = UploadService.INITIAL_RETRY_WAIT_TIME; - - while (attempts <= params.getMaxRetries() && shouldContinue) { - attempts++; - - try { - upload(); - break; - - } catch (Exception exc) { - if (!shouldContinue) { - break; - } else if (attempts > params.getMaxRetries()) { - broadcastError(exc); - } else { - Logger.error(LOG_TAG, "Error in uploadId " + params.id - + " on attempt " + attempts - + ". Waiting " + errorDelay / 1000 + "s before next attempt. ", exc); - - long beforeSleepTs = System.currentTimeMillis(); - - while (shouldContinue && System.currentTimeMillis() < (beforeSleepTs + errorDelay)) { - try { - Thread.sleep(2000); - } catch (Throwable ignored) { - } - } - - errorDelay *= UploadService.BACKOFF_MULTIPLIER; - if (errorDelay > UploadService.MAX_RETRY_WAIT_TIME) { - errorDelay = UploadService.MAX_RETRY_WAIT_TIME; - } - } - } - } - - if (!shouldContinue) { - broadcastCancelled(); - } - } - - /** - * Sets the last time the notification was updated. - * This is handled automatically and you should never call this method. - * - * @param lastProgressNotificationTime time in milliseconds - * @return {@link UploadTask} - */ - protected final UploadTask setLastProgressNotificationTime(long lastProgressNotificationTime) { - this.lastProgressNotificationTime = lastProgressNotificationTime; - return this; - } - - /** - * Sets the upload notification ID for this task. - * This gets called by {@link UploadService} when the task is initialized. - * You should never call this method. - * - * @param notificationId notification ID - * @return {@link UploadTask} - */ - protected final UploadTask setNotificationId(int notificationId) { - this.notificationId = notificationId; - return this; - } - - /** - * Broadcasts a progress update. - * - * @param uploadedBytes number of bytes which has been uploaded to the server - * @param totalBytes total bytes of the request - */ - protected final void broadcastProgress(final long uploadedBytes, final long totalBytes) { - - long currentTime = System.currentTimeMillis(); - if (uploadedBytes < totalBytes && currentTime < lastProgressNotificationTime + UploadService.PROGRESS_REPORT_INTERVAL) { - return; - } - - setLastProgressNotificationTime(currentTime); - - Logger.debug(LOG_TAG, "Broadcasting upload progress for " + params.id - + ": " + uploadedBytes + " bytes of " + totalBytes); - - final UploadInfo uploadInfo = new UploadInfo(params.id, startTime, uploadedBytes, - totalBytes, (attempts - 1), - successfullyUploadedFiles, - pathStringListFrom(params.files)); - - BroadcastData data = new BroadcastData() - .setStatus(BroadcastData.Status.IN_PROGRESS) - .setUploadInfo(uploadInfo); - - final UploadStatusDelegate delegate = UploadService.getUploadStatusDelegate(params.id); - if (delegate != null) { - mainThreadHandler.post(new Runnable() { - @Override - public void run() { - delegate.onProgress(service, uploadInfo); - } - }); - } else { - service.sendBroadcast(data.getIntent()); - } - - updateNotificationProgress(uploadInfo); - } - - /** - * Broadcasts a completion status update and informs the {@link UploadService} that the task - * executes successfully. - * Call this when the task has completed the upload request and has received the response - * from the server. - * - * @param response response got from the server - */ - protected final void broadcastCompleted(final ServerResponse response) { - - final boolean successfulUpload = response.getHttpCode() >= 200 && response.getHttpCode() < 400; - - if (successfulUpload) { - onSuccessfulUpload(); - - if (params.autoDeleteSuccessfullyUploadedFiles && !successfullyUploadedFiles.isEmpty()) { - for (String filePath : successfullyUploadedFiles) { - deleteFile(new File(filePath)); - } - } - } - - Logger.debug(LOG_TAG, "Broadcasting upload " + (successfulUpload ? "completed" : "error") - + " for " + params.id); - - final UploadInfo uploadInfo = new UploadInfo(params.id, startTime, uploadedBytes, - totalBytes, (attempts - 1), - successfullyUploadedFiles, - pathStringListFrom(params.files)); - - final UploadNotificationConfig notificationConfig = params.notificationConfig; - - if (notificationConfig != null) { - if (successfulUpload && notificationConfig.getCompleted().message != null) { - updateNotification(uploadInfo, notificationConfig.getCompleted()); - - } else if (notificationConfig.getError().message != null) { - updateNotification(uploadInfo, notificationConfig.getError()); - } - } - - final UploadStatusDelegate delegate = UploadService.getUploadStatusDelegate(params.id); - if (delegate != null) { - mainThreadHandler.post(new Runnable() { - @Override - public void run() { - if (successfulUpload) { - delegate.onCompleted(service, uploadInfo, response); - } else { - delegate.onError(service, uploadInfo, response, null); - } - } - }); - } else { - BroadcastData data = new BroadcastData() - .setStatus(successfulUpload ? BroadcastData.Status.COMPLETED : BroadcastData.Status.ERROR) - .setUploadInfo(uploadInfo) - .setServerResponse(response); - - service.sendBroadcast(data.getIntent()); - } - - service.taskCompleted(params.id); - } - - /** - * Broadcast a cancelled status. - * This called automatically by {@link UploadTask} when the user cancels the request, - * and the specific implementation of {@link UploadTask#upload()} either - * returns or throws an exception. You should never call this method explicitly in your - * implementation. - */ - protected final void broadcastCancelled() { - - Logger.debug(LOG_TAG, "Broadcasting cancellation for upload with ID: " + params.id); - - final UploadInfo uploadInfo = new UploadInfo(params.id, startTime, uploadedBytes, - totalBytes, (attempts - 1), - successfullyUploadedFiles, - pathStringListFrom(params.files)); - - final UploadNotificationConfig notificationConfig = params.notificationConfig; - - if (notificationConfig != null && notificationConfig.getCancelled().message != null) { - updateNotification(uploadInfo, notificationConfig.getCancelled()); - } - - BroadcastData data = new BroadcastData() - .setStatus(BroadcastData.Status.CANCELLED) - .setUploadInfo(uploadInfo); - - final UploadStatusDelegate delegate = UploadService.getUploadStatusDelegate(params.id); - if (delegate != null) { - mainThreadHandler.post(new Runnable() { - @Override - public void run() { - delegate.onCancelled(service, uploadInfo); - } - }); - } else { - service.sendBroadcast(data.getIntent()); - } - - service.taskCompleted(params.id); - } - - /** - * Add a file to the list of the successfully uploaded files and remove it from the file list - * - * @param file file on the device - */ - protected final void addSuccessfullyUploadedFile(UploadFile file) { - if (!successfullyUploadedFiles.contains(file.path)) { - successfullyUploadedFiles.add(file.path); - params.files.remove(file); - } - } - - /** - * Adds all the files to the list of successfully uploaded files. - * This will automatically remove them from the params.getFiles() list. - */ - protected final void addAllFilesToSuccessfullyUploadedFiles() { - for (Iterator iterator = params.files.iterator(); iterator.hasNext(); ) { - UploadFile file = iterator.next(); - - if (!successfullyUploadedFiles.contains(file.path)) { - successfullyUploadedFiles.add(file.path); - } - iterator.remove(); - } - } - - /** - * Gets the list of all the successfully uploaded files. - * You must not modify this list in your subclasses! You can only read its contents. - * If you want to add an element into it, - * use {@link UploadTask#addSuccessfullyUploadedFile(UploadFile)} - * - * @return list of strings - */ - protected final List getSuccessfullyUploadedFiles() { - return successfullyUploadedFiles; - } - - /** - * Broadcasts an error. - * This called automatically by {@link UploadTask} when the specific implementation of - * {@link UploadTask#upload()} throws an exception and there aren't any left retries. - * You should never call this method explicitly in your implementation. - * - * @param exception exception to broadcast. It's the one thrown by the specific implementation - * of {@link UploadTask#upload()} - */ - private void broadcastError(final Exception exception) { - - Logger.info(LOG_TAG, "Broadcasting error for upload with ID: " - + params.id + ". " + exception.getMessage()); - - final UploadInfo uploadInfo = new UploadInfo(params.id, startTime, uploadedBytes, - totalBytes, (attempts - 1), - successfullyUploadedFiles, - pathStringListFrom(params.files)); - - final UploadNotificationConfig notificationConfig = params.notificationConfig; - - if (notificationConfig != null && notificationConfig.getError().message != null) { - updateNotification(uploadInfo, notificationConfig.getError()); - } - - BroadcastData data = new BroadcastData() - .setStatus(BroadcastData.Status.ERROR) - .setUploadInfo(uploadInfo) - .setException(exception); - - final UploadStatusDelegate delegate = UploadService.getUploadStatusDelegate(params.id); - if (delegate != null) { - mainThreadHandler.post(new Runnable() { - @Override - public void run() { - delegate.onError(service, uploadInfo, null, exception); - } - }); - } else { - service.sendBroadcast(data.getIntent()); - } - - service.taskCompleted(params.id); - } - - /** - * If the upload task is initialized with a notification configuration, this handles its - * creation. - * - * @param uploadInfo upload information and statistics - */ - private void createNotification(UploadInfo uploadInfo) { - if (params.notificationConfig == null || params.notificationConfig.getProgress().message == null) - return; - - UploadNotificationStatusConfig statusConfig = params.notificationConfig.getProgress(); - notificationCreationTimeMillis = System.currentTimeMillis(); - - NotificationCompat.Builder notification = new NotificationCompat.Builder(service, params.notificationConfig.getNotificationChannelId()) - .setWhen(notificationCreationTimeMillis) - .setContentTitle(Placeholders.replace(statusConfig.title, uploadInfo)) - .setContentText(Placeholders.replace(statusConfig.message, uploadInfo)) - .setContentIntent(statusConfig.getClickIntent(service)) - .setSmallIcon(statusConfig.iconResourceID) - .setLargeIcon(statusConfig.largeIcon) - .setColor(statusConfig.iconColorResourceID) - .setGroup(UploadService.NAMESPACE) - .setProgress(100, 0, true) - .setOngoing(true); - - statusConfig.addActionsToNotificationBuilder(notification); - - Notification builtNotification = notification.build(); - - if (service.holdForegroundNotification(params.id, builtNotification)) { - notificationManager.cancel(notificationId); - } else { - notificationManager.notify(notificationId, builtNotification); - } - } - - /** - * Informs the {@link UploadService} that the task has made some progress. You should call this - * method from your task whenever you have successfully transferred some bytes to the server. - * - * @param uploadInfo upload information and statistics - */ - private void updateNotificationProgress(UploadInfo uploadInfo) { - if (params.notificationConfig == null || params.notificationConfig.getProgress().message == null) - return; - - UploadNotificationStatusConfig statusConfig = params.notificationConfig.getProgress(); - - NotificationCompat.Builder notification = new NotificationCompat.Builder(service, params.notificationConfig.getNotificationChannelId()) - .setWhen(notificationCreationTimeMillis) - .setContentTitle(Placeholders.replace(statusConfig.title, uploadInfo)) - .setContentText(Placeholders.replace(statusConfig.message, uploadInfo)) - .setContentIntent(statusConfig.getClickIntent(service)) - .setSmallIcon(statusConfig.iconResourceID) - .setLargeIcon(statusConfig.largeIcon) - .setColor(statusConfig.iconColorResourceID) - .setGroup(UploadService.NAMESPACE) - .setProgress((int) uploadInfo.getTotalBytes(), (int) uploadInfo.getUploadedBytes(), false) - .setOngoing(true); - - statusConfig.addActionsToNotificationBuilder(notification); - - Notification builtNotification = notification.build(); - - if (service.holdForegroundNotification(params.id, builtNotification)) { - notificationManager.cancel(notificationId); - } else { - notificationManager.notify(notificationId, builtNotification); - } - } - - private void setRingtone(NotificationCompat.Builder notification) { - - if (params.notificationConfig.isRingToneEnabled() && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - Uri sound = RingtoneManager.getActualDefaultRingtoneUri(service, RingtoneManager.TYPE_NOTIFICATION); - notification.setSound(sound); - } - - } - - private void updateNotification(UploadInfo uploadInfo, UploadNotificationStatusConfig statusConfig) { - if (params.notificationConfig == null) return; - - notificationManager.cancel(notificationId); - - if (statusConfig.message == null) return; - - if (!statusConfig.autoClear) { - NotificationCompat.Builder notification = new NotificationCompat.Builder(service, params.notificationConfig.getNotificationChannelId()) - .setContentTitle(Placeholders.replace(statusConfig.title, uploadInfo)) - .setContentText(Placeholders.replace(statusConfig.message, uploadInfo)) - .setContentIntent(statusConfig.getClickIntent(service)) - .setAutoCancel(statusConfig.clearOnAction) - .setSmallIcon(statusConfig.iconResourceID) - .setLargeIcon(statusConfig.largeIcon) - .setColor(statusConfig.iconColorResourceID) - .setGroup(UploadService.NAMESPACE) - .setProgress(0, 0, false) - .setOngoing(false); - - statusConfig.addActionsToNotificationBuilder(notification); - - setRingtone(notification); - - // this is needed because the main notification used to show progress is ongoing - // and a new one has to be created to allow the user to dismiss it - uploadInfo.setNotificationID(notificationId + 1); - notificationManager.notify(notificationId + 1, notification.build()); - } - } - - /** - * Tries to delete a file from the device. - * If it fails, the error will be printed in the LogCat. - * - * @param fileToDelete file to delete - * @return true if the file has been deleted, otherwise false. - */ - private boolean deleteFile(File fileToDelete) { - boolean deleted = false; - - try { - deleted = fileToDelete.delete(); - - if (!deleted) { - Logger.error(LOG_TAG, "Unable to delete: " - + fileToDelete.getAbsolutePath()); - } else { - Logger.info(LOG_TAG, "Successfully deleted: " - + fileToDelete.getAbsolutePath()); - } - - } catch (Exception exc) { - Logger.error(LOG_TAG, - "Error while deleting: " + fileToDelete.getAbsolutePath() + - " Check if you granted: android.permission.WRITE_EXTERNAL_STORAGE", exc); - } - - return deleted; - } - - private static List pathStringListFrom(List files) { - final List filesLeft = new ArrayList<>(files.size()); - for (UploadFile f : files) { - filesLeft.add(f.getPath()); - } - return filesLeft; - } - - public final void cancel() { - this.shouldContinue = false; - } - -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadTask.kt b/uploadservice/src/main/java/net/gotev/uploadservice/UploadTask.kt new file mode 100644 index 00000000..ff0f50d6 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/UploadTask.kt @@ -0,0 +1,272 @@ +package net.gotev.uploadservice + +import android.content.Context +import net.gotev.uploadservice.data.UploadFile +import net.gotev.uploadservice.data.UploadInfo +import net.gotev.uploadservice.data.UploadTaskParameters +import net.gotev.uploadservice.exceptions.UploadError +import net.gotev.uploadservice.exceptions.UserCancelledUploadException +import net.gotev.uploadservice.logger.UploadServiceLogger +import net.gotev.uploadservice.network.HttpStack +import net.gotev.uploadservice.network.ServerResponse +import net.gotev.uploadservice.observer.task.UploadTaskObserver +import java.io.IOException +import java.util.* + +abstract class UploadTask : Runnable { + + companion object { + private val TAG = UploadTask::class.java.simpleName + } + + private var lastProgressNotificationTime: Long = 0 + + protected lateinit var context: Context + lateinit var params: UploadTaskParameters + + /** + * Flag indicating if the operation should continue or is cancelled. You should never + * explicitly set this value in your subclasses, as it's written by the Upload Service + * when you call [UploadService.stopUpload]. If this value is false, you should + * terminate your upload task as soon as possible, so be sure to check the status when + * performing long running operations such as data transfer. As a rule of thumb, check this + * value at every step of the upload protocol you are implementing, and after that each chunk + * of data that has been successfully transferred. + */ + var shouldContinue = true + + private val observers = ArrayList(2) + + /** + * Total bytes to transfer. You should initialize this value in the + * [UploadTask.upload] method of your subclasses, before starting the upload data + * transfer. + */ + var totalBytes: Long = 0 + + /** + * Total transferred bytes. You should update this value in your subclasses when you upload + * some data, and before calling [UploadTask.onProgress] + */ + private var uploadedBytes: Long = 0 + + /** + * Start timestamp of this upload task. + */ + private val startTime: Long = Date().time + + /** + * Counter of the upload attempts that has been made; + */ + private var attempts: Int = 0 + + private var errorDelay = UploadServiceConfig.retryPolicy.initialWaitTimeSeconds.toLong() + + private val uploadInfo: UploadInfo + get() = UploadInfo( + uploadId = params.id, + startTime = startTime, + uploadedBytes = uploadedBytes, + totalBytes = totalBytes, + numberOfRetries = attempts - 1, + files = params.files + ) + + /** + * Implementation of the upload logic. + * + * @throws Exception if an error occurs + */ + @Throws(Exception::class) + protected abstract fun upload(httpStack: HttpStack) + + private inline fun doForEachObserver(action: UploadTaskObserver.() -> Unit) { + observers.forEach { + try { + action(it) + } catch (exc: Throwable) { + UploadServiceLogger.error(TAG, exc) { "(uploadID: ${params.id}) error while dispatching event to observer" } + } + } + } + + /** + * Initializes the [UploadTask].

+ * Override this method in subclasses to perform custom task initialization and to get the + * custom parameters set in [UploadRequest.initializeIntent] method. + * + * @param context Upload Service instance. You should use this reference as your context. + * @param intent intent sent to the context to start the upload + * @throws IOException if an I/O exception occurs while initializing + */ + @Throws(IOException::class) + fun init(context: Context, taskParams: UploadTaskParameters, vararg taskObservers: UploadTaskObserver) { + this.context = context + this.params = taskParams + taskObservers.forEach { observers.add(it) } + performInitialization() + } + + open fun performInitialization() {} + + private fun resetAttempts() { + attempts = 0 + errorDelay = UploadServiceConfig.retryPolicy.initialWaitTimeSeconds.toLong() + } + + override fun run() { + doForEachObserver { initialize(UploadInfo(params.id)) } + resetAttempts() + + while (attempts <= params.maxRetries && shouldContinue) { + try { + resetUploadedBytes() + upload(UploadServiceConfig.httpStack) + break + } catch (exc: Throwable) { + if (!shouldContinue) { + break + } else if (attempts >= params.maxRetries) { + onError(exc) + } else { + UploadServiceLogger.error(TAG) { "(uploadID: ${params.id}) error on attempt ${attempts + 1}. Waiting ${errorDelay}s before next attempt. " } + + val sleepDeadline = System.currentTimeMillis() + errorDelay * 1000 + + sleepWhile { shouldContinue && System.currentTimeMillis() < sleepDeadline } + + errorDelay *= UploadServiceConfig.retryPolicy.multiplier.toLong() + + if (errorDelay > UploadServiceConfig.retryPolicy.maxWaitTimeSeconds) { + errorDelay = UploadServiceConfig.retryPolicy.maxWaitTimeSeconds.toLong() + } + } + } + + attempts++ + } + + if (!shouldContinue) { + onUserCancelledUpload() + } + } + + private inline fun sleepWhile(millis: Long = 1000, condition: () -> Boolean) { + while (condition()) { + try { + Thread.sleep(millis) + } catch (exc: Throwable) { } + } + } + + protected fun resetUploadedBytes() { + uploadedBytes = 0 + } + + /** + * Broadcasts a progress update. + * + * @param uploadedBytes number of bytes which has been uploaded to the server + * @param totalBytes total bytes of the request + */ + protected fun onProgress(bytesSent: Long) { + uploadedBytes += bytesSent + if (shouldThrottle(uploadedBytes, totalBytes)) return + UploadServiceLogger.debug(TAG) { "(uploadID: ${params.id}) uploaded ${uploadedBytes * 100 / totalBytes}%, $uploadedBytes of $totalBytes bytes" } + doForEachObserver { onProgress(uploadInfo) } + } + + /** + * Broadcasts a completion status update and informs the [UploadService] that the task + * executes successfully. + * Call this when the task has completed the upload request and has received the response + * from the server. + * + * @param response response got from the server + */ + protected fun onResponseReceived(response: ServerResponse) { + UploadServiceLogger.debug(TAG) { "(uploadID: ${params.id}) upload ${if (response.isSuccessful) "completed" else "error"}" } + + if (response.isSuccessful) { + if (params.autoDeleteSuccessfullyUploadedFiles) { + for (file in successfullyUploadedFiles) { + if (file.handler.delete(context)) { + UploadServiceLogger.info(TAG) { "(uploadID: ${params.id}) successfully deleted: ${file.path}" } + } else { + UploadServiceLogger.error(TAG) { "(uploadID: ${params.id}) error while deleting: ${file.path}" } + } + } + } + + doForEachObserver { onSuccess(uploadInfo, response) } + } else { + doForEachObserver { onError(uploadInfo, UploadError(response)) } + } + + doForEachObserver { onCompleted(uploadInfo) } + } + + /** + * Broadcast a cancelled status. + * This called automatically by [UploadTask] when the user cancels the request, + * and the specific implementation of [UploadTask.upload] either + * returns or throws an exception. You should never call this method explicitly in your + * implementation. + */ + private fun onUserCancelledUpload() { + UploadServiceLogger.debug(TAG) { "(uploadID: ${params.id}) upload cancelled" } + onError(UserCancelledUploadException()) + } + + /** + * Broadcasts an error. + * This called automatically by [UploadTask] when the specific implementation of + * [UploadTask.upload] throws an exception and there aren't any left retries. + * You should never call this method explicitly in your implementation. + * + * @param exception exception to broadcast. It's the one thrown by the specific implementation + * of [UploadTask.upload] + */ + private fun onError(exception: Throwable) { + UploadServiceLogger.error(TAG, exception) { "(uploadID: ${params.id}) error" } + uploadInfo.let { + doForEachObserver { onError(it, exception) } + doForEachObserver { onCompleted(it) } + } + } + + /** + * Adds all the files to the list of successfully uploaded files. + * This will automatically remove them from the params.getFiles() list. + */ + protected fun setAllFilesHaveBeenSuccessfullyUploaded(value: Boolean = true) { + params.files.forEach { it.successfullyUploaded = value } + } + + /** + * Gets the list of all the successfully uploaded files. + * You must not modify this list in your subclasses! You can only read its contents. + * If you want to add an element into it, + * use [UploadTask.addSuccessfullyUploadedFile] + * + * @return list of strings + */ + protected val successfullyUploadedFiles: List + get() = params.files.filter { it.successfullyUploaded } + + fun cancel() { + shouldContinue = false + } + + private fun shouldThrottle(uploadedBytes: Long, totalBytes: Long): Boolean { + val currentTime = System.currentTimeMillis() + + if (uploadedBytes < totalBytes && currentTime < lastProgressNotificationTime + UploadServiceConfig.uploadProgressNotificationIntervalMillis) { + return true + } + + lastProgressNotificationTime = currentTime + return false + } + +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/UploadTaskParameters.java b/uploadservice/src/main/java/net/gotev/uploadservice/UploadTaskParameters.java deleted file mode 100644 index 0931a819..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/UploadTaskParameters.java +++ /dev/null @@ -1,82 +0,0 @@ -package net.gotev.uploadservice; - -import android.os.Parcel; -import android.os.Parcelable; - -import java.util.ArrayList; - -/** - * Class which contains all the basic parameters passed to the upload task. - * If you want to add more parameters, which are specific to your implementation, you should not - * extends this class, but instead create a new class which implements {@link Parcelable} and - * define a constant string which indicates the key used to store the serialized form of the class - * into the intent. Look at {@link HttpUploadTaskParameters} for an example. - * - * @author gotev (Aleksandar Gotev) - */ -public final class UploadTaskParameters implements Parcelable { - - public String id; - public String serverUrl; - private int maxRetries = 0; - public boolean autoDeleteSuccessfullyUploadedFiles = false; - public UploadNotificationConfig notificationConfig; - public ArrayList files = new ArrayList<>(); - - public UploadTaskParameters() { - - } - - // This is used to regenerate the object. - // All Parcelables must have a CREATOR that implements these two methods - public static final Creator CREATOR = - new Creator() { - @Override - public UploadTaskParameters createFromParcel(final Parcel in) { - return new UploadTaskParameters(in); - } - - @Override - public UploadTaskParameters[] newArray(final int size) { - return new UploadTaskParameters[size]; - } - }; - - @Override - public void writeToParcel(Parcel parcel, int arg1) { - parcel.writeString(id); - parcel.writeString(serverUrl); - parcel.writeInt(maxRetries); - parcel.writeByte((byte) (autoDeleteSuccessfullyUploadedFiles ? 1 : 0)); - parcel.writeParcelable(notificationConfig, 0); - parcel.writeList(files); - } - - private UploadTaskParameters(Parcel in) { - id = in.readString(); - serverUrl = in.readString(); - maxRetries = in.readInt(); - autoDeleteSuccessfullyUploadedFiles = in.readByte() == 1; - notificationConfig = in.readParcelable(UploadNotificationConfig.class.getClassLoader()); - in.readList(files, UploadFile.class.getClassLoader()); - } - - @Override - public int describeContents() { - return 0; - } - - public int getMaxRetries() { - return maxRetries; - } - - public UploadTaskParameters setMaxRetries(int maxRetries) { - if (maxRetries < 0) - this.maxRetries = 0; - else - this.maxRetries = maxRetries; - - return this; - } - -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/BroadcastData.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/BroadcastData.kt new file mode 100644 index 00000000..0da060c0 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/BroadcastData.kt @@ -0,0 +1,28 @@ +package net.gotev.uploadservice.data + +import android.content.Intent +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize +import net.gotev.uploadservice.UploadServiceConfig +import net.gotev.uploadservice.network.ServerResponse + +@Parcelize +internal data class BroadcastData @JvmOverloads constructor( + val status: UploadStatus, + val uploadInfo: UploadInfo, + val serverResponse: ServerResponse? = null, + val exception: Throwable? = null +) : Parcelable { + companion object { + private const val paramName = "broadcastData" + + fun fromIntent(intent: Intent): BroadcastData? { + return intent.getParcelableExtra(paramName) + } + } + + fun toIntent() = Intent(UploadServiceConfig.broadcastAction).apply { + setPackage(UploadServiceConfig.namespace) + putExtra(paramName, this@BroadcastData) + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/HttpUploadTaskParameters.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/HttpUploadTaskParameters.kt new file mode 100644 index 00000000..4431ffa9 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/HttpUploadTaskParameters.kt @@ -0,0 +1,17 @@ +package net.gotev.uploadservice.data + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize +import java.util.* + +/** + * Class which contains specific parameters for HTTP uploads. + */ +@Parcelize +data class HttpUploadTaskParameters( + var customUserAgent: String? = null, + var method: String = "POST", + var usesFixedLengthStreamingMode: Boolean = true, + val requestHeaders: ArrayList = ArrayList(5), + val requestParameters: ArrayList = ArrayList(5) +) : Parcelable diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/NameValue.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/NameValue.kt new file mode 100644 index 00000000..db13bd76 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/NameValue.kt @@ -0,0 +1,16 @@ +package net.gotev.uploadservice.data + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize +import net.gotev.uploadservice.extensions.isASCII + +@Parcelize +data class NameValue(val name: String, val value: String) : Parcelable { + fun validateAsHeader(): NameValue { + require(name.isASCII() && value.isASCII()) { + "Header $name and its value $value must be ASCII only! Read http://stackoverflow.com/a/4410331" + } + + return this + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/RetryPolicyConfig.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/RetryPolicyConfig.kt new file mode 100644 index 00000000..6e5c3b3a --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/RetryPolicyConfig.kt @@ -0,0 +1,35 @@ +package net.gotev.uploadservice.data + +data class RetryPolicyConfig( + /** + * Sets the time to wait in seconds before the next attempt when an upload fails + * for the first time. From the second time onwards, this value will be multiplied by + * [multiplier] to get the time to wait before the next attempt. + */ + val initialWaitTimeSeconds: Int = 1, + + /** + * Sets the maximum time to wait in seconds between two upload attempts. + * This is useful because every time an upload fails, the wait time gets multiplied by + * [multiplier] and it's not convenient that the value grows + * indefinitely. + */ + val maxWaitTimeSeconds: Int = 100, + + /** + * Sets the backoff timer multiplier. By default is set to 2, so every time that an upload + * fails, the time to wait between retries will be multiplied by 2. + * E.g. if the first time the wait time is 1s, the second time it will be 2s and the third + * time it will be 4s. + */ + val multiplier: Int = 2, + + /** + * Sets the default number of retries for each request. + */ + val defaultMaxRetries: Int = 3 +) { + override fun toString(): String { + return """{"initialWaitTimeSeconds": $initialWaitTimeSeconds, "maxWaitTimeSeconds": $maxWaitTimeSeconds, "multiplier": $multiplier, "defaultMaxRetries": $defaultMaxRetries}""" + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadFile.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadFile.kt new file mode 100644 index 00000000..43023600 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadFile.kt @@ -0,0 +1,29 @@ +package net.gotev.uploadservice.data + +import android.os.Parcelable +import kotlinx.android.parcel.IgnoredOnParcel +import kotlinx.android.parcel.Parcelize +import net.gotev.uploadservice.UploadServiceConfig +import net.gotev.uploadservice.schemehandlers.SchemeHandler +import java.util.* + +@Parcelize +data class UploadFile @JvmOverloads constructor( + val path: String, + val properties: LinkedHashMap = LinkedHashMap() +) : Parcelable { + + companion object { + private const val successfulUpload = "successful_upload" + } + + @IgnoredOnParcel + val handler: SchemeHandler by lazy { + UploadServiceConfig.getSchemeHandler(path) + } + + @IgnoredOnParcel + var successfullyUploaded: Boolean + get() = properties[successfulUpload]?.toBoolean() ?: false + set(value) { properties[successfulUpload] = value.toString() } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadInfo.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadInfo.kt new file mode 100644 index 00000000..55941c52 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadInfo.kt @@ -0,0 +1,119 @@ +package net.gotev.uploadservice.data + +import android.os.Parcelable +import kotlinx.android.parcel.IgnoredOnParcel +import kotlinx.android.parcel.Parcelize +import java.util.* + +@Parcelize +data class UploadInfo @JvmOverloads constructor( + /** + * Upload unique ID. + */ + val uploadId: String, + + /** + * Upload task's start timestamp in milliseconds. + */ + val startTime: Long = 0, + + /** + * Bytes upload so far. + */ + val uploadedBytes: Long = 0, + + /** + * Upload task total bytes. + */ + val totalBytes: Long = 0, + + /** + * Number of retries that has been made during the upload process. + * If no retries has been made, this value will be zero. + */ + val numberOfRetries: Int = 0, + + /** + * List of all the files present in this upload. + */ + val files: ArrayList = ArrayList() +) : Parcelable { + + @IgnoredOnParcel + private val currentTime: Long = Date().time + + /** + * Gets upload task's elapsed time in milliseconds. + * @return long value + */ + @IgnoredOnParcel + val elapsedTime: Long + get() = currentTime - startTime + + /** + * Gets the elapsed time as a string, expressed in seconds if the value is `< 60`, + * or expressed in minutes and seconds if the value is `>=` 60. + * @return string representation of the elapsed time + */ + @IgnoredOnParcel + val elapsedTimeString: String + get() { + var elapsedSeconds = (elapsedTime / 1000).toInt() + + if (elapsedSeconds == 0) return "0 sec" + + val minutes = elapsedSeconds / 60 + elapsedSeconds -= 60 * minutes + + return if (minutes == 0) { + "$elapsedSeconds sec" + } else { + "$minutes min $elapsedSeconds sec" + } + + } + + /** + * Gets the average upload rate in Kb/s (Kilo bit per second). + * @return upload rate + */ + @IgnoredOnParcel + val uploadRate: Double + get() { + // wait at least a second to stabilize the upload rate a little bit + if (elapsedTime < 1000) return 0.0 + + return uploadedBytes.toDouble() / 1024 * 8 / (elapsedTime / 1000) + } + + /** + * Returns a string representation of the upload rate, expressed in the most convenient unit of + * measurement (Mbit/s if the value is `>=` 1024, B/s if the value is `< 1`, otherwise Kbit/s) + * @return string representation of the upload rate (e.g. 234 Kbit/s) + */ + @IgnoredOnParcel + val uploadRateString: String + get() { + if (uploadRate < 1) { + return "${(uploadRate * 1000).toInt()} bit/s" + } + + if (uploadRate >= 1024) { + return "${(uploadRate / 1024).toInt()} Mb/s" + } + + return "${uploadRate.toInt()} Kb/s" + } + + /** + * Gets the upload progress in percent (from 0 to 100). + * @return integer value + */ + @IgnoredOnParcel + val progressPercent: Int + get() = if (totalBytes == 0L) 0 else (uploadedBytes * 100 / totalBytes).toInt() + + @IgnoredOnParcel + val successfullyUploadedFiles: Int + get() = files.count { it.successfullyUploaded } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationAction.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationAction.kt new file mode 100644 index 00000000..63237aec --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationAction.kt @@ -0,0 +1,22 @@ +package net.gotev.uploadservice.data + +import android.app.PendingIntent +import android.os.Parcelable +import androidx.core.app.NotificationCompat +import kotlinx.android.parcel.Parcelize + +/** + * Class which represents a notification action. + * It is necessary because NotificationCompat.Action is not serializable or Parcelable, thus it's + * not possible to pass it directly in the intents. + */ +@Parcelize +data class UploadNotificationAction( + val icon: Int, + val title: CharSequence, + val intent: PendingIntent +) : Parcelable { + fun asAction(): NotificationCompat.Action { + return NotificationCompat.Action.Builder(icon, title, intent).build() + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationConfig.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationConfig.kt new file mode 100644 index 00000000..b230cf69 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationConfig.kt @@ -0,0 +1,118 @@ +package net.gotev.uploadservice.data + +import android.app.PendingIntent +import android.graphics.Bitmap +import android.os.Parcelable +import kotlinx.android.parcel.IgnoredOnParcel +import kotlinx.android.parcel.Parcelize +import net.gotev.uploadservice.Placeholders + +@Parcelize +class UploadNotificationConfig @JvmOverloads constructor( + var notificationChannelId: String, + //TODO: study how to apply this to notification channels + var isRingToneEnabled: Boolean = true, + val progress: UploadNotificationStatusConfig = UploadNotificationStatusConfig().apply { + message = "Uploading at ${Placeholders.UPLOAD_RATE} (${Placeholders.PROGRESS})" + }, + val completed: UploadNotificationStatusConfig = UploadNotificationStatusConfig().apply { + message = "Upload completed successfully in ${Placeholders.ELAPSED_TIME}" + }, + val error: UploadNotificationStatusConfig = UploadNotificationStatusConfig().apply { + message = "Error during upload" + }, + val cancelled: UploadNotificationStatusConfig = UploadNotificationStatusConfig().apply { + message = "Upload cancelled" + } +) : Parcelable { + + @IgnoredOnParcel + private val allStatuses by lazy { + arrayOf(progress, completed, error, cancelled) + } + + /** + * Sets the notification title for all the notification statuses. + * + * @param title Title to show in the notification icon + * @return [UploadNotificationConfig] + */ + fun setTitleForAllStatuses(title: String): UploadNotificationConfig { + allStatuses.forEach { it.title = title } + return this + } + + /** + * Sets the same notification icon for all the notification statuses. + * + * @param resourceID Resource ID of the icon to use + * @return [UploadNotificationConfig] + */ + fun setIconForAllStatuses(resourceID: Int): UploadNotificationConfig { + allStatuses.forEach { it.iconResourceID = resourceID } + return this + } + + /** + * Sets the same notification icon for all the notification statuses. + * + * @param iconColorResourceID Resource ID of the color to use + * @return [UploadNotificationConfig] + */ + fun setIconColorForAllStatuses(iconColorResourceID: Int): UploadNotificationConfig { + allStatuses.forEach { it.iconColorResourceID = iconColorResourceID } + return this + } + + /** + * Sets the same large notification icon for all the notification statuses. + * + * @param largeIcon Bitmap of the icon to use + * @return [UploadNotificationConfig] + */ + fun setLargeIconForAllStatuses(largeIcon: Bitmap): UploadNotificationConfig { + allStatuses.forEach { it.largeIcon = largeIcon } + return this + } + + /** + * Sets the same intent to be executed when the user taps on the notification + * for all the notification statuses. + * + * @param clickIntent [android.app.PendingIntent] containing the user's action + * @return [UploadNotificationConfig] + */ + fun setClickIntentForAllStatuses(clickIntent: PendingIntent): UploadNotificationConfig { + allStatuses.forEach { it.clickIntent = clickIntent } + return this + } + + /** + * Adds the same notification action for all the notification statuses. + * So for example, if you want to have the same action while the notification is in progress, + * cancelled, completed or with an error, this method will save you lines of code. + * + * @param action [UploadNotificationAction] action to add + * @return [UploadNotificationConfig] + */ + fun addActionForAllStatuses(action: UploadNotificationAction): UploadNotificationConfig { + allStatuses.forEach { it.actions.add(action) } + return this + } + + /** + * Sets whether or not to clear the notification when the user taps on it + * for all the notification statuses. + * + * + * This would not affect progress notification, as it's ongoing and managed by the upload + * service. + * + * @param clearOnAction true to clear the notification, otherwise false + * @return [UploadNotificationConfig] + */ + fun setClearOnActionForAllStatuses(clearOnAction: Boolean): UploadNotificationConfig { + allStatuses.forEach { it.clearOnAction = clearOnAction } + return this + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationStatusConfig.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationStatusConfig.kt new file mode 100644 index 00000000..3989734d --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadNotificationStatusConfig.kt @@ -0,0 +1,78 @@ +package net.gotev.uploadservice.data + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Parcelable +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.app.NotificationCompat +import kotlinx.android.parcel.Parcelize +import java.util.* + +@Parcelize +data class UploadNotificationStatusConfig( + /** + * Notification title. + */ + var title: String? = "File Upload", + + /** + * Notification message. + */ + var message: String? = null, + + /** + * Clear the notification automatically. + * This would not affect progress notification, as it's ongoing and managed by upload service. + * It's used to be able to automatically dismiss cancelled, error or completed notifications. + */ + var autoClear: Boolean = false, + + /** + * Notification icon. + */ + @DrawableRes var iconResourceID: Int = android.R.drawable.ic_menu_upload, + + /** + * Large notification icon. + */ + var largeIcon: Bitmap? = null, + + /** + * Icon color tint. + */ + @ColorInt var iconColorResourceID: Int = NotificationCompat.COLOR_DEFAULT, + + /** + * Intent to be performed when the user taps on the notification. + */ + var clickIntent: PendingIntent? = null, + + /** + * Clear the notification automatically when the clickIntent is performed. + * This would not affect progress notification, as it's ongoing and managed by upload service. + */ + var clearOnAction: Boolean = false, + + /** + * List of actions to be added to this notification. + */ + var actions: ArrayList = ArrayList(3) +) : Parcelable { + fun getClickIntent(context: Context): PendingIntent { + return clickIntent ?: PendingIntent.getBroadcast( + context, + 0, + Intent(), + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + fun addActionsToNotificationBuilder(builder: NotificationCompat.Builder) { + actions.forEach { + builder.addAction(it.asAction()) + } + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadStatus.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadStatus.kt new file mode 100644 index 00000000..8ea171bf --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadStatus.kt @@ -0,0 +1,8 @@ +package net.gotev.uploadservice.data + +enum class UploadStatus { + IN_PROGRESS, + SUCCESS, + ERROR, + COMPLETED +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadTaskParameters.kt b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadTaskParameters.kt new file mode 100644 index 00000000..001ec414 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/data/UploadTaskParameters.kt @@ -0,0 +1,15 @@ +package net.gotev.uploadservice.data + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class UploadTaskParameters( + val id: String, + val serverUrl: String, + val maxRetries: Int, + val autoDeleteSuccessfullyUploadedFiles: Boolean, + val notificationConfig: UploadNotificationConfig?, + val files: ArrayList, + val additionalParameters: Parcelable? = null +) : Parcelable diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/exceptions/Exceptions.kt b/uploadservice/src/main/java/net/gotev/uploadservice/exceptions/Exceptions.kt new file mode 100644 index 00000000..dbcca2b3 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/exceptions/Exceptions.kt @@ -0,0 +1,6 @@ +package net.gotev.uploadservice.exceptions + +import net.gotev.uploadservice.network.ServerResponse + +class UserCancelledUploadException : Throwable("User cancelled upload") +class UploadError(val serverResponse: ServerResponse) : Throwable("Upload error") diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/extensions/CollectionsExtensions.kt b/uploadservice/src/main/java/net/gotev/uploadservice/extensions/CollectionsExtensions.kt new file mode 100644 index 00000000..41f9199f --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/extensions/CollectionsExtensions.kt @@ -0,0 +1,15 @@ +package net.gotev.uploadservice.extensions + +import net.gotev.uploadservice.data.NameValue + +fun ArrayList.addHeader(name: String, value: String) { + add(NameValue(name, value).validateAsHeader()) +} + +fun LinkedHashMap.setOrRemove(key: String, value: String?) { + if (value == null) { + remove(key) + } else { + this[key] = value + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/extensions/ContextExtensions.kt b/uploadservice/src/main/java/net/gotev/uploadservice/extensions/ContextExtensions.kt new file mode 100644 index 00000000..cc6c75f9 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/extensions/ContextExtensions.kt @@ -0,0 +1,28 @@ +package net.gotev.uploadservice.extensions + +import android.content.Context +import android.content.Intent +import android.os.Build +import net.gotev.uploadservice.UploadService +import net.gotev.uploadservice.UploadServiceConfig +import net.gotev.uploadservice.UploadTask +import net.gotev.uploadservice.data.UploadTaskParameters + +fun Context.startNewUpload(taskClass: Class, params: UploadTaskParameters): String { + val intent = Intent(this, UploadService::class.java).apply { + action = UploadServiceConfig.uploadAction + putExtra(UploadService.taskClass, taskClass.name) + putExtra(UploadService.taskParameters, params) + } + + if (Build.VERSION.SDK_INT >= 26) { + require(params.notificationConfig != null) { + "Android Oreo and newer (API 26+) requires a notification configuration for the upload service to run. https://developer.android.com/reference/android/content/Context.html#startForegroundService(android.content.Intent)" + } + startForegroundService(intent) + } else { + startService(intent) + } + + return params.id +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/extensions/StringExtensions.kt b/uploadservice/src/main/java/net/gotev/uploadservice/extensions/StringExtensions.kt new file mode 100644 index 00000000..9643e973 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/extensions/StringExtensions.kt @@ -0,0 +1,62 @@ +package net.gotev.uploadservice.extensions + +import android.webkit.MimeTypeMap +import java.net.URL + +internal fun String?.isASCII(): Boolean { + if (this.isNullOrBlank()) + return false + + for (index in 0 until length) { + if (this[index].toInt() > 127) { + return false + } + } + + return true +} + +internal const val APPLICATION_OCTET_STREAM = "application/octet-stream" +internal const val VIDEO_MP4 = "video/mp4" + +/** + * Tries to auto-detect the content type (MIME type) of a specific file. + * @param absolutePath absolute path to the file + * @return content type (MIME type) of the file, or application/octet-stream if no content + * type could be determined automatically + */ +fun String.autoDetectMimeType(): String { + val index = lastIndexOf(".") + + return if (index in 0 until lastIndex) { + val extension = substring(index + 1).toLowerCase() + + if (extension == "mp4") { + VIDEO_MP4 + } else { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: APPLICATION_OCTET_STREAM + } + } else { + APPLICATION_OCTET_STREAM + } +} + +fun String.isValidHttpUrl(): Boolean { + if (!startsWith("http://") && !startsWith("https://")) return false + + return try { + URL(this) + true + } catch (exc: Throwable) { + false + } +} + +private val usAscii by lazy { Charsets.US_ASCII } +private val utf8 by lazy { Charsets.UTF_8 } + +val String.asciiByes: ByteArray + get() = toByteArray(usAscii) + +val String.utf8Bytes: ByteArray + get() = toByteArray(utf8) diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/extensions/WakeLockExtensions.kt b/uploadservice/src/main/java/net/gotev/uploadservice/extensions/WakeLockExtensions.kt new file mode 100644 index 00000000..d4d6774d --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/extensions/WakeLockExtensions.kt @@ -0,0 +1,21 @@ +package net.gotev.uploadservice.extensions + +import android.content.Context +import android.os.PowerManager + +fun PowerManager.WakeLock?.safeRelease() { + this?.apply { if (isHeld) release() } +} + +fun Context.acquirePartialWakeLock(currentWakeLock: PowerManager.WakeLock?, tag: String): PowerManager.WakeLock { + if (currentWakeLock?.isHeld == true) { + return currentWakeLock + } + + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + + return powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag).apply { + setReferenceCounted(false) + if (!isHeld) acquire() + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/http/BodyWriter.java b/uploadservice/src/main/java/net/gotev/uploadservice/http/BodyWriter.java deleted file mode 100644 index 154d2f88..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/http/BodyWriter.java +++ /dev/null @@ -1,82 +0,0 @@ -package net.gotev.uploadservice.http; - -import net.gotev.uploadservice.UploadService; - -import java.io.IOException; -import java.io.InputStream; - -/** - * Exposes the methods to be implemented to write the request body. - * If you want to use an internal cache or buffer, remember to always get its size value from - * {@link UploadService#BUFFER_SIZE} and to clear everything when not needed to prevent memory leaks - * @author Aleksandar Gotev - */ - -public abstract class BodyWriter { - - /** - * Receives the stream write progress and has the ability to cancel it. - */ - public interface OnStreamWriteListener { - /** - * Indicates if the writing of the stream into the body should continue. - * @return true to continue writing the stream into the body, false to cancel - */ - boolean shouldContinueWriting(); - - /** - * Called every time that a bunch of bytes were written to the body - * @param bytesWritten number of written bytes - */ - void onBytesWritten(int bytesWritten); - } - - /** - * Writes an input stream to the request body. - * The stream will be automatically closed after successful write or if an exception is thrown. - * @param stream input stream from which to read - * @param listener listener which gets notified when bytes are written and which controls if - * the transfer should continue - * @throws IOException if an I/O error occurs - */ - public final void writeStream(InputStream stream, OnStreamWriteListener listener) throws IOException { - if (listener == null) - throw new IllegalArgumentException("listener MUST not be null!"); - - byte[] buffer = new byte[UploadService.BUFFER_SIZE]; - int bytesRead; - - try { - while (listener.shouldContinueWriting() && (bytesRead = stream.read(buffer, 0, buffer.length)) > 0) { - write(buffer, bytesRead); - flush(); - listener.onBytesWritten(bytesRead); - } - } finally { - stream.close(); - } - } - - /** - * Write a byte array into the request body. - * @param bytes array with the bytes to write - * @throws IOException if an error occurs while writing - */ - public abstract void write(byte[] bytes) throws IOException; - - /** - * Write a portion of a byte array into the request body. - * @param bytes array with the bytes to write - * @param lengthToWriteFromStart how many bytes to write, starting from the first one in - * the array - * @throws IOException if an error occurs while writing - */ - public abstract void write(byte[] bytes, int lengthToWriteFromStart) throws IOException; - - /** - * Ensures the bytes written to the body are all transmitted to the server and clears - * the local buffer. - * @throws IOException if an error occurs while flushing the buffer - */ - public abstract void flush() throws IOException; -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/http/HttpConnection.java b/uploadservice/src/main/java/net/gotev/uploadservice/http/HttpConnection.java deleted file mode 100644 index 9bd576c0..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/http/HttpConnection.java +++ /dev/null @@ -1,62 +0,0 @@ -package net.gotev.uploadservice.http; - -import net.gotev.uploadservice.NameValue; -import net.gotev.uploadservice.ServerResponse; - -import java.io.IOException; -import java.util.List; - -/** - * Defines the methods that has to be implemented by an HTTP connection. - * If you're implementing your custom HTTP connection, remember to never cache anything, - * especially in BodyWriter methods, as this will surely cause memory issues when uploading - * large files. The only things which you are allowed to cache are the response code and body - * from the server, which must not be large though. - * @author gotev (Aleksandar Gotev) - */ -public interface HttpConnection { - - /** - * Delegate called when the body is ready to be written. - */ - interface RequestBodyDelegate { - - /** - * Handles the writing of the request body. - * @param bodyWriter object with which to write on the body - * @throws IOException if an error occurs while writing the body - */ - void onBodyReady(BodyWriter bodyWriter) throws IOException; - } - - /** - * Set request headers. - * @param requestHeaders request headers to set - * @throws IOException if an error occurs while setting request headers - * @return instance - */ - HttpConnection setHeaders(List requestHeaders) throws IOException; - - /** - * Sets the total body bytes. - * @param totalBodyBytes total number of bytes - * @param isFixedLengthStreamingMode true if the fixed length streaming mode must be used. If - * it's false, chunked streaming mode has to be used - * @return instance - */ - HttpConnection setTotalBodyBytes(long totalBodyBytes, boolean isFixedLengthStreamingMode); - - /** - * Gets the server response. - * @return object containing the server response status, headers and body. - * @param delegate delegate which handles the writing of the request body - * @throws IOException if an error occurs while getting the server response - * @return response from server - */ - ServerResponse getResponse(RequestBodyDelegate delegate) throws IOException; - - /** - * Closes the connection and frees all the allocated resources. - */ - void close(); -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/http/HttpStack.java b/uploadservice/src/main/java/net/gotev/uploadservice/http/HttpStack.java deleted file mode 100644 index 2b6bcead..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/http/HttpStack.java +++ /dev/null @@ -1,19 +0,0 @@ -package net.gotev.uploadservice.http; - -import java.io.IOException; - -/** - * Defines the methods that has to be implemented by an HTTP stack. - * @author gotev (Aleksandar Gotev) - */ -public interface HttpStack { - - /** - * Creates a new connection for a given URL and HTTP Method. - * @param method HTTP Method - * @param url URL to which to connect to - * @return new connection object - * @throws IOException if an error occurs while creating the connection object - */ - HttpConnection createNewConnection(String method, String url) throws IOException; -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlBodyWriter.java b/uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlBodyWriter.java deleted file mode 100644 index 4538b6b9..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlBodyWriter.java +++ /dev/null @@ -1,34 +0,0 @@ -package net.gotev.uploadservice.http.impl; - -import net.gotev.uploadservice.http.BodyWriter; - -import java.io.IOException; -import java.io.OutputStream; - -/** - * @author Aleksandar Gotev - */ - -public class HurlBodyWriter extends BodyWriter { - - private OutputStream mOutputStream; - - public HurlBodyWriter(OutputStream outputStream) { - mOutputStream = outputStream; - } - - @Override - public void write(byte[] bytes) throws IOException { - mOutputStream.write(bytes); - } - - @Override - public void write(byte[] bytes, int lengthToWriteFromStart) throws IOException { - mOutputStream.write(bytes, 0, lengthToWriteFromStart); - } - - @Override - public void flush() throws IOException { - mOutputStream.flush(); - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlStack.java b/uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlStack.java deleted file mode 100644 index 808ca023..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlStack.java +++ /dev/null @@ -1,42 +0,0 @@ -package net.gotev.uploadservice.http.impl; - -import net.gotev.uploadservice.http.HttpConnection; -import net.gotev.uploadservice.http.HttpStack; - -import java.io.IOException; - -/** - * HttpUrlConnection stack implementation. - * @author gotev (Aleksandar Gotev) - */ -public class HurlStack implements HttpStack { - - private boolean mFollowRedirects; - private boolean mUseCaches; - private int mConnectTimeout; - private int mReadTimeout; - - public HurlStack() { - mFollowRedirects = true; - mUseCaches = false; - mConnectTimeout = 15000; - mReadTimeout = 30000; - } - - public HurlStack(boolean followRedirects, - boolean useCaches, - int connectTimeout, - int readTimeout) { - mFollowRedirects = followRedirects; - mUseCaches = useCaches; - mConnectTimeout = connectTimeout; - mReadTimeout = readTimeout; - } - - @Override - public HttpConnection createNewConnection(String method, String url) throws IOException { - return new HurlStackConnection(method, url, mFollowRedirects, mUseCaches, - mConnectTimeout, mReadTimeout); - } - -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlStackConnection.java b/uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlStackConnection.java deleted file mode 100644 index 49e31023..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/http/impl/HurlStackConnection.java +++ /dev/null @@ -1,173 +0,0 @@ -package net.gotev.uploadservice.http.impl; - -import net.gotev.uploadservice.Logger; -import net.gotev.uploadservice.NameValue; -import net.gotev.uploadservice.ServerResponse; -import net.gotev.uploadservice.UploadService; -import net.gotev.uploadservice.http.HttpConnection; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import javax.net.ssl.HttpsURLConnection; - -/** - * {@link HttpConnection} implementation using {@link HttpURLConnection}. - * @author gotev (Aleksandar Gotev) - */ -public class HurlStackConnection implements HttpConnection { - - private static final String LOG_TAG = HurlStackConnection.class.getSimpleName(); - - private HttpURLConnection mConnection; - - public HurlStackConnection(String method, String url, boolean followRedirects, - boolean useCaches, int connectTimeout, int readTimeout) - throws IOException { - Logger.debug(getClass().getSimpleName(), "creating new connection"); - - URL urlObj = new URL(url); - - if (urlObj.getProtocol().equals("https")) { - mConnection = (HttpsURLConnection) urlObj.openConnection(); - } else { - mConnection = (HttpURLConnection) urlObj.openConnection(); - } - - mConnection.setDoInput(true); - mConnection.setDoOutput(true); - mConnection.setConnectTimeout(connectTimeout); - mConnection.setReadTimeout(readTimeout); - mConnection.setUseCaches(useCaches); - mConnection.setInstanceFollowRedirects(followRedirects); - mConnection.setRequestMethod(method); - } - - @Override - public HttpConnection setHeaders(List requestHeaders) throws IOException { - for (final NameValue param : requestHeaders) { - mConnection.setRequestProperty(param.getName(), param.getValue()); - } - - return this; - } - - @Override - public HttpConnection setTotalBodyBytes(long totalBodyBytes, boolean isFixedLengthStreamingMode) { - if (isFixedLengthStreamingMode) { - if (android.os.Build.VERSION.SDK_INT >= 19) { - mConnection.setFixedLengthStreamingMode(totalBodyBytes); - - } else { - if (totalBodyBytes > Integer.MAX_VALUE) - throw new RuntimeException("You need Android API version 19 or newer to " - + "upload more than 2GB in a single request using " - + "fixed size content length. Try switching to " - + "chunked mode instead, but make sure your server side supports it!"); - - mConnection.setFixedLengthStreamingMode((int) totalBodyBytes); - } - } else { - mConnection.setChunkedStreamingMode(0); - } - - return this; - } - - private byte[] getServerResponseBody() throws IOException { - InputStream stream = null; - - try { - if (mConnection.getResponseCode() / 100 == 2) { - stream = mConnection.getInputStream(); - } else { - stream = mConnection.getErrorStream(); - } - - return getResponseBodyAsByteArray(stream); - - } finally { - if (stream != null) { - try { - stream.close(); - } catch (Exception exc) { - Logger.error(LOG_TAG, "Error while closing server response stream", exc); - } - } - } - } - - private byte[] getResponseBodyAsByteArray(final InputStream inputStream) { - ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); - - byte[] buffer = new byte[UploadService.BUFFER_SIZE]; - int bytesRead; - - try { - while ((bytesRead = inputStream.read(buffer, 0, buffer.length)) > 0) { - byteStream.write(buffer, 0, bytesRead); - } - } catch (Exception ignored) {} - - return byteStream.toByteArray(); - } - - private LinkedHashMap getServerResponseHeaders() throws IOException { - Map> headers = mConnection.getHeaderFields(); - if (headers == null) - return null; - - LinkedHashMap out = new LinkedHashMap<>(headers.size()); - - for (Map.Entry> entry : headers.entrySet()) { - if (entry.getKey() != null) { - StringBuilder headerValue = new StringBuilder(); - for (String value : entry.getValue()) { - headerValue.append(value); - } - out.put(entry.getKey(), headerValue.toString()); - } - } - - return out; - } - - @Override - public ServerResponse getResponse(final RequestBodyDelegate delegate) throws IOException { - - final HurlBodyWriter bodyWriter = new HurlBodyWriter(mConnection.getOutputStream()); - delegate.onBodyReady(bodyWriter); - bodyWriter.flush(); - - return new ServerResponse(mConnection.getResponseCode(), - getServerResponseBody(), getServerResponseHeaders()); - } - - @Override - public void close() { - Logger.debug(getClass().getSimpleName(), "closing connection"); - - if (mConnection != null) { - try { - mConnection.getInputStream().close(); - } catch (Exception ignored) { } - - try { - mConnection.getOutputStream().flush(); - mConnection.getOutputStream().close(); - } catch (Exception ignored) { } - - try { - mConnection.disconnect(); - } catch (Exception exc) { - Logger.error(LOG_TAG, "Error while closing connection", exc); - } - } - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/logger/DefaultLoggerDelegate.kt b/uploadservice/src/main/java/net/gotev/uploadservice/logger/DefaultLoggerDelegate.kt new file mode 100644 index 00000000..9024476e --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/logger/DefaultLoggerDelegate.kt @@ -0,0 +1,22 @@ +package net.gotev.uploadservice.logger + +import android.util.Log + +class DefaultLoggerDelegate : UploadServiceLogger.Delegate { + + companion object { + private const val TAG = "UploadService" + } + + override fun error(tag: String, message: String, exception: Throwable?) { + Log.e(TAG, "$tag - $message", exception) + } + + override fun debug(tag: String, message: String) { + Log.i(TAG, "$tag - $message") + } + + override fun info(tag: String, message: String) { + Log.i(TAG, "$tag - $message") + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/logger/UploadServiceLogger.kt b/uploadservice/src/main/java/net/gotev/uploadservice/logger/UploadServiceLogger.kt new file mode 100644 index 00000000..ad6e25a3 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/logger/UploadServiceLogger.kt @@ -0,0 +1,59 @@ +package net.gotev.uploadservice.logger + +import java.lang.ref.WeakReference + +object UploadServiceLogger { + private var logLevel = LogLevel.OFF + private val defaultLogger = DefaultLoggerDelegate() + private var loggerDelegate = WeakReference(defaultLogger) + + enum class LogLevel { + DEBUG, + INFO, + ERROR, + OFF + } + + interface Delegate { + fun error(tag: String, message: String, exception: Throwable?) + fun debug(tag: String, message: String) + fun info(tag: String, message: String) + } + + @Synchronized + @JvmStatic + fun setDelegate(delegate: Delegate?) { + loggerDelegate = WeakReference(delegate ?: defaultLogger) + } + + @Synchronized + @JvmStatic + fun setLogLevel(level: LogLevel) { + logLevel = level + } + + @Synchronized + @JvmStatic + fun setDevelopmentMode(devModeOn: Boolean) { + logLevel = if (devModeOn) LogLevel.DEBUG else LogLevel.OFF + } + + private fun loggerWithLevel(minLevel: LogLevel) = + if (logLevel > minLevel || logLevel == LogLevel.OFF) null else loggerDelegate.get() + + @JvmOverloads + @JvmStatic + fun error(tag: String, exception: Throwable? = null, message: () -> String) { + loggerWithLevel(LogLevel.ERROR)?.error(tag, message(), exception) + } + + @JvmStatic + fun info(tag: String, message: () -> String) { + loggerWithLevel(LogLevel.INFO)?.info(tag, message()) + } + + @JvmStatic + fun debug(tag: String, message: () -> String) { + loggerWithLevel(LogLevel.DEBUG)?.debug(tag, message()) + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/network/BodyWriter.kt b/uploadservice/src/main/java/net/gotev/uploadservice/network/BodyWriter.kt new file mode 100644 index 00000000..96e54414 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/network/BodyWriter.kt @@ -0,0 +1,86 @@ +package net.gotev.uploadservice.network + +import net.gotev.uploadservice.UploadService +import net.gotev.uploadservice.UploadServiceConfig +import java.io.Closeable + +import java.io.IOException +import java.io.InputStream + +abstract class BodyWriter(private val listener: OnStreamWriteListener) : Closeable { + + /** + * Receives the stream write progress and has the ability to cancel it. + */ + interface OnStreamWriteListener { + /** + * Indicates if the writing of the stream into the body should continue. + * @return true to continue writing the stream into the body, false to cancel + */ + fun shouldContinueWriting(): Boolean + + /** + * Called every time that a bunch of bytes were written to the body + * @param bytesWritten number of written bytes + */ + fun onBytesWritten(bytesWritten: Int) + } + + /** + * Writes an input stream to the request body. + * The stream will be automatically closed after successful write or if an exception is thrown. + * @param stream input stream from which to read + * @throws IOException if an I/O error occurs + */ + @Throws(IOException::class) + fun writeStream(stream: InputStream) { + val buffer = ByteArray(UploadServiceConfig.bufferSizeBytes) + + stream.use { + while (listener.shouldContinueWriting()) { + val bytesRead = it.read(buffer, 0, buffer.size) + if (bytesRead <= 0) break + + write(buffer, bytesRead) + } + } + } + + /** + * Write a byte array into the request body. + * @param bytes array with the bytes to write + * @throws IOException if an error occurs while writing + */ + fun write(bytes: ByteArray) { + internalWrite(bytes) + flush() + listener.onBytesWritten(bytes.size) + } + + /** + * Write a portion of a byte array into the request body. + * @param bytes array with the bytes to write + * @param lengthToWriteFromStart how many bytes to write, starting from the first one in + * the array + * @throws IOException if an error occurs while writing + */ + fun write(bytes: ByteArray, lengthToWriteFromStart: Int) { + internalWrite(bytes, lengthToWriteFromStart) + flush() + listener.onBytesWritten(lengthToWriteFromStart) + } + + @Throws(IOException::class) + abstract fun internalWrite(bytes: ByteArray) + + @Throws(IOException::class) + abstract fun internalWrite(bytes: ByteArray, lengthToWriteFromStart: Int) + + /** + * Ensures the bytes written to the body are all transmitted to the server and clears + * the local buffer. + * @throws IOException if an error occurs while flushing the buffer + */ + @Throws(IOException::class) + abstract fun flush() +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/network/HttpRequest.kt b/uploadservice/src/main/java/net/gotev/uploadservice/network/HttpRequest.kt new file mode 100644 index 00000000..4b5a233a --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/network/HttpRequest.kt @@ -0,0 +1,54 @@ +package net.gotev.uploadservice.network + +import net.gotev.uploadservice.data.NameValue +import java.io.Closeable + +import java.io.IOException + +interface HttpRequest: Closeable { + + /** + * Delegate called when the body is ready to be written. + */ + interface RequestBodyDelegate { + + /** + * Handles the writing of the request body. + * @param bodyWriter object with which to write on the body + * @throws IOException if an error occurs while writing the body + */ + @Throws(IOException::class) + fun onWriteRequestBody(bodyWriter: BodyWriter) + } + + /** + * Set request headers. + * @param requestHeaders request headers to set + * @throws IOException if an error occurs while setting request headers + * @return instance + */ + @Throws(IOException::class) + fun setHeaders(requestHeaders: List): HttpRequest + + /** + * Sets the total body bytes. + * @param totalBodyBytes total number of bytes + * @param isFixedLengthStreamingMode true if the fixed length streaming mode must be used. If + * it's false, chunked streaming mode has to be used. + * https://gist.github.com/CMCDragonkai/6bfade6431e9ffb7fe88 + * @return instance + */ + fun setTotalBodyBytes(totalBodyBytes: Long, isFixedLengthStreamingMode: Boolean): HttpRequest + + /** + * Gets the server response. + * @return object containing the server response status, headers and body. + * @param delegate delegate which handles the writing of the request body + * @param listener listener which gets notified when bytes are written and which controls if + * the transfer should continue + * @throws IOException if an error occurs while getting the server response + * @return response from server + */ + @Throws(IOException::class) + fun getResponse(delegate: RequestBodyDelegate, listener: BodyWriter.OnStreamWriteListener): ServerResponse +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/network/HttpStack.kt b/uploadservice/src/main/java/net/gotev/uploadservice/network/HttpStack.kt new file mode 100644 index 00000000..eae3314e --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/network/HttpStack.kt @@ -0,0 +1,16 @@ +package net.gotev.uploadservice.network + +import java.io.IOException + +interface HttpStack { + /** + * Creates a new connection for a given URL and HTTP Method. + * @param uploadId ID of the upload which requested this connection + * @param method HTTP Method + * @param url URL to which to connect to + * @return new connection object + * @throws IOException if an error occurs while creating the connection object + */ + @Throws(IOException::class) + fun newRequest(uploadId: String, method: String, url: String): HttpRequest +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/network/ServerResponse.kt b/uploadservice/src/main/java/net/gotev/uploadservice/network/ServerResponse.kt new file mode 100644 index 00000000..5517385c --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/network/ServerResponse.kt @@ -0,0 +1,57 @@ +package net.gotev.uploadservice.network + +import android.os.Parcelable +import kotlinx.android.parcel.IgnoredOnParcel +import kotlinx.android.parcel.Parcelize +import java.io.Serializable + +@Parcelize +data class ServerResponse( + /** + * server response response code. If you are implementing a Non-HTTP + * protocol, set this to 200 to inform that the task has been completed + * successfully. Integer values lower than 200 or greater that 299 indicates + * error response from server. + */ + val code: Int, + + /** + * server response body. + * If your server responds with a string, you can get it with + * [ServerResponse.bodyString]. + * If the string is a JSON, you can parse it using a library such as org.json + * (embedded in Android) or google's gson + * If your server does not return anything, set this to empty array. + */ + val body: ByteArray, + + /** + * server response headers + */ + val headers: LinkedHashMap +) : Parcelable, Serializable { + + /** + * Gets server response body as string. + * If the string is a JSON, you can parse it using a library such as org.json + * (embedded in Android) or google's gson + * @return string + */ + @IgnoredOnParcel + val bodyString: String + get() = String(body) + + @IgnoredOnParcel + val isSuccessful: Boolean + get() = code in 200..399 + + companion object { + fun successfulEmpty(): ServerResponse { + return ServerResponse( + code = 200, + body = ByteArray(1), + headers = LinkedHashMap() + ) + } + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlBodyWriter.kt b/uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlBodyWriter.kt new file mode 100644 index 00000000..f5b51123 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlBodyWriter.kt @@ -0,0 +1,28 @@ +package net.gotev.uploadservice.network.hurl + +import net.gotev.uploadservice.network.BodyWriter + +import java.io.IOException +import java.io.OutputStream + +class HurlBodyWriter(private val stream: OutputStream, listener: OnStreamWriteListener) : BodyWriter(listener) { + @Throws(IOException::class) + override fun internalWrite(bytes: ByteArray) { + stream.write(bytes) + } + + @Throws(IOException::class) + override fun internalWrite(bytes: ByteArray, lengthToWriteFromStart: Int) { + stream.write(bytes, 0, lengthToWriteFromStart) + } + + @Throws(IOException::class) + override fun flush() { + stream.flush() + } + + @Throws(IOException::class) + override fun close() { + stream.close() + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlStack.kt b/uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlStack.kt new file mode 100644 index 00000000..7dd7cace --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlStack.kt @@ -0,0 +1,19 @@ +package net.gotev.uploadservice.network.hurl + +import net.gotev.uploadservice.network.HttpRequest +import net.gotev.uploadservice.network.HttpStack + +import java.io.IOException + +class HurlStack(private val followRedirects: Boolean = true, + private val useCaches: Boolean = false, + private val connectTimeout: Int = 15000, + private val readTimeout: Int = 30000) : HttpStack { + + @Throws(IOException::class) + override fun newRequest(uploadId: String, method: String, url: String): HttpRequest { + return HurlStackRequest(uploadId, method, url, followRedirects, useCaches, + connectTimeout, readTimeout) + } + +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlStackRequest.kt b/uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlStackRequest.kt new file mode 100644 index 00000000..7b4daa09 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/network/hurl/HurlStackRequest.kt @@ -0,0 +1,134 @@ +package net.gotev.uploadservice.network.hurl + +import net.gotev.uploadservice.data.NameValue +import net.gotev.uploadservice.logger.UploadServiceLogger +import net.gotev.uploadservice.network.BodyWriter +import net.gotev.uploadservice.network.HttpRequest +import net.gotev.uploadservice.network.ServerResponse +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.* +import javax.net.ssl.HttpsURLConnection +import kotlin.collections.LinkedHashMap + +class HurlStackRequest( + private val uploadId: String, + method: String, + url: String, + followRedirects: Boolean, + useCaches: Boolean, + connectTimeout: Int, + readTimeout: Int) : HttpRequest { + + private val connection: HttpURLConnection + private val uuid = UUID.randomUUID().toString() + + private fun String.createConnection(): HttpURLConnection { + val url = URL(trim()) + + return if ("https".equals(url.protocol, ignoreCase = true)) { + url.openConnection() as HttpsURLConnection + } else { + url.openConnection() as HttpURLConnection + } + } + + init { + UploadServiceLogger.debug(javaClass.simpleName) { + "(uploadID: $uploadId) creating new HttpURLConnection" + } + + connection = url.createConnection().apply { + doInput = true + doOutput = true + this.connectTimeout = connectTimeout + this.readTimeout = readTimeout + this.useCaches = useCaches + instanceFollowRedirects = followRedirects + requestMethod = method + } + } + + private val responseBody: ByteArray + @Throws(IOException::class) + get() { + return if (connection.responseCode / 100 == 2) { + connection.inputStream + } else { + connection.errorStream + }.use { + it.readBytes() + } + } + + private val responseHeaders: LinkedHashMap + @Throws(IOException::class) + get() { + val headers = connection.headerFields ?: return LinkedHashMap(0) + + return LinkedHashMap(headers.size).apply { + headers.entries + .filter { it.key != null && it.value != null && it.value.isNotEmpty() } + .forEach { (key, values) -> + this[key] = values.first() + } + } + } + + @Throws(IOException::class) + override fun setHeaders(requestHeaders: List): HttpRequest { + for (param in requestHeaders) { + connection.setRequestProperty(param.name.trim(), param.value.trim()) + } + + return this + } + + override fun setTotalBodyBytes(totalBodyBytes: Long, isFixedLengthStreamingMode: Boolean): HttpRequest { + connection.apply { + if (isFixedLengthStreamingMode) { + setFixedLengthStreamingMode(totalBodyBytes) + } else { + setChunkedStreamingMode(0) + } + } + + return this + } + + @Throws(IOException::class) + override fun getResponse(delegate: HttpRequest.RequestBodyDelegate, listener: BodyWriter.OnStreamWriteListener) = use { + HurlBodyWriter(connection.outputStream, listener).use { + delegate.onWriteRequestBody(it) + } + + ServerResponse(connection.responseCode, responseBody, responseHeaders) + } + + override fun close() { + UploadServiceLogger.debug(javaClass.simpleName) { + "(uploadID: $uploadId) closing HttpURLConnection (uuid: $uuid)" + } + + try { + connection.inputStream.close() + } catch (ignored: Throwable) { + } + + try { + connection.outputStream.flush() + } catch (ignored: Throwable) { + } + + try { + connection.outputStream.close() + } catch (ignored: Throwable) { + } + + try { + connection.disconnect() + } catch (ignored: Throwable) { + } + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/observer/request/RequestObserver.kt b/uploadservice/src/main/java/net/gotev/uploadservice/observer/request/RequestObserver.kt new file mode 100644 index 00000000..386f6819 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/observer/request/RequestObserver.kt @@ -0,0 +1,103 @@ +package net.gotev.uploadservice.observer.request + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import net.gotev.uploadservice.UploadServiceConfig +import net.gotev.uploadservice.data.BroadcastData +import net.gotev.uploadservice.data.UploadInfo +import net.gotev.uploadservice.data.UploadStatus +import net.gotev.uploadservice.network.ServerResponse + +abstract class RequestObserver : BroadcastReceiver() { + + final override fun onReceive(context: Context, intent: Intent?) { + val safeIntent = intent ?: return + if (safeIntent.action != UploadServiceConfig.broadcastAction) return + val data = BroadcastData.fromIntent(safeIntent) ?: return + + val uploadInfo = data.uploadInfo + + if (!shouldAcceptEventFrom(uploadInfo)) { + return + } + + when (data.status) { + UploadStatus.IN_PROGRESS -> onProgress(context, uploadInfo) + UploadStatus.ERROR -> onError(context, uploadInfo, data.exception!!) + UploadStatus.SUCCESS -> onSuccess(context, uploadInfo, data.serverResponse!!) + UploadStatus.COMPLETED -> onCompleted(context, uploadInfo) + } + } + + /** + * Method called every time a new event arrives from an upload task, to decide whether or not + * to process it. This is useful if you want to filter events based on custom business logic. + * + * @param uploadInfo upload info to + * @return true to accept the event, false to discard it + */ + open fun shouldAcceptEventFrom(uploadInfo: UploadInfo): Boolean { + return true + } + + /** + * Register this upload receiver.

+ * If you use this receiver in an [android.app.Activity], you have to call this method inside + * [android.app.Activity.onResume], after `super.onResume();`.

+ * If you use it in a [android.app.Service], you have to + * call this method inside [android.app.Service.onCreate], after `super.onCreate();`. + * + * @param context context in which to register this receiver + */ + fun register(context: Context) { + context.registerReceiver(this, UploadServiceConfig.broadcastIntentFilter) + } + + /** + * Unregister this upload receiver.

+ * If you use this receiver in an [android.app.Activity], you have to call this method inside + * [android.app.Activity.onPause], after `super.onPause();`.

+ * If you use it in a [android.app.Service], you have to + * call this method inside [android.app.Service.onDestroy]. + * + * @param context context in which to unregister this receiver + */ + fun unregister(context: Context) { + context.unregisterReceiver(this) + } + + /** + * Called when the upload progress changes. + * + * @param context context + * @param uploadInfo upload status information + */ + abstract fun onProgress(context: Context, uploadInfo: UploadInfo) + + /** + * Called when the upload is completed successfully. + * + * @param context context + * @param uploadInfo upload status information + * @param serverResponse response got from the server + */ + abstract fun onSuccess(context: Context, uploadInfo: UploadInfo, serverResponse: ServerResponse) + + /** + * Called when an error happens during the upload. + * + * @param context context + * @param uploadInfo upload status information + * @param exception exception that caused the error + */ + abstract fun onError(context: Context, uploadInfo: UploadInfo, exception: Throwable) + + /** + * Called when the upload is completed wither with success or error. + * + * @param context context + * @param uploadInfo upload status information + */ + abstract fun onCompleted(context: Context, uploadInfo: UploadInfo) +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/observer/request/SingleRequestObserver.kt b/uploadservice/src/main/java/net/gotev/uploadservice/observer/request/SingleRequestObserver.kt new file mode 100644 index 00000000..021b6347 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/observer/request/SingleRequestObserver.kt @@ -0,0 +1,17 @@ +package net.gotev.uploadservice.observer.request + +import net.gotev.uploadservice.UploadRequest +import net.gotev.uploadservice.data.UploadInfo + +abstract class SingleRequestObserver : RequestObserver() { + private var uploadID: String? = null + + fun subscribe(request: UploadRequest<*>) { + uploadID = request.startUpload() + } + + final override fun shouldAcceptEventFrom(uploadInfo: UploadInfo): Boolean { + if (uploadID == null) return false + return uploadInfo.uploadId == uploadID + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/BroadcastEmitter.kt b/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/BroadcastEmitter.kt new file mode 100644 index 00000000..64b672dc --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/BroadcastEmitter.kt @@ -0,0 +1,32 @@ +package net.gotev.uploadservice.observer.task + +import android.content.Context +import net.gotev.uploadservice.data.BroadcastData +import net.gotev.uploadservice.data.UploadInfo +import net.gotev.uploadservice.data.UploadStatus +import net.gotev.uploadservice.network.ServerResponse + +class BroadcastEmitter(private val context: Context) : UploadTaskObserver { + + private fun send(data: BroadcastData) { + context.sendBroadcast(data.toIntent()) + } + + override fun initialize(info: UploadInfo) {} + + override fun onProgress(info: UploadInfo) { + send(BroadcastData(UploadStatus.IN_PROGRESS, info)) + } + + override fun onSuccess(info: UploadInfo, response: ServerResponse) { + send(BroadcastData(UploadStatus.SUCCESS, info, response)) + } + + override fun onCompleted(info: UploadInfo) { + send(BroadcastData(UploadStatus.COMPLETED, info)) + } + + override fun onError(info: UploadInfo, exception: Throwable) { + send(BroadcastData(UploadStatus.ERROR, info, null, exception)) + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/NotificationHandler.kt b/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/NotificationHandler.kt new file mode 100644 index 00000000..97151998 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/NotificationHandler.kt @@ -0,0 +1,116 @@ +package net.gotev.uploadservice.observer.task + +import android.app.NotificationManager +import android.content.Context +import android.media.RingtoneManager +import android.os.Build +import androidx.core.app.NotificationCompat +import net.gotev.uploadservice.data.UploadNotificationConfig +import net.gotev.uploadservice.data.UploadNotificationStatusConfig +import net.gotev.uploadservice.UploadService +import net.gotev.uploadservice.UploadServiceConfig +import net.gotev.uploadservice.data.UploadInfo +import net.gotev.uploadservice.exceptions.UserCancelledUploadException +import net.gotev.uploadservice.network.ServerResponse +import net.gotev.uploadservice.Placeholders + +class NotificationHandler(private val service: UploadService, + private val notificationId: Int, + private val uploadId: String, + private val config: UploadNotificationConfig) : UploadTaskObserver { + + private val notificationCreationTimeMillis by lazy { System.currentTimeMillis() } + private val notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private fun NotificationCompat.Builder.setRingtoneCompat(): NotificationCompat.Builder { + if (config.isRingToneEnabled && Build.VERSION.SDK_INT < 26) { + val sound = RingtoneManager.getActualDefaultRingtoneUri(service, RingtoneManager.TYPE_NOTIFICATION) + setSound(sound) + } + + return this + } + + private fun NotificationCompat.Builder.notify() { + build().apply { + if (service.holdForegroundNotification(uploadId, this)) { + notificationManager.cancel(notificationId) + } else { + notificationManager.notify(notificationId, this) + } + } + } + + private fun NotificationCompat.Builder.setCommonParameters(statusConfig: UploadNotificationStatusConfig, info: UploadInfo): NotificationCompat.Builder { + return setGroup(UploadServiceConfig.namespace) + .setContentTitle(Placeholders.replace(statusConfig.title, info)) + .setContentText(Placeholders.replace(statusConfig.message, info)) + .setContentIntent(statusConfig.getClickIntent(service)) + .setSmallIcon(statusConfig.iconResourceID) + .setLargeIcon(statusConfig.largeIcon) + .setColor(statusConfig.iconColorResourceID) + .apply { + statusConfig.addActionsToNotificationBuilder(this) + } + } + + private fun ongoingNotification(info: UploadInfo, statusConfig: UploadNotificationStatusConfig): NotificationCompat.Builder? { + if (statusConfig.message == null) return null + + return NotificationCompat.Builder(service, config.notificationChannelId) + .setWhen(notificationCreationTimeMillis) + .setCommonParameters(statusConfig, info) + .setOngoing(true) + } + + private fun updateNotification(info: UploadInfo, statusConfig: UploadNotificationStatusConfig) { + notificationManager.cancel(notificationId) + + if (statusConfig.message == null || statusConfig.autoClear) return + + val notification = NotificationCompat.Builder(service, config.notificationChannelId) + .setCommonParameters(statusConfig, info) + .setProgress(0, 0, false) + .setOngoing(false) + .setAutoCancel(statusConfig.clearOnAction) + .setRingtoneCompat() + .build() + + // this is needed because the main notification used to show progress is ongoing + // and a new one has to be created to allow the user to dismiss it + notificationManager.notify(notificationId + 1, notification) + } + + override fun initialize(info: UploadInfo) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.getNotificationChannel(config.notificationChannelId) + ?: throw IllegalArgumentException("The provided notification channel ID ${config.notificationChannelId} does not exist! You must create it at app startup and before Upload Service!") + } + + ongoingNotification(info, config.progress) + ?.setProgress(100, 0, true) + ?.notify() + } + + override fun onProgress(info: UploadInfo) { + ongoingNotification(info, config.progress) + ?.setProgress(100, info.progressPercent, false) + ?.notify() + } + + override fun onSuccess(info: UploadInfo, response: ServerResponse) { + updateNotification(info, config.completed) + } + + override fun onError(info: UploadInfo, exception: Throwable) { + val statusConfig = if (exception is UserCancelledUploadException) { + config.cancelled + } else { + config.error + } + + updateNotification(info, statusConfig) + } + + override fun onCompleted(info: UploadInfo) {} +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/TaskCompletionNotifier.kt b/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/TaskCompletionNotifier.kt new file mode 100644 index 00000000..90ea566e --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/TaskCompletionNotifier.kt @@ -0,0 +1,23 @@ +package net.gotev.uploadservice.observer.task + +import net.gotev.uploadservice.UploadService +import net.gotev.uploadservice.data.UploadInfo +import net.gotev.uploadservice.network.ServerResponse + +class TaskCompletionNotifier(private val service: UploadService): UploadTaskObserver { + override fun initialize(info: UploadInfo) { + } + + override fun onProgress(info: UploadInfo) { + } + + override fun onSuccess(info: UploadInfo, response: ServerResponse) { + } + + override fun onError(info: UploadInfo, exception: Throwable) { + } + + override fun onCompleted(info: UploadInfo) { + service.taskCompleted(info.uploadId) + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/UploadTaskObserver.kt b/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/UploadTaskObserver.kt new file mode 100644 index 00000000..86b9c4fe --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/observer/task/UploadTaskObserver.kt @@ -0,0 +1,12 @@ +package net.gotev.uploadservice.observer.task + +import net.gotev.uploadservice.data.UploadInfo +import net.gotev.uploadservice.network.ServerResponse + +interface UploadTaskObserver { + fun initialize(info: UploadInfo) + fun onProgress(info: UploadInfo) + fun onSuccess(info: UploadInfo, response: ServerResponse) + fun onError(info: UploadInfo, exception: Throwable) + fun onCompleted(info: UploadInfo) +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/protocols/binary/BinaryUploadRequest.kt b/uploadservice/src/main/java/net/gotev/uploadservice/protocols/binary/BinaryUploadRequest.kt new file mode 100644 index 00000000..b08b056d --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/protocols/binary/BinaryUploadRequest.kt @@ -0,0 +1,62 @@ +package net.gotev.uploadservice.protocols.binary + +import android.content.Context +import net.gotev.uploadservice.HttpUploadRequest +import net.gotev.uploadservice.UploadTask +import net.gotev.uploadservice.data.UploadFile +import net.gotev.uploadservice.logger.UploadServiceLogger +import java.io.FileNotFoundException +import java.io.IOException + +/** + * Binary file upload request. The binary upload uses a single file as the raw body of the + * upload request. + * @param context application context + * @param serverUrl URL of the server side script that will handle the multipart form upload. + * E.g.: http://www.yourcompany.com/your/script + */ +class BinaryUploadRequest(context: Context, serverUrl: String) : HttpUploadRequest(context, serverUrl) { + + override val taskClass: Class + get() = BinaryUploadTask::class.java + + /** + * Sets the file used as raw body of the upload request. + * + * @param path path to the file that you want to upload + * @throws FileNotFoundException if the file to upload does not exist + * @return [BinaryUploadRequest] + */ + @Throws(IOException::class) + fun setFileToUpload(path: String): BinaryUploadRequest { + files.clear() + files.add(UploadFile(path)) + return this + } + + override fun addParameter(paramName: String, paramValue: String): BinaryUploadRequest { + logDoesNotSupportParameters() + return this + } + + override fun addArrayParameter(paramName: String, vararg array: String): BinaryUploadRequest { + logDoesNotSupportParameters() + return this + } + + override fun addArrayParameter(paramName: String, list: List): BinaryUploadRequest { + logDoesNotSupportParameters() + return this + } + + override fun startUpload(): String { + require(files.isNotEmpty()) { "Set the file to be used in the request body first!" } + return super.startUpload() + } + + private fun logDoesNotSupportParameters() { + UploadServiceLogger.error(javaClass.simpleName) { + "This upload method does not support adding parameters" + } + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/protocols/binary/BinaryUploadTask.kt b/uploadservice/src/main/java/net/gotev/uploadservice/protocols/binary/BinaryUploadTask.kt new file mode 100644 index 00000000..47356eb9 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/protocols/binary/BinaryUploadTask.kt @@ -0,0 +1,18 @@ +package net.gotev.uploadservice.protocols.binary + +import net.gotev.uploadservice.HttpUploadTask +import net.gotev.uploadservice.network.BodyWriter + +/** + * Implements a binary file upload task. + */ +class BinaryUploadTask : HttpUploadTask() { + private val file by lazy { params.files.first().handler } + + override val bodyLength: Long + get() = file.size(context) + + override fun onWriteRequestBody(bodyWriter: BodyWriter) { + bodyWriter.writeStream(file.stream(context)) + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/MultipartUploadRequest.kt b/uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/MultipartUploadRequest.kt new file mode 100644 index 00000000..7818152e --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/MultipartUploadRequest.kt @@ -0,0 +1,61 @@ +package net.gotev.uploadservice.protocols.multipart + +import android.content.Context +import net.gotev.uploadservice.HttpUploadRequest +import net.gotev.uploadservice.UploadTask +import net.gotev.uploadservice.data.UploadFile +import java.io.FileNotFoundException + +/** + * HTTP/Multipart upload request. This is the most common way to upload files on a server. + * It's the same kind of request that browsers do when you use the <form> tag + * @param context application context + * @param serverUrl URL of the server side script that will handle the multipart form upload. + * E.g.: http://www.yourcompany.com/your/script + */ +class MultipartUploadRequest(context: Context, serverUrl: String) : HttpUploadRequest(context, serverUrl) { + + override val taskClass: Class + get() = MultipartUploadTask::class.java + + /** + * Adds a file to this upload request. + * + * @param filePath path to the file that you want to upload + * @param parameterName Name of the form parameter that will contain file's data + * @param fileName File name seen by the server side script. If null, the original file name + * will be used + * @param contentType Content type of the file. If null or empty, the mime type will be + * automatically detected. If fore some reasons autodetection fails, + * `application/octet-stream` will be used by default + * @return [MultipartUploadRequest] + */ + @Throws(FileNotFoundException::class) + @JvmOverloads + fun addFileToUpload(filePath: String, + parameterName: String, + fileName: String? = null, + contentType: String? = null): MultipartUploadRequest { + require(filePath.isNotBlank() && parameterName.isNotBlank()) { + "Please specify valid filePath and parameterName. They cannot be blank." + } + + files.add(UploadFile(filePath).apply { + this.parameterName = parameterName + + this.contentType = if (contentType.isNullOrBlank()) { + handler.contentType(context) + } else { + contentType + } + + remoteFileName = if (fileName.isNullOrBlank()) { + handler.name(context) + } else { + fileName + } + }) + + return this + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/MultipartUploadTask.kt b/uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/MultipartUploadTask.kt new file mode 100644 index 00000000..117d58b0 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/MultipartUploadTask.kt @@ -0,0 +1,85 @@ +package net.gotev.uploadservice.protocols.multipart + +import net.gotev.uploadservice.BuildConfig +import net.gotev.uploadservice.HttpUploadTask +import net.gotev.uploadservice.data.NameValue +import net.gotev.uploadservice.data.UploadFile +import net.gotev.uploadservice.extensions.addHeader +import net.gotev.uploadservice.extensions.asciiByes +import net.gotev.uploadservice.extensions.utf8Bytes +import net.gotev.uploadservice.network.BodyWriter + +/** + * Implements an HTTP Multipart upload task. + */ +class MultipartUploadTask : HttpUploadTask() { + + companion object { + private const val BOUNDARY_SIGNATURE = "-------UploadService${BuildConfig.VERSION_NAME}-" + private const val NEW_LINE = "\r\n" + private const val TWO_HYPHENS = "--" + } + + private val boundary = BOUNDARY_SIGNATURE + System.nanoTime() + private val boundaryBytes = (TWO_HYPHENS + boundary + NEW_LINE).asciiByes + private val trailerBytes = (TWO_HYPHENS + boundary + TWO_HYPHENS + NEW_LINE).asciiByes + private val newLineBytes = NEW_LINE.utf8Bytes + + private val NameValue.multipartHeader: ByteArray + get() = boundaryBytes + ("Content-Disposition: form-data; " + + "name=\"$name\"$NEW_LINE$NEW_LINE$value$NEW_LINE").utf8Bytes + + private val UploadFile.multipartHeader: ByteArray + get() = boundaryBytes + ("Content-Disposition: form-data; " + + "name=\"$parameterName\"; " + + "filename=\"$remoteFileName\"$NEW_LINE" + + "Content-Type: $contentType$NEW_LINE$NEW_LINE").utf8Bytes + + private val UploadFile.totalMultipartBytes: Long + get() = multipartHeader.size.toLong() + handler.size(context) + newLineBytes.size.toLong() + + private fun BodyWriter.writeRequestParameters() { + httpParams.requestParameters.forEach { + write(it.multipartHeader) + } + } + + private fun BodyWriter.writeFiles() { + for (file in params.files) { + if (!shouldContinue) break + + write(file.multipartHeader) + writeStream(file.handler.stream(context)) + write(newLineBytes) + } + } + + private val requestParametersLength: Long + get() = httpParams.requestParameters.map { it.multipartHeader.size.toLong() }.sum() + + private val filesLength: Long + get() = params.files.map { it.totalMultipartBytes }.sum() + + override val bodyLength: Long + get() = requestParametersLength + filesLength + trailerBytes.size + + override fun performInitialization() { + httpParams.requestHeaders.apply { + addHeader("Content-Type", "multipart/form-data; boundary=$boundary") + addHeader("Connection", if (params.files.size <= 1) "close" else "Keep-Alive") + } + } + + override fun onWriteRequestBody(bodyWriter: BodyWriter) { + //reset uploaded bytes when the body is ready to be written + //because sometimes this gets invoked when network changes + resetUploadedBytes() + setAllFilesHaveBeenSuccessfullyUploaded(false) + + bodyWriter.apply { + writeRequestParameters() + writeFiles() + write(trailerBytes) + } + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/UploadFileExtensions.kt b/uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/UploadFileExtensions.kt new file mode 100644 index 00000000..793ecd26 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/protocols/multipart/UploadFileExtensions.kt @@ -0,0 +1,22 @@ +package net.gotev.uploadservice.protocols.multipart + +import net.gotev.uploadservice.data.UploadFile +import net.gotev.uploadservice.extensions.setOrRemove + +// properties associated to each file +private const val PROPERTY_PARAM_NAME = "multipartParamName" +private const val PROPERTY_REMOTE_FILE_NAME = "multipartRemoteFileName" +private const val PROPERTY_CONTENT_TYPE = "multipartContentType" + +internal var UploadFile.parameterName: String? + get() = properties[PROPERTY_PARAM_NAME] + set(value) { properties.setOrRemove(PROPERTY_PARAM_NAME, value) } + +internal var UploadFile.remoteFileName: String? + get() = properties[PROPERTY_REMOTE_FILE_NAME] + set(value) { properties.setOrRemove(PROPERTY_REMOTE_FILE_NAME, value) } + + +internal var UploadFile.contentType: String? + get() = properties[PROPERTY_CONTENT_TYPE] + set(value) { properties.setOrRemove(PROPERTY_CONTENT_TYPE, value) } diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/ContentResolverSchemeHandler.kt b/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/ContentResolverSchemeHandler.kt new file mode 100644 index 00000000..b2b6702f --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/ContentResolverSchemeHandler.kt @@ -0,0 +1,62 @@ +package net.gotev.uploadservice.schemehandlers + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import net.gotev.uploadservice.extensions.APPLICATION_OCTET_STREAM +import net.gotev.uploadservice.logger.UploadServiceLogger +import java.io.File +import java.io.IOException + +internal class ContentResolverSchemeHandler : SchemeHandler { + + private lateinit var uri: Uri + + override fun init(path: String) { + uri = Uri.parse(path) + } + + override fun size(context: Context): Long { + return context.contentResolver.query(uri, null, null, null, null)?.use { + if (it.moveToFirst()) { + it.getLong(it.getColumnIndex(OpenableColumns.SIZE)) + } else { + null + } + } ?: run { + UploadServiceLogger.error(javaClass.simpleName) { "no cursor data for $uri, returning size 0" } + //TODO: investigate what happens when size is 0 + 0L + } + } + + override fun stream(context: Context) = context.contentResolver.openInputStream(uri) + ?: throw IOException("can't open input stream for $uri") + + override fun contentType(context: Context): String { + val type = context.contentResolver.getType(uri) + + return if (type.isNullOrBlank()) { + APPLICATION_OCTET_STREAM + } else { + type + } + } + + override fun name(context: Context): String { + return context.contentResolver.query(uri, null, null, null, null)?.use { + if (it.moveToFirst()) { + it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + } else { + null + } + } ?: uri.toString().split(File.separator).last() + } + + override fun delete(context: Context) = try { + context.contentResolver.delete(uri, null, null) > 0 + } catch (exc: Throwable) { + UploadServiceLogger.error(javaClass.simpleName, exc) { "File deletion error" } + false + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/ContentSchemeHandler.java b/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/ContentSchemeHandler.java deleted file mode 100644 index a75df589..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/ContentSchemeHandler.java +++ /dev/null @@ -1,83 +0,0 @@ -package net.gotev.uploadservice.schemehandlers; - -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.provider.OpenableColumns; - -import net.gotev.uploadservice.ContentType; -import net.gotev.uploadservice.Logger; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.InputStream; - -/** - * Handles Android content uris, wraps android.content.Uri - * @author stephentuso - * @author gotev - */ -class ContentSchemeHandler implements SchemeHandler { - - private Uri uri; - - @Override - public void init(String path) { - uri = Uri.parse(path); - } - - @Override - public long getLength(Context context) { - return getUriSize(context); - } - - @Override - public InputStream getInputStream(Context context) throws FileNotFoundException { - return context.getContentResolver().openInputStream(uri); - } - - @Override - public String getContentType(Context context) { - String type = context.getContentResolver().getType(uri); - if (type == null || type.isEmpty()) { - type = ContentType.APPLICATION_OCTET_STREAM; - } - return type; - } - - @Override - public String getName(Context context) { - return getUriName(context); - } - - private long getUriSize(Context context) { - Cursor cursor = context.getContentResolver().query(uri, null, null, null, null); - if (cursor == null) { - Logger.error(getClass().getSimpleName(), "null cursor for " + uri + ", returning size 0"); - return 0; - } - int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE); - cursor.moveToFirst(); - long size = cursor.getLong(sizeIndex); - cursor.close(); - return size; - } - - private String getUriName(Context context) { - Cursor cursor = context.getContentResolver().query(uri, null, null, null, null); - if (cursor == null) { - return getUriNameFallback(); - } - int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - cursor.moveToFirst(); - String name = cursor.getString(nameIndex); - cursor.close(); - return name; - } - - private String getUriNameFallback() { - String[] components = uri.toString().split(File.separator); - return components[components.length - 1]; - } - -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/FileSchemeHandler.java b/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/FileSchemeHandler.java deleted file mode 100644 index 6dccb6fc..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/FileSchemeHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package net.gotev.uploadservice.schemehandlers; - -import android.content.Context; - -import net.gotev.uploadservice.ContentType; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.InputStream; - -/** - * Handler for normal file paths, wraps java.io.File - * @author stephentuso - */ -class FileSchemeHandler implements SchemeHandler { - - private File file; - - @Override - public void init(String path) { - file = new File(path); - } - - @Override - public long getLength(Context context) { - return file.length(); - } - - @Override - public InputStream getInputStream(Context context) throws FileNotFoundException { - return new FileInputStream(file); - } - - @Override - public String getContentType(Context context) { - return ContentType.autoDetect(file.getAbsolutePath()); - } - - @Override - public String getName(Context context) { - return file.getName(); - } -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/FileSchemeHandler.kt b/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/FileSchemeHandler.kt new file mode 100644 index 00000000..e0163d3f --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/FileSchemeHandler.kt @@ -0,0 +1,32 @@ +package net.gotev.uploadservice.schemehandlers + +import android.content.Context +import net.gotev.uploadservice.extensions.autoDetectMimeType +import net.gotev.uploadservice.logger.UploadServiceLogger +import java.io.File +import java.io.FileInputStream +import java.io.IOException + +internal class FileSchemeHandler : SchemeHandler { + private lateinit var file: File + + override fun init(path: String) { + file = File(path) + } + + override fun size(context: Context) = file.length() + + override fun stream(context: Context) = FileInputStream(file) + + override fun contentType(context: Context) = file.absolutePath.autoDetectMimeType() + + override fun name(context: Context) = file.name + ?: throw IOException("Can't get file name for ${file.absolutePath}") + + override fun delete(context: Context) = try { + file.delete() + } catch (exc: Throwable) { + UploadServiceLogger.error(javaClass.simpleName, exc) { "File deletion error" } + false + } +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandler.java b/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandler.java deleted file mode 100644 index 453909c9..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandler.java +++ /dev/null @@ -1,20 +0,0 @@ -package net.gotev.uploadservice.schemehandlers; - -import android.content.Context; - -import java.io.FileNotFoundException; -import java.io.InputStream; - -/** - * Allows for different file representations to be used by abstracting several characteristics - * and operations - * @author stephentuso - * @author gotev - */ -public interface SchemeHandler { - void init(String path); - long getLength(Context context); - InputStream getInputStream(Context context) throws FileNotFoundException; - String getContentType(Context context); - String getName(Context context); -} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandler.kt b/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandler.kt new file mode 100644 index 00000000..5cb004d7 --- /dev/null +++ b/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandler.kt @@ -0,0 +1,36 @@ +package net.gotev.uploadservice.schemehandlers + +import android.content.Context +import java.io.InputStream + +interface SchemeHandler { + /** + * Initialize instance with file path. + */ + fun init(path: String) + + /** + * Gets file size in bytes. + */ + fun size(context: Context): Long + + /** + * Gets file input stream to read it. + */ + fun stream(context: Context): InputStream + + /** + * Gets file content type. + */ + fun contentType(context: Context): String + + /** + * Gets file name. + */ + fun name(context: Context): String + + /** + * Deletes the file. + */ + fun delete(context: Context): Boolean +} diff --git a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandlerFactory.java b/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandlerFactory.java deleted file mode 100644 index fc8f466f..00000000 --- a/uploadservice/src/main/java/net/gotev/uploadservice/schemehandlers/SchemeHandlerFactory.java +++ /dev/null @@ -1,51 +0,0 @@ -package net.gotev.uploadservice.schemehandlers; - -import java.lang.reflect.InvocationTargetException; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * Factory which instantiates the proper scheme handler based on the scheme passed. - * @author gotev - */ -public class SchemeHandlerFactory { - - private static class LazyHolder { - private static final SchemeHandlerFactory INSTANCE = new SchemeHandlerFactory(); - } - - public static SchemeHandlerFactory getInstance() { - return LazyHolder.INSTANCE; - } - - private LinkedHashMap> handlers = new LinkedHashMap<>(); - - private SchemeHandlerFactory() { - handlers.put("/", FileSchemeHandler.class); - handlers.put("content://", ContentSchemeHandler.class); - } - - public SchemeHandler get(String path) - throws NoSuchMethodException, IllegalAccessException, - InvocationTargetException, InstantiationException { - - for (Map.Entry> handler : handlers.entrySet()) { - if (path.startsWith(handler.getKey())) { - SchemeHandler schemeHandler = handler.getValue().newInstance(); - schemeHandler.init(path); - return schemeHandler; - } - } - - throw new UnsupportedOperationException("No handlers for " + path); - } - - public boolean isSupported(String path) { - for (String scheme : handlers.keySet()) { - if (path.startsWith(scheme)) - return true; - } - - return false; - } -}