From 0300c73ea80ef1d6575dbb90314cdcc12d00393d Mon Sep 17 00:00:00 2001 From: Chace Daniels Date: Mon, 31 Mar 2025 12:58:06 -0700 Subject: [PATCH 01/10] feat: initial implementation --- ...actions.yml => continuous_integration.yml} | 17 +- .github/workflows/publish-android.yml | 35 ++ docs/CHANGELOG.md => CHANGELOG.md | 8 +- docs/LICENSE => LICENSE | 4 +- build.gradle | 79 ++-- docs/README.md | 50 --- docs/assets/createRepositoryButton.png | Bin 13627 -> 0 bytes docs/assets/repositoryNameExample.png | Bin 26229 -> 0 bytes docs/assets/useThisTemplateButton.png | Bin 6389 -> 0 bytes fastlane/Appfile | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- pom.xml | 10 + ...st_template.md => pull_request_template.md | 13 +- scripts/publish-android.sh | 38 ++ scripts/publish-module.gradle | 74 ++++ scripts/publish-root.gradle | 37 ++ settings.gradle | 2 +- sonar-project.properties | 2 +- .../ion-android/ExampleInstrumentedTest.kt | 24 -- src/main/AndroidManifest.xml | 22 +- .../ion-android/MainActivity.kt | 11 - .../ionfiletransferlib/IONFLTRController.kt | 338 ++++++++++++++++++ .../helpers/IONFLTRExceptionsMapper.kt | 22 ++ .../helpers/IONFLTRFileHelper.kt | 35 ++ .../helpers/IONFLTRInputsValidator.kt | 25 ++ .../model/IONFLTRException.kt | 38 ++ .../model/IONFLTRTransferOptions.kt | 58 +++ .../model/IONFLTRTransferResult.kt | 48 +++ .../drawable-v24/ic_launcher_foreground.xml | 30 -- .../res/drawable/ic_launcher_background.xml | 170 --------- src/main/res/layout/activity_main.xml | 18 - .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 - .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 - src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 1404 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 2898 -> 0 bytes src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 982 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 1772 -> 0 bytes src/main/res/mipmap-xhdpi/ic_launcher.webp | Bin 1900 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 3918 -> 0 bytes src/main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 2884 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 5914 -> 0 bytes src/main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 3844 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 7778 -> 0 bytes src/main/res/values-night/themes.xml | 16 - src/main/res/values/colors.xml | 10 - src/main/res/values/strings.xml | 3 - src/main/res/values/themes.xml | 16 - .../ionfiletransferlib}/ExampleUnitTest.kt | 2 +- 48 files changed, 839 insertions(+), 430 deletions(-) rename .github/workflows/{github_actions.yml => continuous_integration.yml} (59%) create mode 100644 .github/workflows/publish-android.yml rename docs/CHANGELOG.md => CHANGELOG.md (61%) rename docs/LICENSE => LICENSE (96%) delete mode 100644 docs/README.md delete mode 100644 docs/assets/createRepositoryButton.png delete mode 100644 docs/assets/repositoryNameExample.png delete mode 100644 docs/assets/useThisTemplateButton.png create mode 100644 pom.xml rename docs/pull_request_template.md => pull_request_template.md (69%) create mode 100644 scripts/publish-android.sh create mode 100644 scripts/publish-module.gradle create mode 100644 scripts/publish-root.gradle delete mode 100644 src/androidTest/java/io.ionic.libs.ionfiletransferlib/ion-android/ExampleInstrumentedTest.kt delete mode 100644 src/main/java/io.ionic.libs.ionfiletransferlib/ion-android/MainActivity.kt create mode 100644 src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt create mode 100644 src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRExceptionsMapper.kt create mode 100644 src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt create mode 100644 src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt create mode 100644 src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt create mode 100644 src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferOptions.kt create mode 100644 src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferResult.kt delete mode 100644 src/main/res/drawable-v24/ic_launcher_foreground.xml delete mode 100644 src/main/res/drawable/ic_launcher_background.xml delete mode 100644 src/main/res/layout/activity_main.xml delete mode 100644 src/main/res/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml delete mode 100644 src/main/res/mipmap-hdpi/ic_launcher.webp delete mode 100644 src/main/res/mipmap-hdpi/ic_launcher_round.webp delete mode 100644 src/main/res/mipmap-mdpi/ic_launcher.webp delete mode 100644 src/main/res/mipmap-mdpi/ic_launcher_round.webp delete mode 100644 src/main/res/mipmap-xhdpi/ic_launcher.webp delete mode 100644 src/main/res/mipmap-xhdpi/ic_launcher_round.webp delete mode 100644 src/main/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100644 src/main/res/mipmap-xxhdpi/ic_launcher_round.webp delete mode 100644 src/main/res/mipmap-xxxhdpi/ic_launcher.webp delete mode 100644 src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp delete mode 100644 src/main/res/values-night/themes.xml delete mode 100644 src/main/res/values/colors.xml delete mode 100644 src/main/res/values/strings.xml delete mode 100644 src/main/res/values/themes.xml rename src/test/java/{io.ionic.libs.ionfiletransferlib/ion-android => io/ionic/libs/ionfiletransferlib}/ExampleUnitTest.kt (85%) diff --git a/.github/workflows/github_actions.yml b/.github/workflows/continuous_integration.yml similarity index 59% rename from .github/workflows/github_actions.yml rename to .github/workflows/continuous_integration.yml index b164667..6c1b39e 100644 --- a/.github/workflows/github_actions.yml +++ b/.github/workflows/continuous_integration.yml @@ -1,4 +1,4 @@ -name: GitHub Actions +name: Continuous Integration on: workflow_dispatch: @@ -31,11 +31,12 @@ jobs: - name: Code Coverage run: bundle exec fastlane coverage - - name: Setup sonarqube - uses: warchant/setup-sonar-scanner@v8 + # Commenting Sonarqube steps for now, until we are able to configure Sonarqube in Ionic repos + #- name: Setup sonarqube + # uses: warchant/setup-sonar-scanner@v8 - - name: Send to Sonarcloud - run: bundle exec fastlane sonarqube - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file + #- name: Send to Sonarcloud + # run: bundle exec fastlane sonarqube + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/publish-android.yml b/.github/workflows/publish-android.yml new file mode 100644 index 0000000..c5dd721 --- /dev/null +++ b/.github/workflows/publish-android.yml @@ -0,0 +1,35 @@ +name: Publish Native Android Library + +on: workflow_dispatch + +jobs: + publish-android: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'adopt' + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + - name: Grant execute permission for publishing script + run: chmod +x ./scripts/publish-android.sh + - name: Make local props + run: | + cat << EOF > "local.properties" + ossrhUsername=${{ secrets.ANDROID_OSSRH_USERNAME }} + ossrhPassword=${{ secrets.ANDROID_OSSRH_PASSWORD }} + sonatypeStagingProfileId=${{ secrets.ANDROID_SONATYPE_STAGING_PROFILE_ID }} + signing.keyId=${{ secrets.ANDROID_SIGNING_KEY_ID }} + signing.password=${{ secrets.ANDROID_SIGNING_PASSWORD }} + signing.key=${{ secrets.ANDROID_SIGNING_KEY }} + EOF + echo "local.properties file has been created successfully." + - name: Run publish script + working-directory: ./scripts + run: ./publish-android.sh \ No newline at end of file diff --git a/docs/CHANGELOG.md b/CHANGELOG.md similarity index 61% rename from docs/CHANGELOG.md rename to CHANGELOG.md index ffa5173..5dde7d3 100644 --- a/docs/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -### 2024-02-01 -- Fix: Update `github_actions.yml` file steps versions (https://outsystemsrd.atlassian.net/browse/RMET-2568). +## [Unreleased] -### 2022-04-12 -Create repository. +### 2025-03-31 + +- Implement native library with methods `downloadFile` and `uploadFile`. \ No newline at end of file diff --git a/docs/LICENSE b/LICENSE similarity index 96% rename from docs/LICENSE rename to LICENSE index c2979b0..13c5e40 100644 --- a/docs/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 OutSystems +Copyright (c) 2025 Ionic Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/build.gradle b/build.gradle index 0802323..8f61f72 100644 --- a/build.gradle +++ b/build.gradle @@ -1,27 +1,49 @@ buildscript { - ext.kotlin_version = "1.5.21" - ext.jacocoVersion = '0.8.7' + ext.kotlin_version = "1.9.24" + ext.jacocoVersion = '0.8.9' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' + if (System.getenv("SHOULD_PUBLISH") == "true") { + classpath("io.github.gradle-nexus:publish-plugin:1.1.0") + } + classpath 'com.android.tools.build:gradle:8.7.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jacoco:org.jacoco.core:$jacocoVersion" } } +plugins { + id "org.sonarqube" version "3.5.0.2730" +} + +sonarqube { + // TODO update this information once ionic Sonarqube is available + properties { + property "sonar.projectKey", "OutSystems_IONFileTransferLib-Android" + property "sonar.organization", "outsystemsrd" + property "sonar.host.url", "https://sonarcloud.io" + } +} + +if (System.getenv("SHOULD_PUBLISH") == "true") { + apply plugin: "io.github.gradle-nexus.publish-plugin" + apply from: file("./scripts/publish-root.gradle") +} + apply plugin: "com.android.library" apply plugin: "kotlin-android" apply plugin: "jacoco" android { - compileSdk 32 + namespace "io.ionic.libs.ionfiletransferlib" + compileSdk 35 defaultConfig { - minSdk 26 - targetSdk 32 + minSdk 23 + targetSdk 35 versionCode 1 versionName "1.0" @@ -35,31 +57,42 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } - task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) { + tasks.register('jacocoTestReport', JacocoReport) { + dependsOn['testDebugUnitTest'] reports { - xml.enabled = true - html.enabled = true + xml.getRequired().set(true) + html.getRequired().set(true) } def fileFilter = ['**/BuildConfig.*', '**/Manifest*.*'] - def debugTree = fileTree(dir: "${buildDir}/tmp/kotlin-classes/debugUnitTest", excludes: fileFilter) - def mainSrc = "${project.projectDir}/src/main/java" + def debugTree = fileTree(dir: "${layout.buildDirectory}/tmp/kotlin-classes/debugUnitTest", excludes: fileFilter) + def mainSrc = "${project.projectDir}/src/main/kotlin" sourceDirectories.setFrom(files([mainSrc])) classDirectories.setFrom(files([debugTree])) - executionData.setFrom(fileTree(dir: "$buildDir", includes: [ + executionData.setFrom(fileTree(dir: "${layout.buildDirectory}", includes: [ "jacoco/testDebugUnitTest.exec", "outputs/code-coverage/connected/*coverage.ec" ])) } + + packagingOptions { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } + + publishing { + singleVariant("release") + } } repositories { @@ -69,11 +102,15 @@ repositories { dependencies { - implementation 'androidx.core:core-ktx:1.7.0' - implementation 'androidx.appcompat:appcompat:1.4.1' - implementation 'com.google.android.material:material:1.5.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.3' + implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.activity:activity-ktx:1.10.1' + testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' } + +if (System.getenv("SHOULD_PUBLISH") == "true") { + apply from: file("./scripts/publish-module.gradle") +} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index a440437..0000000 --- a/docs/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# ion-android - -Welcome to **ion-android**. This repository serves as a template to create repositories used to build Android libraries. This file will guide you through that process, that is defined by two sequential steps: - -1. Use the current repository as the template for the new one. -2. Clone the new repository on our machine. -3. Run a script that updates the created repository with the correct information. - -These steps are detailed in the next sections. - -:warning: Every step listed here must be successfully completed before you start working on the new repository. - -## Create a Repository Based on the Template - -First, we need to create a new repository. To accomplish this, please press the **Use this template** button available on the repository's GitHub webpage. - -![Use this template button](./assets/useThisTemplateButton.png) - -Next, we have to define the new repository's name. In order to get the maximum performance of the following step, we advise you to use the **[ProjectName]Lib-Android** format for the name. The names used for the **Health and Fitness** and the **Social Logins** are valid examples of the expected format (_OSHealthFitnessLib-Android_ and _OSSocialLoginsLib-Android_ respectively). - -The following image shows an example of the creation of a repository for the Android' Payments Library. - -![Example for payments repository name](./assets/repositoryNameExample.png) - -After filling up the form as needed, the last step to effectively create the repository is the click on the **Create repository from template** button. - -![Create repository from template button](./assets/createRepositoryButton.png) - -## Clone the New Repository - -After completing the previous step, the next one is something common done in every repository a developer needs to do work on: clone the repository on the local machine. - -## Run the **generator_script.sh** - -To finish the process, we just have one last thing to do. Run the **generator_script.sh** script that automates a couple of changes we need to apply. It is included in the _scripts_ folder. - -To run the script, please execute the following commands on **Terminal**: - -``` -cd scripts -sh generator_script.sh -``` - -Here's the complete list of what the script does: - -- The script provides a bit of information, such as mentioning the name that it will use as the Library name (its based on the one you used while creating the repository on GitHub). -- Requests the user for the application's package identifier. The format required is provided and needs to be complied with in order to advance. -- It informs that the script itself will be deleted, as it is a one time execution only. -- It performs the needed changes, replacing all placeholder's organisational identifier and library name for the ones provided by the user. -- To conclude, the script commits and pushes the changes to the remote repository. diff --git a/docs/assets/createRepositoryButton.png b/docs/assets/createRepositoryButton.png deleted file mode 100644 index 423ed4af659df90736f8470198d25b36a4b1db3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13627 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 S4A=b^yVlh<(5ln0i~2uyd5}8* diff --git a/docs/assets/useThisTemplateButton.png b/docs/assets/useThisTemplateButton.png deleted file mode 100644 index 22dab190826be65ef98b64a67b67e44ea9112064..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6389 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 diff --git a/fastlane/Appfile b/fastlane/Appfile index 1b7dd2f..2535dec 100644 --- a/fastlane/Appfile +++ b/fastlane/Appfile @@ -1,2 +1,2 @@ json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one -package_name("io.ionic.libs.ionfiletransferlib.ion-android") # e.g. com.krausefx.app +package_name("io.ionic.libs.ionfiletransferlib") # e.g. com.krausefx.app diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8a7aeff..1c06fd9 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri Apr 08 08:58:08 WEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..0b9debe --- /dev/null +++ b/pom.xml @@ -0,0 +1,10 @@ + + + + 4.0.0 + io.ionic.libs + ionfiletransfer-android + 0.0.1 + \ No newline at end of file diff --git a/docs/pull_request_template.md b/pull_request_template.md similarity index 69% rename from docs/pull_request_template.md rename to pull_request_template.md index 8127c95..dcfcc9e 100644 --- a/docs/pull_request_template.md +++ b/pull_request_template.md @@ -1,10 +1,6 @@ ## Description -## Context - - - ## Type of changes - [ ] Fix (non-breaking change which fixes an issue) @@ -12,11 +8,6 @@ - [ ] Refactor (cosmetic changes) - [ ] Breaking change (change that would cause existing functionality to not work as expected) -## Platforms affected -- [ ] Android -- [ ] iOS -- [ ] JavaScript - ## Tests @@ -25,8 +16,6 @@ ## Checklist -- [ ] Pull request title follows the format `RNMT-XXXX ` -- [ ] Code follows code style of this project - [ ] CHANGELOG.md file is correctly updated - [ ] Changes require an update to the documentation - - [ ] Documentation has been updated accordingly + - [ ] Documentation has been updated accordingly \ No newline at end of file diff --git a/scripts/publish-android.sh b/scripts/publish-android.sh new file mode 100644 index 0000000..177dcbc --- /dev/null +++ b/scripts/publish-android.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +ANDROID_PATH=../ +LOG_OUTPUT=./tmp/publish-android.txt +THE_VERSION=`sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' ../pom.xml` + +# Get latest io.ionic:portals XML version info +PUBLISHED_URL="https://repo1.maven.org/maven2/io/ionic/libs/ionfiletransfer-android/maven-metadata.xml" +PUBLISHED_DATA=$(curl -s $PUBLISHED_URL) +PUBLISHED_VERSION="$(perl -ne 'print and last if s/.*<latest>(.*)<\/latest>.*/\1/;' <<< $PUBLISHED_DATA)" + +if [[ "$THE_VERSION" == "$PUBLISHED_VERSION" ]]; then + printf %"s\n\n" "Duplicate: a published version exists for $THE_VERSION, skipping..." +else + # Make log dir if doesnt exist + mkdir -p ./tmp + + # Export ENV variable used by Gradle for Versioning + export THE_VERSION + export SHOULD_PUBLISH=true + + printf %"s\n" "Attempting to build and publish version $THE_VERSION" + # Publish a release to the Maven repo + "$ANDROID_PATH"/gradlew clean build publishReleasePublicationToSonatypeRepository closeAndReleaseSonatypeStagingRepository --no-daemon --max-workers 1 -b "$ANDROID_PATH"/build.gradle -Pandroid.useAndroidX=true > $LOG_OUTPUT 2>&1 + # Stage a version + # "$ANDROID_PATH"/gradlew clean build publishReleasePublicationToSonatypeRepository --no-daemon --max-workers 1 -b "$ANDROID_PATH"/build.gradle -Pandroid.useAndroidX=true > $LOG_OUTPUT 2>&1 + + echo $RESULT + + if grep --quiet "BUILD SUCCESSFUL" $LOG_OUTPUT; then + printf %"s\n" "Success: Published to MavenCentral." + else + printf %"s\n" "Error publishing, check $LOG_OUTPUT for more info! Manually review and release from the Sonatype Repository Manager may be necessary https://s01.oss.sonatype.org/" + cat $LOG_OUTPUT + exit 1 + fi + +fi \ No newline at end of file diff --git a/scripts/publish-module.gradle b/scripts/publish-module.gradle new file mode 100644 index 0000000..4137563 --- /dev/null +++ b/scripts/publish-module.gradle @@ -0,0 +1,74 @@ +apply plugin: 'maven-publish' +apply plugin: 'signing' + +def LIB_VERSION = System.getenv('THE_VERSION') + +task androidSourcesJar(type: Jar) { + archiveClassifier.set('sources') + from android.sourceSets.main.java.srcDirs + from android.sourceSets.main.kotlin.srcDirs +} + +artifacts { + archives androidSourcesJar +} + +group = 'io.ionic.libs' +version = LIB_VERSION + +afterEvaluate { + publishing { + publications { + release(MavenPublication) { + // Coordinates + groupId 'io.ionic.libs' + artifactId 'ionfiletransfer-android' + version LIB_VERSION + + // Two artifacts, the `aar` (or `jar`) and the sources + if (project.plugins.findPlugin("com.android.library")) { + from components.release + } else { + artifact("$buildDir/libs/${project.getName()}-${version}.jar") + } + + artifact androidSourcesJar + + // POM Data + pom { + name = 'ionfiletransfer-android' + description = 'File Transfer Android Lib' + url = 'https://github.com/ionic-team/ion-android-filetransfer' + licenses { + license { + name = 'License' + url = 'https://github.com/ionic-team/ion-android-filetransfer/blob/main/LICENSE' + } + } + developers { + developer { + name = 'Ionic' + email = 'hi@ionic.io' + } + } + + // Version Control Info + scm { + connection = 'scm:git:github.com:ionic-team/ion-android-filetransfer.git' + developerConnection = 'scm:git:ssh://github.com:ionic-team/ion-android-filetransfer.git' + url = 'https://github.com/ionic-team/ion-android-filetransfer/tree/main' + } + } + } + } + } +} + +signing { + useInMemoryPgpKeys( + rootProject.ext["signing.keyId"], + rootProject.ext["signing.key"], + rootProject.ext["signing.password"], + ) + sign publishing.publications +} \ No newline at end of file diff --git a/scripts/publish-root.gradle b/scripts/publish-root.gradle new file mode 100644 index 0000000..98f8532 --- /dev/null +++ b/scripts/publish-root.gradle @@ -0,0 +1,37 @@ +// Create variables with empty default values +ext["signing.keyId"] = '' +ext["signing.key"] = '' +ext["signing.password"] = '' +ext["ossrhUsername"] = '' +ext["ossrhPassword"] = '' +ext["sonatypeStagingProfileId"] = '' + +File secretPropsFile = file('./local.properties') +if (secretPropsFile.exists()) { + // Read local.properties file first if it exists + Properties p = new Properties() + new FileInputStream(secretPropsFile).withCloseable { is -> p.load(is) } + p.each { name, value -> ext[name] = value } +} else { + // Use system environment variables + ext["ossrhUsername"] = System.getenv('ANDROID_OSSRH_USERNAME') + ext["ossrhPassword"] = System.getenv('ANDROID_OSSRH_PASSWORD') + ext["sonatypeStagingProfileId"] = System.getenv('ANDROID_SONATYPE_STAGING_PROFILE_ID') + ext["signing.keyId"] = System.getenv('ANDROID_SIGNING_KEY_ID') + ext["signing.key"] = System.getenv('ANDROID_SIGNING_KEY') + ext["signing.password"] = System.getenv('ANDROID_SIGNING_PASSWORD') +} + +// Set up Sonatype repository +nexusPublishing { + repositories { + sonatype { + stagingProfileId = sonatypeStagingProfileId + username = ossrhUsername + password = ossrhPassword + nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/")) + snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) + } + } + repositoryDescription = 'IONFileTransferLib Android Lib v' + System.getenv('THE_VERSION') +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index a86f381..e491ae6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = "ion-android" \ No newline at end of file +rootProject.name = "IONFileTransferLib" \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties index 2e33159..9899d91 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,6 @@ # Organization and project keys are displayed in the right sidebar of the project homepage sonar.organization=outsystemsrd -sonar.projectKey=OutSystems_ion-android-Android +sonar.projectKey=OutSystems_IONFileTransferLib-Android sonar.host.url=https://sonarcloud.io sonar.language=kotlin diff --git a/src/androidTest/java/io.ionic.libs.ionfiletransferlib/ion-android/ExampleInstrumentedTest.kt b/src/androidTest/java/io.ionic.libs.ionfiletransferlib/ion-android/ExampleInstrumentedTest.kt deleted file mode 100644 index 97811ab..0000000 --- a/src/androidTest/java/io.ionic.libs.ionfiletransferlib/ion-android/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.ionic.libs.ionfiletransferlib.ion-android - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("io.ionic.libs.ionfiletransferlib.ion-android", appContext.packageName) - } -} \ No newline at end of file diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index f55651f..310a301 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -1,23 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="io.ionic.libs.ionfiletransferlib.ion-android"> - - <application - android:allowBackup="true" - android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" - android:roundIcon="@mipmap/ic_launcher_round" - android:supportsRtl="true" - android:theme="@style/Theme.ion-android"> - <activity - android:name=".MainActivity" - android:exported="true"> - <intent-filter> - <action android:name="android.intent.action.MAIN" /> - - <category android:name="android.intent.category.LAUNCHER" /> - </intent-filter> - </activity> - </application> - + xmlns:tools="http://schemas.android.com/tools"> + <application tools:node="merge"></application> </manifest> \ No newline at end of file diff --git a/src/main/java/io.ionic.libs.ionfiletransferlib/ion-android/MainActivity.kt b/src/main/java/io.ionic.libs.ionfiletransferlib/ion-android/MainActivity.kt deleted file mode 100644 index c0f2529..0000000 --- a/src/main/java/io.ionic.libs.ionfiletransferlib/ion-android/MainActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.ionic.libs.ionfiletransferlib.ion-android - -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - } -} \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt new file mode 100644 index 0000000..0fff358 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -0,0 +1,338 @@ +package io.ionic.libs.ionfiletransferlib + +import android.content.Context +import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRInputsValidator +import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRFileHelper +import io.ionic.libs.ionfiletransferlib.helpers.runCatchingIONFLTRExceptions +import io.ionic.libs.ionfiletransferlib.model.IONFLTRException +import io.ionic.libs.ionfiletransferlib.model.IONFLTRDownloadOptions +import io.ionic.libs.ionfiletransferlib.model.IONFLTRUploadOptions +import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferResult +import io.ionic.libs.ionfiletransferlib.model.IONFLTRProgressStatus +import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferComplete +import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferHttpOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.util.UUID + +/** + * Entry point in IONFileTransferLib-Android + * + * Contains relevant methods for downloading and uploading files in Android. + */ +class IONFLTRController internal constructor( + private val inputsValidator: IONFLTRInputsValidator, + private val fileHelper: IONFLTRFileHelper +) { + constructor() : this( + inputsValidator = IONFLTRInputsValidator(), + fileHelper = IONFLTRFileHelper() + ) + + companion object { + private const val BUFFER_SIZE = 8192 // 8KB buffer size + private const val BOUNDARY = "----IONFLTRBoundary" + } + + /** + * Downloads a file from a remote URL to a local file path. + * + * @param options The download options including URL and file path + * @return A Flow of [IONFLTRTransferResult] to track progress and completion + */ + fun downloadFile(options: IONFLTRDownloadOptions): Flow<IONFLTRTransferResult> = flow { + runCatchingIONFLTRExceptions { + // Validate inputs + when { + options.url.isBlank() -> throw IONFLTRException.EmptyURL(options.url) + !inputsValidator.isURLValid(options.url) -> throw IONFLTRException.InvalidURL(options.url) + !inputsValidator.isPathValid(options.filePath) -> throw IONFLTRException.InvalidPath(options.filePath) + } + + // Create parent directories if needed + val targetFile = File(options.filePath) + fileHelper.createParentDirectories(targetFile) + + // Setup connection + val connection = setupConnection(options.url, options.httpOptions) + + try { + connection.connect() + + // Check response code + val responseCode = connection.responseCode + if (responseCode < 200 || responseCode > 299) { + val errorBody = connection.errorStream?.bufferedReader()?.readText() + throw IONFLTRException.HttpError( + responseCode.toString(), + errorBody, + connection.headerFields.mapValues { it.value.firstOrNull() ?: "" } + ) + } + + // Get content length if available + val contentLength = connection.contentLength.toLong() + val lengthComputable = contentLength > 0 + + // Download the file + BufferedInputStream(connection.inputStream).use { inputStream -> + FileOutputStream(targetFile).use { fileOut -> + BufferedOutputStream(fileOut).use { outputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead: Long = 0 + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + // Emit progress + emit( + IONFLTRTransferResult.Ongoing( + IONFLTRProgressStatus( + bytes = totalBytesRead, + contentLength = contentLength, + lengthComputable = lengthComputable + ) + ) + ) + } + + // Emit completion + val headers = connection.headerFields.mapValues { it.value.firstOrNull() ?: "" } + emit( + IONFLTRTransferResult.Complete( + IONFLTRTransferComplete( + totalBytes = totalBytesRead, + responseCode = responseCode.toString(), + responseBody = null, + headers = headers + ) + ) + ) + } + } + } + } finally { + connection.disconnect() + } + }.onFailure { throwable -> + throw throwable + } + }.flowOn(Dispatchers.IO) + + /** + * Uploads a file from a local path to a remote URL. + * + * @param options The upload options including URL, file path, and other configuration + * @return A Flow of [IONFLTRTransferResult] to track progress and completion + */ + fun uploadFile(options: IONFLTRUploadOptions): Flow<IONFLTRTransferResult> = flow { + runCatchingIONFLTRExceptions { + // Validate inputs + when { + options.url.isBlank() -> throw IONFLTRException.EmptyURL(options.url) + !inputsValidator.isURLValid(options.url) -> throw IONFLTRException.InvalidURL(options.url) + !inputsValidator.isPathValid(options.filePath) -> throw IONFLTRException.InvalidPath(options.filePath) + } + + // Check if file exists + val file = File(options.filePath) + if (!file.exists()) { + throw IONFLTRException.FileDoesNotExist() + } + + // Setup connection + val connection = setupConnection(options.url, options.httpOptions) + + try { + val fileSize = file.length() + + // Handle multipart or direct upload + if (options.chunkedMode) { + connection.setChunkedStreamingMode(BUFFER_SIZE) + } else { + connection.setFixedLengthStreamingMode(fileSize) + } + + // Set content type if not already set + if (!options.httpOptions.headers.containsKey("Content-Type")) { + val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" + if (options.httpOptions.method.equals("POST", ignoreCase = true)) { + connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY") + } else { + connection.setRequestProperty("Content-Type", mimeType) + } + } + + connection.doOutput = true + connection.connect() + + // Start uploading + connection.outputStream.use { connOutputStream -> + BufferedOutputStream(connOutputStream).use { outputStream -> + if (options.httpOptions.method.equals("POST", ignoreCase = true)) { + // Handle multipart form data + val boundary = "--$BOUNDARY\r\n" + outputStream.write(boundary.toByteArray(StandardCharsets.UTF_8)) + + val fileHeader = "Content-Disposition: form-data; name=\"${options.fileKey}\"; filename=\"${file.name}\"\r\n" + outputStream.write(fileHeader.toByteArray(StandardCharsets.UTF_8)) + + val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" + val contentType = "Content-Type: $mimeType\r\n\r\n" + outputStream.write(contentType.toByteArray(StandardCharsets.UTF_8)) + + // Write file content + FileInputStream(file).use { fileInputStream -> + BufferedInputStream(fileInputStream).use { inputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead: Long = 0 + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + // Emit progress + emit( + IONFLTRTransferResult.Ongoing( + IONFLTRProgressStatus( + bytes = totalBytesRead, + contentLength = fileSize, + lengthComputable = true + ) + ) + ) + } + } + } + + outputStream.write("\r\n--$BOUNDARY--\r\n".toByteArray(StandardCharsets.UTF_8)) + } else { + // Direct upload (not multipart) + FileInputStream(file).use { fileInputStream -> + BufferedInputStream(fileInputStream).use { inputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead: Long = 0 + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + // Emit progress + emit( + IONFLTRTransferResult.Ongoing( + IONFLTRProgressStatus( + bytes = totalBytesRead, + contentLength = fileSize, + lengthComputable = true + ) + ) + ) + } + } + } + } + } + } + + // Check response + val responseCode = connection.responseCode + val responseBody = if (responseCode >= 200 && responseCode < 300) { + connection.inputStream.bufferedReader().readText() + } else { + connection.errorStream?.bufferedReader()?.readText() + } + + if (responseCode < 200 || responseCode > 299) { + throw IONFLTRException.HttpError( + responseCode.toString(), + responseBody, + connection.headerFields.mapValues { it.value.firstOrNull() ?: "" } + ) + } + + // Return success + val headers = connection.headerFields.mapValues { it.value.firstOrNull() ?: "" } + emit( + IONFLTRTransferResult.Complete( + IONFLTRTransferComplete( + totalBytes = file.length(), + responseCode = responseCode.toString(), + responseBody = responseBody, + headers = headers + ) + ) + ) + } finally { + connection.disconnect() + } + }.onFailure { throwable -> + throw throwable + } + }.flowOn(Dispatchers.IO) + + /** + * Sets up the HTTP connection with the provided options. + */ + private fun setupConnection(urlString: String, httpOptions: IONFLTRTransferHttpOptions): HttpURLConnection { + val url = URL(urlString) + val connection = url.openConnection() as HttpURLConnection + + // Set method + connection.requestMethod = httpOptions.method + + // Set timeouts + connection.connectTimeout = httpOptions.connectTimeout + connection.readTimeout = httpOptions.readTimeout + + // Set headers + httpOptions.headers.forEach { (key, value) -> + connection.setRequestProperty(key, value) + } + + // Set parameters + if (httpOptions.params.isNotEmpty() && httpOptions.shouldEncodeUrlParams) { + val paramBuilder = StringBuilder() + httpOptions.params.forEach { (key, values) -> + values.forEach { value -> + if (paramBuilder.isNotEmpty()) paramBuilder.append("&") + paramBuilder.append("$key=$value") + } + } + + if (httpOptions.method.equals("GET", ignoreCase = true)) { + val separator = if (urlString.contains("?")) "&" else "?" + val newUrl = URL("$urlString$separator$paramBuilder") + return newUrl.openConnection() as HttpURLConnection + } else { + connection.doOutput = true + connection.outputStream.use { os -> + os.write(paramBuilder.toString().toByteArray()) + } + } + } + + // Set redirect handling + connection.instanceFollowRedirects = !httpOptions.disableRedirects + + // Set SSL factory if provided + if (httpOptions.sslSocketFactory != null && connection is javax.net.ssl.HttpsURLConnection) { + connection.sslSocketFactory = httpOptions.sslSocketFactory + } + + return connection + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRExceptionsMapper.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRExceptionsMapper.kt new file mode 100644 index 0000000..48279bf --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRExceptionsMapper.kt @@ -0,0 +1,22 @@ +package io.ionic.libs.ionfiletransferlib.helpers + +import io.ionic.libs.ionfiletransferlib.model.IONFLTRException +import java.io.FileNotFoundException +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException + +internal inline fun <T> T.runCatchingIONFLTRExceptions(block: T.() -> Unit): Result<Unit> = + runCatching(block).mapErrorToIONFLTRException() + +internal fun <R> Result<R>.mapErrorToIONFLTRException(): Result<R> = + exceptionOrNull()?.let { throwable -> + val mappedException: IONFLTRException = when (throwable) { + is IONFLTRException -> throwable + is FileNotFoundException -> IONFLTRException.FileDoesNotExist(throwable) + is ConnectException, is SocketTimeoutException -> IONFLTRException.ConnectionError(throwable) + is IOException -> IONFLTRException.TransferError(throwable) + else -> IONFLTRException.UnknownError(throwable) + } + Result.failure(mappedException) + } ?: this \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt new file mode 100644 index 0000000..43572ee --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt @@ -0,0 +1,35 @@ +package io.ionic.libs.ionfiletransferlib.helpers + +import android.webkit.MimeTypeMap +import io.ionic.libs.ionfiletransferlib.model.IONFLTRException +import java.io.File + +class IONFLTRFileHelper { + /** + * Gets a MIME type based on the provided file path + * + * @param filePath The full path to file + * @return The MIME type or null if it was unable to determine + */ + fun getMimeType(filePath: String?): String? = + MimeTypeMap.getFileExtensionFromUrl(filePath)?.let { extension -> + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + } + + /** + * Creates parent directories for a file if they don't exist + * + * @param file The file to create parent directories for + * @throws IONFLTRException.CannotCreateDirectory If the directories cannot be created + */ + @Throws(IONFLTRException.CannotCreateDirectory::class) + fun createParentDirectories(file: File) { + val parent = file.parentFile + if (parent != null && !parent.exists()) { + val created = parent.mkdirs() + if (!created) { + throw IONFLTRException.CannotCreateDirectory(parent.path) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt new file mode 100644 index 0000000..25e7921 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt @@ -0,0 +1,25 @@ +package io.ionic.libs.ionfiletransferlib.helpers + +import java.util.regex.Pattern + +class IONFLTRInputsValidator { + /** + * Boolean method to check if a given file path is valid + * @param path The file path to check + * @return true if path is valid, false otherwise + */ + fun isPathValid(path: String?): Boolean { + return !path.isNullOrBlank() + } + + /** + * Boolean method to check if a given URL is valid + * @param url The URL to check + * @return true if URL is valid, false otherwise + */ + fun isURLValid(url: String): Boolean { + val pattern = + Pattern.compile("http[s]?://(([^/:.[:space:]]+(.[^/:.[:space:]]+)*)|([0-9](.[0-9]{3})))(:[0-9]+)?((/[^?#[:space:]]+)([^#[:space:]]+)?(#.+)?)?") + return pattern.matcher(url).find() + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt new file mode 100644 index 0000000..f8a70b3 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt @@ -0,0 +1,38 @@ +package io.ionic.libs.ionfiletransferlib.model + +/** + * The available exceptions that the File Transfer library can return. + * Some of the exceptions can return a cause in case it was triggered by another source (e.g. Android OS) + */ +sealed class IONFLTRException( + override val message: String, + override val cause: Throwable? = null +) : Throwable(message, cause) { + + class InvalidPath(val path: String?) : + IONFLTRException("The provided path is either null or empty.") + + class EmptyURL(val url: String?) : + IONFLTRException("The provided url is either null or empty.") + + class InvalidURL(val url: String) : + IONFLTRException("The provided url is not valid.") + + class FileDoesNotExist(override val cause: Throwable? = null) : + IONFLTRException("The specified file does not exist", cause) + + class CannotCreateDirectory(val path: String, override val cause: Throwable? = null) : + IONFLTRException("Cannot create directory at $path", cause) + + class HttpError(val responseCode: String, val responseBody: String?, val headers: Map<String, String>?) : + IONFLTRException("HTTP error: $responseCode") + + class ConnectionError(override val cause: Throwable?) : + IONFLTRException("Error establishing connection", cause) + + class TransferError(override val cause: Throwable?) : + IONFLTRException("Error during file transfer", cause) + + class UnknownError(override val cause: Throwable?) : + IONFLTRException("An unknown error occurred while trying to run the operation", cause) +} \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferOptions.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferOptions.kt new file mode 100644 index 0000000..127cd5d --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferOptions.kt @@ -0,0 +1,58 @@ +package io.ionic.libs.ionfiletransferlib.model + +import javax.net.ssl.SSLSocketFactory + +/** + * Options for downloading a file + * + * @property url The URL to download the file from + * @property filePath The local path where the downloaded file will be saved + * @property httpOptions Additional HTTP options for the download request + */ +data class IONFLTRDownloadOptions( + val url: String, + val filePath: String, + val httpOptions: IONFLTRTransferHttpOptions = IONFLTRTransferHttpOptions("GET") +) + +/** + * Options for uploading a file + * + * @property url The URL to upload the file to + * @property filePath The local path of the file to upload + * @property chunkedMode Whether to use chunked transfer encoding + * @property mimeType The MIME type of the file (null for auto-detection) + * @property fileKey The form field name for the file when uploading as multipart/form-data + * @property httpOptions Additional HTTP options for the upload request + */ +data class IONFLTRUploadOptions( + val url: String, + val filePath: String, + val chunkedMode: Boolean = false, + val mimeType: String? = null, + val fileKey: String = "file", + val httpOptions: IONFLTRTransferHttpOptions = IONFLTRTransferHttpOptions("POST") +) + +/** + * HTTP options for file transfer operations + * + * @property method The HTTP method (GET, POST, etc.) + * @property headers HTTP headers to include in the request + * @property params Additional parameters for the request + * @property shouldEncodeUrlParams Whether to URL-encode the parameters + * @property readTimeout Read timeout in milliseconds + * @property connectTimeout Connection timeout in milliseconds + * @property disableRedirects Whether to disable automatic redirects + * @property sslSocketFactory Custom SSL socket factory (optional) + */ +data class IONFLTRTransferHttpOptions( + val method: String, + val headers: Map<String, String> = emptyMap(), + val params: Map<String, Array<String>> = emptyMap(), + val shouldEncodeUrlParams: Boolean = true, + val readTimeout: Int = 60_000, + val connectTimeout: Int = 60_000, + val disableRedirects: Boolean = false, + val sslSocketFactory: SSLSocketFactory? = null +) \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferResult.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferResult.kt new file mode 100644 index 0000000..6137189 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferResult.kt @@ -0,0 +1,48 @@ +package io.ionic.libs.ionfiletransferlib.model + +/** + * Represents the result of a file transfer operation (upload or download) + */ +sealed class IONFLTRTransferResult { + /** + * Represents an ongoing transfer operation with progress information + * + * @property status Current progress status information + */ + data class Ongoing(val status: IONFLTRProgressStatus) : IONFLTRTransferResult() + + /** + * Represents a completed transfer operation + * + * @property data Complete transfer information + */ + data class Complete(val data: IONFLTRTransferComplete) : IONFLTRTransferResult() +} + +/** + * Progress status information for an ongoing transfer + * + * @property bytes Number of bytes transferred so far + * @property contentLength Total size of the content in bytes, if known + * @property lengthComputable Whether the total content length is known + */ +data class IONFLTRProgressStatus( + val bytes: Long, + val contentLength: Long, + val lengthComputable: Boolean +) + +/** + * Information about a completed transfer + * + * @property totalBytes Total number of bytes transferred + * @property responseCode HTTP response code + * @property responseBody HTTP response body (if available) + * @property headers HTTP response headers (if available) + */ +data class IONFLTRTransferComplete( + val totalBytes: Long, + val responseCode: String, + val responseBody: String?, + val headers: Map<String, String>? +) \ No newline at end of file diff --git a/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d1..0000000 --- a/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:aapt="http://schemas.android.com/aapt" - android:width="108dp" - android:height="108dp" - android:viewportWidth="108" - android:viewportHeight="108"> - <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> - <aapt:attr name="android:fillColor"> - <gradient - android:endX="85.84757" - android:endY="92.4963" - android:startX="42.9492" - android:startY="49.59793" - android:type="linear"> - <item - android:color="#44000000" - android:offset="0.0" /> - <item - android:color="#00000000" - android:offset="1.0" /> - </gradient> - </aapt:attr> - </path> - <path - android:fillColor="#FFFFFF" - android:fillType="nonZero" - android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" - android:strokeWidth="1" - android:strokeColor="#00000000" /> -</vector> \ No newline at end of file diff --git a/src/main/res/drawable/ic_launcher_background.xml b/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9..0000000 --- a/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="108dp" - android:height="108dp" - android:viewportWidth="108" - android:viewportHeight="108"> - <path - android:fillColor="#3DDC84" - android:pathData="M0,0h108v108h-108z" /> - <path - android:fillColor="#00000000" - android:pathData="M9,0L9,108" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M19,0L19,108" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M29,0L29,108" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M39,0L39,108" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M49,0L49,108" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M59,0L59,108" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M69,0L69,108" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M79,0L79,108" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M89,0L89,108" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M99,0L99,108" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M0,9L108,9" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M0,19L108,19" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M0,29L108,29" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M0,39L108,39" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M0,49L108,49" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M0,59L108,59" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M0,69L108,69" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M0,79L108,79" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M0,89L108,89" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M0,99L108,99" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M19,29L89,29" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M19,39L89,39" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M19,49L89,49" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M19,59L89,59" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M19,69L89,69" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M19,79L89,79" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M29,19L29,89" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M39,19L39,89" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M49,19L49,89" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M59,19L59,89" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M69,19L69,89" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> - <path - android:fillColor="#00000000" - android:pathData="M79,19L79,89" - android:strokeWidth="0.8" - android:strokeColor="#33FFFFFF" /> -</vector> diff --git a/src/main/res/layout/activity_main.xml b/src/main/res/layout/activity_main.xml deleted file mode 100644 index 4fc2444..0000000 --- a/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,18 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context=".MainActivity"> - - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Hello World!" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintLeft_toLeftOf="parent" - app:layout_constraintRight_toRightOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - -</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index eca70cf..0000000 --- a/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@drawable/ic_launcher_background" /> - <foreground android:drawable="@drawable/ic_launcher_foreground" /> -</adaptive-icon> \ No newline at end of file diff --git a/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index eca70cf..0000000 --- a/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@drawable/ic_launcher_background" /> - <foreground android:drawable="@drawable/ic_launcher_foreground" /> -</adaptive-icon> \ No newline at end of file diff --git a/src/main/res/mipmap-hdpi/ic_launcher.webp b/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78ecd372343283f4157dcfd918ec5165bb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cm<R)d19?=)E<{+g@mp0C)CAX%7ksNnpX=jPlJEkqD9%o*fC(U7iySOYHHS zCLH@bXPyI|^Z)Mc^PG7Oc+NfBJO`d7p5;U`M53Wbd!w|M(MUoNRc2m{@^!wFKzL?& zx_RAc-^9Azxo%DmXW^9GV0~;n_G9&dym)|QrMEx!y_F=oDunn;1y)cvAc6z{0MHiz zodGIH07w8nF%*bGq9Gv`YHnm80|d1T$uW=u4vGV%tX#>e5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!<r*4*+?g*sysFgiN}}d!-T(>jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#<RA5Hg_mG&BZS>9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG<W<SKpuS2eaDGkL-J- z!+&UV_N0e<DV*|igwqdC*l{lj0T&&`uq=ycU@f9x0i8jzLa(y*X(YV?PCtUw;ks}p z4zFn7N(-OG^(9ot1ZhYISOWe9?+l%f6v41n+j<OM$_uJgNP?ZJy}hEMMH=;WiG4I` zs(bIWwSD<LJnAN(E(Xjr4k(^UYF37F_3f{;E%%FEa&I3I0GbdH@{pD5$m+1PN5CW) zyZ&*9o#8wstdx@^rci;0B4BP2+H4Y<KbJI5L(bXG(k@`Kp=d>8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqro<oln8GL@_6LPC)kg1!M)Y!|NCn7b*0sG zEN=&c2xMM<>a($ne7EUK;#3V<N-jQ7j($tREa0F&-HzYCQtR#fTCZMRN*ZSm;T7a+ zuxa$}zDL8R9wGYkHb$+gJoiM@z+u{u7a_VUBwtd)bPzHxH}C`W=^2PsBr`s7taBMG zm#Ss=-o`)W8%%x%>YkXaew%Kh^3OrMht<?zOY6P}#rBhn_hrWY$_P`{#CBR9w?+E6 zt7r#NN-tjxFY{9q75P<|L<ZJBHwn4FhG(&i>jYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)<UE%?IC0Du41FrE~F_qc8nOq>P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9<vs%8>j@06@(!<!eaZcF<_le1MVaYMg=gRy*f2#IaBH-mJIpy+L z=Gsbhd6=3>{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG diff --git a/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2898 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 diff --git a/src/main/res/mipmap-mdpi/ic_launcher.webp b/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64e58ba64d180ce43ee13bf9a17835fbca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuH<!ckn_w-(t15itRHmqN0O$B3XH(E|jyV^QXq8=yM`Q**vy zpEpgQd+no=J<Tlv&+_>B%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}<p z;bEy|mw1;}P&gp|0ssKe4*;D3Dlh;r06r}ehC-pC4mGy`3;|+W04+#B4r_x~@mHHy z)H}bD|I2-n_L$pW;*I)~?=#N<)`92&<$3IR`#<SH6@I&FRQa6xBmQ5wPwJ2PAI(ne z$L2Yb@JHxb`+bLk*AjR$^`b?pr|?!6=+AboIQ2D-p)UI7x(J0|5(5~ur$_+)`>C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ<hjKiZs6mOSFB&+cIl`GV$93-<ciUjF#*1^<p~gh ziQ_{)r0dA7$It&Fe=obxu8n!+elxmgqxPbUL!FxW0;AOfqz@8JOz9Qbm)m-9!^7D) z480@BoIIb<oT``+rVla8L)8fXO&6}3P9n4v$`6WG<DUNWuKb9J9rUsAn7d-_YWT^U z{NXl@OAPIJ!>3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^Q<WhKYr9rXr6*~Tmpuq6NjnD6;;NNBGIg-1ZvfACQ4{ocrwM0)?`oL2ts zCXY5KT@`(ir63J0?%+_(-dDgf<6R$u{lCdy6Zi5d+Bf;1OXyD;xe3#Gug*&T|0o41 zD8;$|JvUv&@vsLIH&C5+S{!k&{~Z54^y@9r>X7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!T<mO diff --git a/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611da081676d42f6c3f78a2c91e7bcedddedb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1772 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 O>FJ$wK}0tWNB{uH;AM~i diff --git a/src/main/res/mipmap-xhdpi/ic_launcher.webp b/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070fe34c611c42c0d3ad3013a0dce358be0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1900 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 diff --git a/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3918 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 diff --git a/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77f9f036a47549d47db79c16788749dca10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2884 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 diff --git a/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f5083623b375139afb391af71cc533a7dd37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5914 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�(p-{x$&=IACIfoAR<Nvvu>!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@<pC<8PLRXJuvO4y>xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5<Pf8lV3fF#Ppu>Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@<t56P}GC(ea zcuDo~%vU?vTqAIp#flPNMI2`5;t8&98^HQwmsAsoPNK_fx^Bggb1WVe*N(RqH8#4x zCQN_rN*!W39u4A)O3%B(eX4-oO~1PlFj1j%A~@Wj>9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`<bqO>pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0x<PZ)*(STjdw~o@Rw-Or2Ax?U!if0^R7qyS!JDYN`R9pBUrvi5{Lgqu za0I&Zd*A54UPC}|lJz-2f!1VYM+A-ElFU{V`W)8LtCk5Mx|)Il)$QH*gA63%JZFt} zGq@eFEWXJ~R>Ps?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeN<ek%udeE*tim(T?PyxRjZ^lIknLzS6Fbvpe_110000002i;2E&u=k diff --git a/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d6427e6fa1074b79ccd52ef67ac15c5637e85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3844 zcmV+f5Bu;^Nk&He4gdgGMM6+kP&il$0000G0002L006%L06|PpNQVLd01cqCZJQ!l zdEc+9kGs3OD-bz^9uc|AA8?1rA#x4f-93WH-QAt;uJ6U6Yp<>o!9>IaV6aUZ*?W>} zs4%E?srLW`CJh0GCIK@hTkrW7A15Iu<z{NI>%N<!nR<>&?Q^$0+!{Tv&|t^Y@u%!L zglTg&?Q5q#ijZ;&HBQ?FNPp;k3J5!&{^+SGq<pNwB|u%pA^-t3!%mrgTx*^S#Zw_4 ziE?C>?AX~SiOM9jJMRpyP?RCr@z38AQyy&WRMaC;n4una$~nJKSp?q|s8F00c9?Q! zY_ovvjTFm+DeQM^LXJ#v0}6HRt3R1%5PT*}W!k8BEM;Jrj8dIceFo2fhzTqaB3KKk zGlCLI)gU25(#u6ch6GeB1k@eHq7l{EHXv0n6xE#ws#ri}08kkCf8hUt{|Ejb`2YW* zvg}0nSSX1m=76s?sZhRY$K=3dpJ+y*eDULGnL2}4>4nvW^7_<~wIM_5fjvwt4h1|g z)g0Z6ZFq9j<~9~b8((~TN{Z?ZQfw|is&Xp~AC61sj;xItKyCHdI|tCMC_LbXF>~vR z=w6V3^H=W4CbAgR4#xw}ETTwu2guW~=Crl@SMXv85jQ=%y!s^?m4PI0My7MWICO;- z175jm%&PcPWh8QdOU(#8bp4!N7ET-+)N}N2zk2)8ch|4Q&lPFNQgT-thu053`r*h3 z_8dI@G;`zn;lH$zX3RzIk`E8~`J=BBdR}qD%n@vVG1834)!pS1Y?zVkJGtsa(sB~y zNfMYKsOJb%5J(0ivK8d+l2D2y&5X!cg3BG!AJ}910|_${nF}sC1QF^nLIhzXk-Y#x z0)&1iK!O;Og0Ky!;`<M509H^qAWjSb01!F=odGJq0Kfn~F&2tLA|W9aSuDUH0|chv z><Y$E!fyEcTj8Iz{FnTW`OLS!v-~mg2YDxGX7|*O>b~v%b$`S4E&fB)1NB4v@8wr( z&+NX4e^&o)ecb=)dd~C!{(1e6t?&9j{l8%U*k4)?`(L<U4S(5x*nimdWB<W>3;Qjw z#w7FS+U(94MaJKS!J9O8^$)36_J8;thW#2$y9i{bB{?M{QS_inZIJ!jwqAbfXYVd$ zQ5fC$6Nc9hFi8m^;oI-%C#BS|c8vy+@{jx6hFcf^_;2VRgkoN(0h!_VSGmgNPRsxI z8$rTo0LaYq-H5i&<W~DEeA0J5Ejcr=NoEuyBL(OVLAcB}X_8cB_uJ!s7cp0dRqVBe zsUE`ZT_vw`#PhJ3GZL&MgceBX?CZld6L?=CALkxMG)wd*K}0qB5G);flh~+*<#sdk zHVpiyxmjf=)gVwD(Othch%-?7mJ-JFN@GgN5H*j<vXzv;;EgH@{<`xp`bGWxdTuF9 zVfPw2|Mb0|{SR@<coJRz*Ldo7C8_WV2F~CA|MCG$;<8+wMv2K&bEOiLe$h{|mYTns zmq|q&A*1?q+ixKWAASoVH!ZEVh`i*LG6iiJkbnUG@aX^m02AN;)E{3iDq9o+QQz{^ zE>gtj81=&xU?H-Y2==G@uQV7E`@+2E9XQW@{&j`?EOktk|Ho{HU>ZqDzvgjwBmdex z&uZNd2C1h{{}2k6Ys9$*nFP3;K%u!MhW`uZy7Sn`1M1zs@Es&;z*Z>Gsh@-3Fe6pE zQD2@cqF((NrRevgvLsvM_8;;iNyJ5nyPyy?e!kvKjGj`6diRFBEe49Oa7wwkJFV7Z z$YT&DWloYu-H?3<0BKn9L&JYDT-SK~*6c5pi18P26$JESKRYj{T7Zk6KiRJcbv<El z9J+CwC&)JZ>OO*{P56Q6s8msbeI3>|j>K9}Q9UBeq*inXKemCm`-<5|-$ZyN4u$(3 z&HcvqehFD%5Yrmykg-^d`=BSa8(i=>ZoC77^mWY{evp(km@aHqhUECBz76YiR+VYK zY_avFC~V3$=`6C4JhfHAQ@DZtUOwH`L;oYX6zK0-uI^?hS$ALfq}A7evR;ohJHij} zHSZdW?<e{2-WHa_?U=it9}&7kqMpjq1mSDIef>EKv9U1s4oD*<(0oQ*;MaQ6@cvGL zuHCPgm_NhVsgp^sfr*ia^Db}swo1?O(_Q2)y+S$CBm+g=9wCOUPbz(x)_GbaKa@A7 zuI&!ynLiZRT#V%_y_-D`0Z5lT*auoe{(U5NylTzFSJW()W-#F6*&A`LNO1bV#Y;QJ z<awv-I3PIiWGHhTy$}zF2Y)1sqQ<os%Ovgx8Kp1IIYp8yKG??*Ss|3D&_gso#&bcG zAOx0jE$6M4Ta>SbLBnp|B^dtK|KIWC|No>JjWBWE@n7O)x{&^E(WMeMvp57#qA8m* zeTow*U@_86B#Fm*rxyYu5<KF&LxRTn#b#-=V+wrM90aLp;^z%k__(dWQ)AGshK?G2 zG_7TEuE}qQ1p|pu9cXTCVY1=}eY&5#0^oi_6WJzXND#Il2{P2*Glja>PRWaWHx8y> z*qmHEp(AMDl0v)ij(AY8fnH=~ZwwjVAbu*m5;xPf<qJX_d*%rb0I5H47@IVnb7S0o zz2PY$`9p9<?MI}^fsvg}<5vnkl@iWSyJE|RKd<CD3n(U@+9y@s<I(?>idh@ov6d8g zfJsi&!QyK53Es%sC39ts;54V68koALD4b|%tNHW0bIkZAJKa=W&FomJSEDT>W1xIX z1x%Z>AvNIsSPLcn3RTcHXb@KB?cuM)=x6fcIx>&(GxqZ8w3p#jJ(GVgc*`c0HG}dv zIop&Qim!K1NFwic%07KcjWgHBPUkq7f~lj;TPqVGTiT#cUeim>;nY`>h@a*S{qQex zQ`z62WK|Mj)Y{tfF{;T4<U2X{`x?}US~MrE1C|_1&};NNy=Xd=->P;c8$Q|KU?Joh zIk<oAxu7<8J8_((U}1AcLhLHd#;6?=ujo!ltdCtw#~hyreNq0TmvSJC6kvD&I97fd znpE<a3v3nA{>A^z%X7z|r>4aTh@|StTi!-r1D!g=zb#3d#{{&K3CqE$Iz-UH<%37c zRfkO`&uM%#AD3PHv`g5t0e^O%nVL0d{Xlx^EjEC3#skF@`zl-7PF^0oxW)1!C!JxR zWvuAHH?)61FKA1QeT*_sY7;_Id#!GmV4n`<w=^Ck{Y6qCCnK=crd>MO{~sv}VLSK` zXRw=Y=Clz*00B(5y^K;gCZMAzjT5+c3IC=)l(9VIDdatpxj3y89WwI|bH&$!ZEvp` zPR!T@#!(|KfI-w?!&+7$N3F6>tD{YO4Qg$d_`nNEdfVCha9vaPn0jI0`)`@*72hq! zp<q2y@kKfVrSfb}8vmw$SopDtXNL>U5ND^P*RoEkbD5o#az(-g=Y)L>HH>O<qeopz zUN9W@%YIO|oPuhw|3vc#<KCMY=x6o1bq4B(<v$M-V#@J4x8rW0u2vp3d;J)Q>c%}$ zT3Rs_ih0;4+Lv4Y;@Iv(;fUbQ=i-G(#>vghec~*j(I#r|5mqFiJBpzi&hzEcD{u$< zRsm0BVYn=pT;0>R(itW|*D&;O%bOc7et9ACaH#J>z3A<mlHC6`?wC3cPj=a+0L!KJ z29dbN4hGxn(vG|*nDvH_Gu%A>1A~6fdP>pmbM%xzm4>|;c_?B+%sl;Qs2{t!60$^u zH1t@9^6>;?!FuusnISi$f5CL&;z?EqJN$FBuWDA#D5`cy_UvCFIVvf{c?4N0teh;d zET$7aVbj08KTQS!x?Nd1Is8q8qFzs}a=!@nJ;7FSfCY^T@D-gpw`w<6e#X3+;O}1h z$%I!M)0bg|EKUA04Qjn@+x{Rj8vt6Wn!R|3A92z}^$KfF5(#CWr4y#~re1CN4i4w0 z#GsypBR<e;sgowNDv$gUgnDd>{xA3Er7sgAi(|}1-W?s~n$7?K|9WL8kpVfw-;#b9 z+mn;=e<xV2z&$aXbbB^9!5xN=DIomsyx0q9u03Cg{>p!162U5R>_t}fOt~tE?s#m( zO-S$7>Ay6*hHdZ)7_oU915WYYCIX;hFI-U2EWYX!pllONr@Q--2o~`!<G<U!Wm!i6 zcOe$Xm6I0E(yJ$r-ME}i2`)znbXd1p52N%TOsuKK&9}G3_UznkOzVC5f5D;nCf)Z+ zj#uVX)+?#DL<kaNRk~0wN>isi6vTPLJ4@(|o=<RrQ3C!v$5WYUUCW7tGYI}Ga=@S6 z#oVDLA^DrRJ><U3UOnQXJ$?>%NHYjo0_S&q*UQIROw@*N-By@P<Aa>aQ&;YxFZ0aR zX&}LeOEz);#m~Hwm^VAY8DK}b$F4bo{jMN?d!lxKPhNklzr^Cd`0f4oJr^z=I|l`* zm8AHm*fPV`0=lF3Pnnp}&J0N1X@}-D94YvmUabFrLGSnTz7Mu^21F#O5tN#CuY9Vh zUZBH=ez%h*wkf0hBtXJh1SN3d+IF{gzT7lp)j}n?03lt;XSQRAh7qd&v;RwTYDuQ# zbI2*r<>?x-G0@hM{;%{VBD7nLKt~D`T~-HAt5;h%i0_=Ifs=yHma5dhJ+QMG?Ux(a z|E?1CMy1!~oA`FP!k~iG=t&5#>bVdz=peT8HMB6Y)#7PpETtNryT^+Rv3vpJaF^zP z{H}0-LyV9Fu21ID%wO9f1IKlFr1p4c{o-?03vyB-tr5duk^&L$;m_|f$vs`^Sl{j2 z95}oY{LlY+=ZS%J+tZoXCd0*sSU7w^gjovXn+g7uyra5{cU49@yHf#Z^Jl-$9cIfo z+AJuxH$VLb=#+uBbVmUjn<pB8s2*J`I5CyYgqeYUoxo|zGhX;tyDo1a#27aF@cZj$ zgh*)qH$l}mt);}{RwPfX7p=vEVccsmWhYwNX6Is75w5D@Tj;I~X$WiCH;n&HX9}>x zxb1pZ@-O9=AIk4@S)m6fJ2?{HrNYwwnL3a45muuNjr;6$O`bGEM0T4A2_S$t=86*- zcO+0mywg*j<MP8}9*qyfJ7GqMnvW0dCHIXpIOyq&xVwY1Hj?9}nQ4)L0000000000 G0001O&w8c+ diff --git a/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae37cbc3587421d6889eadd1d91fbf1994d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7778 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 diff --git a/src/main/res/values-night/themes.xml b/src/main/res/values-night/themes.xml deleted file mode 100644 index f088d7f..0000000 --- a/src/main/res/values-night/themes.xml +++ /dev/null @@ -1,16 +0,0 @@ -<resources xmlns:tools="http://schemas.android.com/tools"> - <!-- Base application theme. --> - <style name="Theme.ion-android" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> - <!-- Primary brand color. --> - <item name="colorPrimary">@color/purple_200</item> - <item name="colorPrimaryVariant">@color/purple_700</item> - <item name="colorOnPrimary">@color/black</item> - <!-- Secondary brand color. --> - <item name="colorSecondary">@color/teal_200</item> - <item name="colorSecondaryVariant">@color/teal_200</item> - <item name="colorOnSecondary">@color/black</item> - <!-- Status bar color. --> - <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item> - <!-- Customize your theme here. --> - </style> -</resources> \ No newline at end of file diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml deleted file mode 100644 index f8c6127..0000000 --- a/src/main/res/values/colors.xml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <color name="purple_200">#FFBB86FC</color> - <color name="purple_500">#FF6200EE</color> - <color name="purple_700">#FF3700B3</color> - <color name="teal_200">#FF03DAC5</color> - <color name="teal_700">#FF018786</color> - <color name="black">#FF000000</color> - <color name="white">#FFFFFFFF</color> -</resources> \ No newline at end of file diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml deleted file mode 100644 index 47fdabf..0000000 --- a/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ -<resources> - <string name="app_name">ion-android</string> -</resources> \ No newline at end of file diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml deleted file mode 100644 index 426e11b..0000000 --- a/src/main/res/values/themes.xml +++ /dev/null @@ -1,16 +0,0 @@ -<resources xmlns:tools="http://schemas.android.com/tools"> - <!-- Base application theme. --> - <style name="Theme.ion-android" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> - <!-- Primary brand color. --> - <item name="colorPrimary">@color/purple_500</item> - <item name="colorPrimaryVariant">@color/purple_700</item> - <item name="colorOnPrimary">@color/white</item> - <!-- Secondary brand color. --> - <item name="colorSecondary">@color/teal_200</item> - <item name="colorSecondaryVariant">@color/teal_700</item> - <item name="colorOnSecondary">@color/black</item> - <!-- Status bar color. --> - <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item> - <!-- Customize your theme here. --> - </style> -</resources> \ No newline at end of file diff --git a/src/test/java/io.ionic.libs.ionfiletransferlib/ion-android/ExampleUnitTest.kt b/src/test/java/io/ionic/libs/ionfiletransferlib/ExampleUnitTest.kt similarity index 85% rename from src/test/java/io.ionic.libs.ionfiletransferlib/ion-android/ExampleUnitTest.kt rename to src/test/java/io/ionic/libs/ionfiletransferlib/ExampleUnitTest.kt index e113d5b..d264ad0 100644 --- a/src/test/java/io.ionic.libs.ionfiletransferlib/ion-android/ExampleUnitTest.kt +++ b/src/test/java/io/ionic/libs/ionfiletransferlib/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package io.ionic.libs.ionfiletransferlib.ion-android +package io.ionic.libs.ionfiletransferlib import org.junit.Test From c171dd614f2c3f2949b75451541676e30d338250 Mon Sep 17 00:00:00 2001 From: Chace Daniels <chace.daniels@outsystems.com> Date: Wed, 2 Apr 2025 14:57:55 -0700 Subject: [PATCH 02/10] fix: use chunked mode for multipart uploads to prevent content length mismatch --- .../io/ionic/libs/ionfiletransferlib/IONFLTRController.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt index 0fff358..11d51a8 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -162,7 +162,12 @@ class IONFLTRController internal constructor( if (options.chunkedMode) { connection.setChunkedStreamingMode(BUFFER_SIZE) } else { - connection.setFixedLengthStreamingMode(fileSize) + if (!options.httpOptions.method.equals("POST", ignoreCase = true)) { + connection.setFixedLengthStreamingMode(fileSize) + } else { + // Use chunked transfer encoding for multipart uploads + connection.setChunkedStreamingMode(BUFFER_SIZE) + } } // Set content type if not already set From c714ce4cd535fbdd25c2d9c1f1d276a86de2b100 Mon Sep 17 00:00:00 2001 From: Chace Daniels <chace.daniels@outsystems.com> Date: Thu, 3 Apr 2025 09:38:14 -0700 Subject: [PATCH 03/10] refactor:accurate byte tracking, non-blocking IO, and proper multipart handling --- .../ionfiletransferlib/IONFLTRController.kt | 299 ++++++++++++------ .../helpers/IONFLTRFileHelper.kt | 2 +- .../helpers/IONFLTRInputsValidator.kt | 2 +- .../model/IONFLTRException.kt | 2 +- .../model/IONFLTRTransferOptions.kt | 2 + .../model/IONFLTRTransferResult.kt | 2 +- 6 files changed, 202 insertions(+), 107 deletions(-) diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt index 11d51a8..c7fafe1 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -1,16 +1,15 @@ package io.ionic.libs.ionfiletransferlib -import android.content.Context -import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRInputsValidator import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRFileHelper +import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRInputsValidator import io.ionic.libs.ionfiletransferlib.helpers.runCatchingIONFLTRExceptions -import io.ionic.libs.ionfiletransferlib.model.IONFLTRException import io.ionic.libs.ionfiletransferlib.model.IONFLTRDownloadOptions -import io.ionic.libs.ionfiletransferlib.model.IONFLTRUploadOptions -import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferResult +import io.ionic.libs.ionfiletransferlib.model.IONFLTRException import io.ionic.libs.ionfiletransferlib.model.IONFLTRProgressStatus import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferComplete import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferHttpOptions +import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferResult +import io.ionic.libs.ionfiletransferlib.model.IONFLTRUploadOptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -23,7 +22,7 @@ import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL import java.nio.charset.StandardCharsets -import java.util.UUID +import kotlinx.coroutines.withContext /** * Entry point in IONFileTransferLib-Android @@ -44,6 +43,48 @@ class IONFLTRController internal constructor( private const val BOUNDARY = "----IONFLTRBoundary" } + /** + * Writes a file to an output stream and emits progress updates. + * + * @param file The file to write + * @param outputStream The output stream to write to + * @param totalBytesWritten The current total bytes written + * @param totalSize The total size to report in progress updates + * @param emit Function to emit progress updates + */ + private suspend fun writeFileWithProgress( + file: File, + outputStream: BufferedOutputStream, + totalBytesWritten: Long, + totalSize: Long, + emit: suspend (IONFLTRTransferResult) -> Unit + ): Long = withContext(Dispatchers.IO) { + var currentTotalBytes = totalBytesWritten + FileInputStream(file).use { fileInputStream -> + BufferedInputStream(fileInputStream).use { inputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + currentTotalBytes += bytesRead + + // Emit progress + emit( + IONFLTRTransferResult.Ongoing( + IONFLTRProgressStatus( + bytes = currentTotalBytes, + contentLength = totalSize, + lengthComputable = true + ) + ) + ) + } + } + } + currentTotalBytes + } + /** * Downloads a file from a remote URL to a local file path. * @@ -53,11 +94,7 @@ class IONFLTRController internal constructor( fun downloadFile(options: IONFLTRDownloadOptions): Flow<IONFLTRTransferResult> = flow { runCatchingIONFLTRExceptions { // Validate inputs - when { - options.url.isBlank() -> throw IONFLTRException.EmptyURL(options.url) - !inputsValidator.isURLValid(options.url) -> throw IONFLTRException.InvalidURL(options.url) - !inputsValidator.isPathValid(options.filePath) -> throw IONFLTRException.InvalidPath(options.filePath) - } + validateTransferInputs(options.url, options.filePath) // Create parent directories if needed val targetFile = File(options.filePath) @@ -76,7 +113,7 @@ class IONFLTRController internal constructor( throw IONFLTRException.HttpError( responseCode.toString(), errorBody, - connection.headerFields.mapValues { it.value.firstOrNull() ?: "" } + connection.headerFields ) } @@ -109,14 +146,13 @@ class IONFLTRController internal constructor( } // Emit completion - val headers = connection.headerFields.mapValues { it.value.firstOrNull() ?: "" } emit( IONFLTRTransferResult.Complete( IONFLTRTransferComplete( totalBytes = totalBytesRead, responseCode = responseCode.toString(), responseBody = null, - headers = headers + headers = connection.headerFields ) ) ) @@ -126,9 +162,7 @@ class IONFLTRController internal constructor( } finally { connection.disconnect() } - }.onFailure { throwable -> - throw throwable - } + }.getOrThrow() }.flowOn(Dispatchers.IO) /** @@ -140,11 +174,7 @@ class IONFLTRController internal constructor( fun uploadFile(options: IONFLTRUploadOptions): Flow<IONFLTRTransferResult> = flow { runCatchingIONFLTRExceptions { // Validate inputs - when { - options.url.isBlank() -> throw IONFLTRException.EmptyURL(options.url) - !inputsValidator.isURLValid(options.url) -> throw IONFLTRException.InvalidURL(options.url) - !inputsValidator.isPathValid(options.filePath) -> throw IONFLTRException.InvalidPath(options.filePath) - } + validateTransferInputs(options.url, options.filePath) // Check if file exists val file = File(options.filePath) @@ -157,23 +187,46 @@ class IONFLTRController internal constructor( try { val fileSize = file.length() + val useChunkedMode = options.chunkedMode || fileSize == -1L + var totalBytesWritten: Long - // Handle multipart or direct upload - if (options.chunkedMode) { + if (useChunkedMode) { connection.setChunkedStreamingMode(BUFFER_SIZE) + connection.setRequestProperty("Transfer-Encoding", "chunked") } else { - if (!options.httpOptions.method.equals("POST", ignoreCase = true)) { + if (!isPostOrPutMethod(options.httpOptions.method)) { connection.setFixedLengthStreamingMode(fileSize) } else { - // Use chunked transfer encoding for multipart uploads - connection.setChunkedStreamingMode(BUFFER_SIZE) + // Calculate total size including multipart overhead + val multipartOverheadBytes = if (isPostOrPutMethod(options.httpOptions.method)) { + val boundary = "--$BOUNDARY\r\n" + val fileHeader = "Content-Disposition: form-data; name=\"${options.fileKey}\"; filename=\"${file.name}\"\r\n" + val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" + val contentType = "Content-Type: $mimeType\r\n\r\n" + val closingBoundary = "\r\n--$BOUNDARY--\r\n" + + // Add form parameters overhead if any + val formParamsOverhead = options.formParams?.entries?.sumOf { (key, value) -> + val paramHeader = "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" + val paramValue = "$value\r\n" + (paramHeader + paramValue + boundary).toByteArray(StandardCharsets.UTF_8).size + } ?: 0 + + boundary.toByteArray(StandardCharsets.UTF_8).size + + fileHeader.toByteArray(StandardCharsets.UTF_8).size + + contentType.toByteArray(StandardCharsets.UTF_8).size + + closingBoundary.toByteArray(StandardCharsets.UTF_8).size + + formParamsOverhead + } else 0 + + connection.setFixedLengthStreamingMode(fileSize + multipartOverheadBytes) } } // Set content type if not already set if (!options.httpOptions.headers.containsKey("Content-Type")) { val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" - if (options.httpOptions.method.equals("POST", ignoreCase = true)) { + if (isPostOrPutMethod(options.httpOptions.method)) { connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY") } else { connection.setRequestProperty("Content-Type", mimeType) @@ -186,107 +239,115 @@ class IONFLTRController internal constructor( // Start uploading connection.outputStream.use { connOutputStream -> BufferedOutputStream(connOutputStream).use { outputStream -> - if (options.httpOptions.method.equals("POST", ignoreCase = true)) { + if (isPostOrPutMethod(options.httpOptions.method)) { // Handle multipart form data val boundary = "--$BOUNDARY\r\n" - outputStream.write(boundary.toByteArray(StandardCharsets.UTF_8)) + val boundaryBytes = boundary.toByteArray(StandardCharsets.UTF_8) + outputStream.write(boundaryBytes) + + // Calculate multipart overhead for form parameters and keep track of written bytes + totalBytesWritten = boundaryBytes.size.toLong() + + // Write additional form parameters if any + options.formParams?.forEach { (key, value) -> + val paramHeader = "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" + val paramValue = "$value\r\n" + val paramBytes = (paramHeader + paramValue).toByteArray(StandardCharsets.UTF_8) + outputStream.write(paramBytes) + totalBytesWritten += paramBytes.size + + // Write boundary for next part + outputStream.write(boundaryBytes) + totalBytesWritten += boundaryBytes.size + } val fileHeader = "Content-Disposition: form-data; name=\"${options.fileKey}\"; filename=\"${file.name}\"\r\n" - outputStream.write(fileHeader.toByteArray(StandardCharsets.UTF_8)) + val fileHeaderBytes = fileHeader.toByteArray(StandardCharsets.UTF_8) + outputStream.write(fileHeaderBytes) val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" val contentType = "Content-Type: $mimeType\r\n\r\n" - outputStream.write(contentType.toByteArray(StandardCharsets.UTF_8)) + val contentTypeBytes = contentType.toByteArray(StandardCharsets.UTF_8) + outputStream.write(contentTypeBytes) + + // Calculate closing boundary + val closingBoundary = "\r\n--$BOUNDARY--\r\n" + val closingBoundaryBytes = closingBoundary.toByteArray(StandardCharsets.UTF_8) + + // Calculate total multipart overhead bytes + val multipartOverheadBytes = boundaryBytes.size + fileHeaderBytes.size + + contentTypeBytes.size + closingBoundaryBytes.size + + // Actual total size includes file size plus multipart overhead + val totalSize = fileSize + multipartOverheadBytes + + // Update totalBytesWritten to include file header and content type + totalBytesWritten += fileHeaderBytes.size + contentTypeBytes.size // Write file content - FileInputStream(file).use { fileInputStream -> - BufferedInputStream(fileInputStream).use { inputStream -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - var totalBytesRead: Long = 0 - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - - // Emit progress - emit( - IONFLTRTransferResult.Ongoing( - IONFLTRProgressStatus( - bytes = totalBytesRead, - contentLength = fileSize, - lengthComputable = true - ) - ) - ) - } - } - } + totalBytesWritten = writeFileWithProgress( + file = file, + outputStream = outputStream, + totalBytesWritten = totalBytesWritten, + totalSize = totalSize, + emit = { emit(it) } + ) - outputStream.write("\r\n--$BOUNDARY--\r\n".toByteArray(StandardCharsets.UTF_8)) + outputStream.write(closingBoundaryBytes) + totalBytesWritten += closingBoundaryBytes.size + + // Final update to ensure we account for the closing boundary + emit( + IONFLTRTransferResult.Ongoing( + IONFLTRProgressStatus( + bytes = totalBytesWritten, + contentLength = totalSize, + lengthComputable = true + ) + ) + ) } else { // Direct upload (not multipart) - FileInputStream(file).use { fileInputStream -> - BufferedInputStream(fileInputStream).use { inputStream -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - var totalBytesRead: Long = 0 - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - - // Emit progress - emit( - IONFLTRTransferResult.Ongoing( - IONFLTRProgressStatus( - bytes = totalBytesRead, - contentLength = fileSize, - lengthComputable = true - ) - ) - ) - } - } - } + totalBytesWritten = writeFileWithProgress( + file = file, + outputStream = outputStream, + totalBytesWritten = 0, + totalSize = fileSize, + emit = { emit(it) } + ) } } } // Check response val responseCode = connection.responseCode - val responseBody = if (responseCode >= 200 && responseCode < 300) { + val responseBody = if (responseCode in 200..299) { connection.inputStream.bufferedReader().readText() } else { - connection.errorStream?.bufferedReader()?.readText() - } - - if (responseCode < 200 || responseCode > 299) { - throw IONFLTRException.HttpError( - responseCode.toString(), - responseBody, - connection.headerFields.mapValues { it.value.firstOrNull() ?: "" } - ) + connection.errorStream?.bufferedReader()?.readText()?.also { + throw IONFLTRException.HttpError( + responseCode.toString(), + it, + connection.headerFields + ) + } } // Return success - val headers = connection.headerFields.mapValues { it.value.firstOrNull() ?: "" } emit( IONFLTRTransferResult.Complete( IONFLTRTransferComplete( - totalBytes = file.length(), + totalBytes = if (isPostOrPutMethod(options.httpOptions.method)) totalBytesWritten else file.length(), responseCode = responseCode.toString(), responseBody = responseBody, - headers = headers + headers = connection.headerFields ) ) ) } finally { connection.disconnect() } - }.onFailure { throwable -> - throw throwable - } + }.getOrThrow() }.flowOn(Dispatchers.IO) /** @@ -309,23 +370,30 @@ class IONFLTRController internal constructor( } // Set parameters - if (httpOptions.params.isNotEmpty() && httpOptions.shouldEncodeUrlParams) { - val paramBuilder = StringBuilder() - httpOptions.params.forEach { (key, values) -> - values.forEach { value -> - if (paramBuilder.isNotEmpty()) paramBuilder.append("&") - paramBuilder.append("$key=$value") + if (httpOptions.params.isNotEmpty()) { + val paramString = buildString { + httpOptions.params.forEach { (key, values) -> + values.forEach { value -> + if (isNotEmpty()) append("&") + val encodedKey = if (httpOptions.shouldEncodeUrlParams) { + java.net.URLEncoder.encode(key, StandardCharsets.UTF_8.name()) + } else key + val encodedValue = if (httpOptions.shouldEncodeUrlParams) { + java.net.URLEncoder.encode(value, StandardCharsets.UTF_8.name()) + } else value + append("$encodedKey=$encodedValue") + } } } if (httpOptions.method.equals("GET", ignoreCase = true)) { val separator = if (urlString.contains("?")) "&" else "?" - val newUrl = URL("$urlString$separator$paramBuilder") + val newUrl = URL("$urlString$separator$paramString") return newUrl.openConnection() as HttpURLConnection } else { connection.doOutput = true connection.outputStream.use { os -> - os.write(paramBuilder.toString().toByteArray()) + os.write(paramString.toByteArray()) } } } @@ -340,4 +408,29 @@ class IONFLTRController internal constructor( return connection } + + /** + * Validates the URL and file path for transfer operations. + * + * @param url The URL to validate + * @param filePath The file path to validate + * @throws IONFLTRException if validation fails + */ + private fun validateTransferInputs(url: String, filePath: String) { + when { + url.isBlank() -> throw IONFLTRException.EmptyURL(url) + !inputsValidator.isURLValid(url) -> throw IONFLTRException.InvalidURL(url) + !inputsValidator.isPathValid(filePath) -> throw IONFLTRException.InvalidPath(filePath) + } + } + + /** + * Checks if the HTTP method is either POST or PUT. + * + * @param method The HTTP method to check + * @return True if the method is POST or PUT, false otherwise + */ + private fun isPostOrPutMethod(method: String): Boolean { + return method.equals("POST", ignoreCase = true) || method.equals("PUT", ignoreCase = true) + } } \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt index 43572ee..8945515 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt @@ -4,7 +4,7 @@ import android.webkit.MimeTypeMap import io.ionic.libs.ionfiletransferlib.model.IONFLTRException import java.io.File -class IONFLTRFileHelper { +internal class IONFLTRFileHelper { /** * Gets a MIME type based on the provided file path * diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt index 25e7921..06774f6 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt @@ -2,7 +2,7 @@ package io.ionic.libs.ionfiletransferlib.helpers import java.util.regex.Pattern -class IONFLTRInputsValidator { +internal class IONFLTRInputsValidator { /** * Boolean method to check if a given file path is valid * @param path The file path to check diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt index f8a70b3..54baf1b 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt @@ -24,7 +24,7 @@ sealed class IONFLTRException( class CannotCreateDirectory(val path: String, override val cause: Throwable? = null) : IONFLTRException("Cannot create directory at $path", cause) - class HttpError(val responseCode: String, val responseBody: String?, val headers: Map<String, String>?) : + class HttpError(val responseCode: String, val responseBody: String?, val headers: Map<String, List<String>>?) : IONFLTRException("HTTP error: $responseCode") class ConnectionError(override val cause: Throwable?) : diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferOptions.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferOptions.kt index 127cd5d..e5e1fd0 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferOptions.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferOptions.kt @@ -23,6 +23,7 @@ data class IONFLTRDownloadOptions( * @property chunkedMode Whether to use chunked transfer encoding * @property mimeType The MIME type of the file (null for auto-detection) * @property fileKey The form field name for the file when uploading as multipart/form-data + * @property formParams Additional form parameters to include in multipart/form-data uploads * @property httpOptions Additional HTTP options for the upload request */ data class IONFLTRUploadOptions( @@ -31,6 +32,7 @@ data class IONFLTRUploadOptions( val chunkedMode: Boolean = false, val mimeType: String? = null, val fileKey: String = "file", + val formParams: Map<String, String>? = null, val httpOptions: IONFLTRTransferHttpOptions = IONFLTRTransferHttpOptions("POST") ) diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferResult.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferResult.kt index 6137189..1fd7fb0 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferResult.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferResult.kt @@ -44,5 +44,5 @@ data class IONFLTRTransferComplete( val totalBytes: Long, val responseCode: String, val responseBody: String?, - val headers: Map<String, String>? + val headers: Map<String, List<String>>? ) \ No newline at end of file From c5ecc370136b8fc4d826c5b1a471bc18dcaa57e3 Mon Sep 17 00:00:00 2001 From: Chace Daniels <chace.daniels@outsystems.com> Date: Thu, 3 Apr 2025 09:41:50 -0700 Subject: [PATCH 04/10] move connection to helper class --- .../ionfiletransferlib/IONFLTRController.kt | 75 ++----------------- .../helpers/IONFLTRConnectionHelper.kt | 70 +++++++++++++++++ 2 files changed, 78 insertions(+), 67 deletions(-) create mode 100644 src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRConnectionHelper.kt diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt index c7fafe1..da6372a 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -1,5 +1,6 @@ package io.ionic.libs.ionfiletransferlib +import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRConnectionHelper import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRFileHelper import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRInputsValidator import io.ionic.libs.ionfiletransferlib.helpers.runCatchingIONFLTRExceptions @@ -7,22 +8,19 @@ import io.ionic.libs.ionfiletransferlib.model.IONFLTRDownloadOptions import io.ionic.libs.ionfiletransferlib.model.IONFLTRException import io.ionic.libs.ionfiletransferlib.model.IONFLTRProgressStatus import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferComplete -import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferHttpOptions import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferResult import io.ionic.libs.ionfiletransferlib.model.IONFLTRUploadOptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream -import java.net.HttpURLConnection -import java.net.URL import java.nio.charset.StandardCharsets -import kotlinx.coroutines.withContext /** * Entry point in IONFileTransferLib-Android @@ -31,11 +29,13 @@ import kotlinx.coroutines.withContext */ class IONFLTRController internal constructor( private val inputsValidator: IONFLTRInputsValidator, - private val fileHelper: IONFLTRFileHelper + private val fileHelper: IONFLTRFileHelper, + private val connectionHelper: IONFLTRConnectionHelper ) { constructor() : this( inputsValidator = IONFLTRInputsValidator(), - fileHelper = IONFLTRFileHelper() + fileHelper = IONFLTRFileHelper(), + connectionHelper = IONFLTRConnectionHelper() ) companion object { @@ -101,7 +101,7 @@ class IONFLTRController internal constructor( fileHelper.createParentDirectories(targetFile) // Setup connection - val connection = setupConnection(options.url, options.httpOptions) + val connection = connectionHelper.setupConnection(options.url, options.httpOptions) try { connection.connect() @@ -183,7 +183,7 @@ class IONFLTRController internal constructor( } // Setup connection - val connection = setupConnection(options.url, options.httpOptions) + val connection = connectionHelper.setupConnection(options.url, options.httpOptions) try { val fileSize = file.length() @@ -350,65 +350,6 @@ class IONFLTRController internal constructor( }.getOrThrow() }.flowOn(Dispatchers.IO) - /** - * Sets up the HTTP connection with the provided options. - */ - private fun setupConnection(urlString: String, httpOptions: IONFLTRTransferHttpOptions): HttpURLConnection { - val url = URL(urlString) - val connection = url.openConnection() as HttpURLConnection - - // Set method - connection.requestMethod = httpOptions.method - - // Set timeouts - connection.connectTimeout = httpOptions.connectTimeout - connection.readTimeout = httpOptions.readTimeout - - // Set headers - httpOptions.headers.forEach { (key, value) -> - connection.setRequestProperty(key, value) - } - - // Set parameters - if (httpOptions.params.isNotEmpty()) { - val paramString = buildString { - httpOptions.params.forEach { (key, values) -> - values.forEach { value -> - if (isNotEmpty()) append("&") - val encodedKey = if (httpOptions.shouldEncodeUrlParams) { - java.net.URLEncoder.encode(key, StandardCharsets.UTF_8.name()) - } else key - val encodedValue = if (httpOptions.shouldEncodeUrlParams) { - java.net.URLEncoder.encode(value, StandardCharsets.UTF_8.name()) - } else value - append("$encodedKey=$encodedValue") - } - } - } - - if (httpOptions.method.equals("GET", ignoreCase = true)) { - val separator = if (urlString.contains("?")) "&" else "?" - val newUrl = URL("$urlString$separator$paramString") - return newUrl.openConnection() as HttpURLConnection - } else { - connection.doOutput = true - connection.outputStream.use { os -> - os.write(paramString.toByteArray()) - } - } - } - - // Set redirect handling - connection.instanceFollowRedirects = !httpOptions.disableRedirects - - // Set SSL factory if provided - if (httpOptions.sslSocketFactory != null && connection is javax.net.ssl.HttpsURLConnection) { - connection.sslSocketFactory = httpOptions.sslSocketFactory - } - - return connection - } - /** * Validates the URL and file path for transfer operations. * diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRConnectionHelper.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRConnectionHelper.kt new file mode 100644 index 0000000..63c5cf5 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRConnectionHelper.kt @@ -0,0 +1,70 @@ +package io.ionic.libs.ionfiletransferlib.helpers + +import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferHttpOptions +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets + +/** + * Helper class for setting up HTTP connections with proper configuration. + */ +class IONFLTRConnectionHelper { + /** + * Sets up the HTTP connection with the provided options. + */ + fun setupConnection(urlString: String, httpOptions: IONFLTRTransferHttpOptions): HttpURLConnection { + val url = URL(urlString) + val connection = url.openConnection() as HttpURLConnection + + // Set method + connection.requestMethod = httpOptions.method + + // Set timeouts + connection.connectTimeout = httpOptions.connectTimeout + connection.readTimeout = httpOptions.readTimeout + + // Set headers + httpOptions.headers.forEach { (key, value) -> + connection.setRequestProperty(key, value) + } + + // Set parameters + if (httpOptions.params.isNotEmpty()) { + val paramString = buildString { + httpOptions.params.forEach { (key, values) -> + values.forEach { value -> + if (isNotEmpty()) append("&") + val encodedKey = if (httpOptions.shouldEncodeUrlParams) { + java.net.URLEncoder.encode(key, StandardCharsets.UTF_8.name()) + } else key + val encodedValue = if (httpOptions.shouldEncodeUrlParams) { + java.net.URLEncoder.encode(value, StandardCharsets.UTF_8.name()) + } else value + append("$encodedKey=$encodedValue") + } + } + } + + if (httpOptions.method.equals("GET", ignoreCase = true)) { + val separator = if (urlString.contains("?")) "&" else "?" + val newUrl = URL("$urlString$separator$paramString") + return newUrl.openConnection() as HttpURLConnection + } else { + connection.doOutput = true + connection.outputStream.use { os -> + os.write(paramString.toByteArray()) + } + } + } + + // Set redirect handling + connection.instanceFollowRedirects = !httpOptions.disableRedirects + + // Set SSL factory if provided + if (httpOptions.sslSocketFactory != null && connection is javax.net.ssl.HttpsURLConnection) { + connection.sslSocketFactory = httpOptions.sslSocketFactory + } + + return connection + } +} \ No newline at end of file From 1e695791e37f0ea59d7d2433e3b846fa5cc352e8 Mon Sep 17 00:00:00 2001 From: Chace Daniels <chace.daniels@outsystems.com> Date: Thu, 3 Apr 2025 10:30:17 -0700 Subject: [PATCH 05/10] refactor: split up methods --- .../ionfiletransferlib/IONFLTRController.kt | 633 +++++++++++------- 1 file changed, 383 insertions(+), 250 deletions(-) diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt index da6372a..1aeee68 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -20,6 +20,7 @@ import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.net.HttpURLConnection import java.nio.charset.StandardCharsets /** @@ -43,6 +44,179 @@ class IONFLTRController internal constructor( private const val BOUNDARY = "----IONFLTRBoundary" } + /** + * Downloads a file from a remote URL to a local file path. + * + * @param options The download options including URL and file path + * @return A Flow of [IONFLTRTransferResult] to track progress and completion + */ + fun downloadFile(options: IONFLTRDownloadOptions): Flow<IONFLTRTransferResult> = flow { + runCatchingIONFLTRExceptions { + // Prepare for download + val (targetFile, connection) = prepareForDownload(options) + + try { + // Execute the download and handle response + val (responseCode, contentLength) = beginDownload(connection) + + // Perform the actual file download with progress reporting + val totalBytesRead = downloadFileWithProgress( + connection = connection, + targetFile = targetFile, + contentLength = contentLength, + emit = { emit(it) } + ) + + // Emit completion + emitDownloadCompletion( + responseCode = responseCode, + totalBytesRead = totalBytesRead, + headers = connection.headerFields, + emit = { emit(it) } + ) + } finally { + connection.disconnect() + } + }.getOrThrow() + }.flowOn(Dispatchers.IO) + + /** + * Prepares for download by validating inputs, creating directories and setting up connection. + */ + private fun prepareForDownload(options: IONFLTRDownloadOptions): Pair<File, HttpURLConnection> { + // Validate inputs + validateTransferInputs(options.url, options.filePath) + + // Create parent directories if needed + val targetFile = File(options.filePath) + fileHelper.createParentDirectories(targetFile) + + // Setup connection + val connection = connectionHelper.setupConnection(options.url, options.httpOptions) + + return Pair(targetFile, connection) + } + + /** + * Begins the download process and checks the response code. + * + * @return Pair of response code and content length + */ + private fun beginDownload(connection: HttpURLConnection): Pair<Int, Long> { + connection.connect() + + // Check response code + val responseCode = connection.responseCode + if (responseCode < 200 || responseCode > 299) { + val errorBody = connection.errorStream?.bufferedReader()?.readText() + throw IONFLTRException.HttpError( + responseCode.toString(), + errorBody, + connection.headerFields + ) + } + + // Get content length if available + val contentLength = connection.contentLength.toLong() + + return Pair(responseCode, contentLength) + } + + /** + * Downloads the file content with progress reporting. + */ + private suspend fun downloadFileWithProgress( + connection: HttpURLConnection, + targetFile: File, + contentLength: Long, + emit: suspend (IONFLTRTransferResult) -> Unit + ): Long = withContext(Dispatchers.IO) { + BufferedInputStream(connection.inputStream).use { inputStream -> + FileOutputStream(targetFile).use { fileOut -> + BufferedOutputStream(fileOut).use { outputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead: Long = 0 + val lengthComputable = contentLength > 0 + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + // Emit progress + emit( + IONFLTRTransferResult.Ongoing( + IONFLTRProgressStatus( + bytes = totalBytesRead, + contentLength = contentLength, + lengthComputable = lengthComputable + ) + ) + ) + } + + totalBytesRead + } + } + } + } + + /** + * Emits download completion event. + */ + private suspend fun emitDownloadCompletion( + responseCode: Int, + totalBytesRead: Long, + headers: Map<String, List<String>>?, + emit: suspend (IONFLTRTransferResult) -> Unit + ) { + emit( + IONFLTRTransferResult.Complete( + IONFLTRTransferComplete( + totalBytes = totalBytesRead, + responseCode = responseCode.toString(), + responseBody = null, + headers = headers + ) + ) + ) + } + + /** + * Uploads a file from a local path to a remote URL. + * + * @param options The upload options including URL, file path, and other configuration + * @return A Flow of [IONFLTRTransferResult] to track progress and completion + */ + fun uploadFile(options: IONFLTRUploadOptions): Flow<IONFLTRTransferResult> = flow { + runCatchingIONFLTRExceptions { + // Prepare for upload + val (file, connection) = prepareForUpload(options) + + try { + val useChunkedMode = options.chunkedMode || file.length() == -1L + + // Configure connection based on upload mode + configureConnectionForUpload(connection, options, file, useChunkedMode) + + connection.doOutput = true + connection.connect() + + // Perform the upload + val totalBytesWritten: Long = if (isPostOrPutMethod(options.httpOptions.method)) { + handleMultipartUpload(connection, options, file, emit = { emit(it) }) + } else { + handleDirectUpload(connection, file, emit = { emit(it) }) + } + + // Process the response + processUploadResponse(connection, options, file, totalBytesWritten, emit = { emit(it) }) + } finally { + connection.disconnect() + } + }.getOrThrow() + }.flowOn(Dispatchers.IO) + /** * Writes a file to an output stream and emits progress updates. * @@ -64,11 +238,11 @@ class IONFLTRController internal constructor( BufferedInputStream(fileInputStream).use { inputStream -> val buffer = ByteArray(BUFFER_SIZE) var bytesRead: Int - + while (inputStream.read(buffer).also { bytesRead = it } != -1) { outputStream.write(buffer, 0, bytesRead) currentTotalBytes += bytesRead - + // Emit progress emit( IONFLTRTransferResult.Ongoing( @@ -84,271 +258,230 @@ class IONFLTRController internal constructor( } currentTotalBytes } - + /** - * Downloads a file from a remote URL to a local file path. - * - * @param options The download options including URL and file path - * @return A Flow of [IONFLTRTransferResult] to track progress and completion + * Prepares for upload by validating inputs and setting up connection. */ - fun downloadFile(options: IONFLTRDownloadOptions): Flow<IONFLTRTransferResult> = flow { - runCatchingIONFLTRExceptions { - // Validate inputs - validateTransferInputs(options.url, options.filePath) - - // Create parent directories if needed - val targetFile = File(options.filePath) - fileHelper.createParentDirectories(targetFile) - - // Setup connection - val connection = connectionHelper.setupConnection(options.url, options.httpOptions) - - try { - connection.connect() - - // Check response code - val responseCode = connection.responseCode - if (responseCode < 200 || responseCode > 299) { - val errorBody = connection.errorStream?.bufferedReader()?.readText() - throw IONFLTRException.HttpError( - responseCode.toString(), - errorBody, - connection.headerFields - ) - } - - // Get content length if available - val contentLength = connection.contentLength.toLong() - val lengthComputable = contentLength > 0 - - // Download the file - BufferedInputStream(connection.inputStream).use { inputStream -> - FileOutputStream(targetFile).use { fileOut -> - BufferedOutputStream(fileOut).use { outputStream -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - var totalBytesRead: Long = 0 - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - - // Emit progress - emit( - IONFLTRTransferResult.Ongoing( - IONFLTRProgressStatus( - bytes = totalBytesRead, - contentLength = contentLength, - lengthComputable = lengthComputable - ) - ) - ) - } - - // Emit completion - emit( - IONFLTRTransferResult.Complete( - IONFLTRTransferComplete( - totalBytes = totalBytesRead, - responseCode = responseCode.toString(), - responseBody = null, - headers = connection.headerFields - ) - ) - ) - } - } - } - } finally { - connection.disconnect() - } - }.getOrThrow() - }.flowOn(Dispatchers.IO) - + private fun prepareForUpload(options: IONFLTRUploadOptions): Pair<File, HttpURLConnection> { + // Validate inputs + validateTransferInputs(options.url, options.filePath) + + // Check if file exists + val file = File(options.filePath) + if (!file.exists()) { + throw IONFLTRException.FileDoesNotExist() + } + + // Setup connection + val connection = connectionHelper.setupConnection(options.url, options.httpOptions) + + return Pair(file, connection) + } + /** - * Uploads a file from a local path to a remote URL. - * - * @param options The upload options including URL, file path, and other configuration - * @return A Flow of [IONFLTRTransferResult] to track progress and completion + * Configures the connection for upload based on the upload mode. */ - fun uploadFile(options: IONFLTRUploadOptions): Flow<IONFLTRTransferResult> = flow { - runCatchingIONFLTRExceptions { - // Validate inputs - validateTransferInputs(options.url, options.filePath) - - // Check if file exists - val file = File(options.filePath) - if (!file.exists()) { - throw IONFLTRException.FileDoesNotExist() + private fun configureConnectionForUpload( + connection: HttpURLConnection, + options: IONFLTRUploadOptions, + file: File, + useChunkedMode: Boolean + ) { + if (useChunkedMode) { + connection.setChunkedStreamingMode(BUFFER_SIZE) + connection.setRequestProperty("Transfer-Encoding", "chunked") + } else { + if (!isPostOrPutMethod(options.httpOptions.method)) { + connection.setFixedLengthStreamingMode(file.length()) + } else { + // Calculate total size including multipart overhead + val multipartOverheadBytes = calculateMultipartOverhead(options, file) + connection.setFixedLengthStreamingMode(file.length() + multipartOverheadBytes) } - - // Setup connection - val connection = connectionHelper.setupConnection(options.url, options.httpOptions) - - try { - val fileSize = file.length() - val useChunkedMode = options.chunkedMode || fileSize == -1L - var totalBytesWritten: Long + } + + // Set content type if not already set + if (!options.httpOptions.headers.containsKey("Content-Type")) { + val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" + if (isPostOrPutMethod(options.httpOptions.method)) { + connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY") + } else { + connection.setRequestProperty("Content-Type", mimeType) + } + } + } + + /** + * Calculates the overhead bytes for multipart uploads. + */ + private fun calculateMultipartOverhead(options: IONFLTRUploadOptions, file: File): Long { + val boundary = "--$BOUNDARY\r\n" + val fileHeader = "Content-Disposition: form-data; name=\"${options.fileKey}\"; filename=\"${file.name}\"\r\n" + val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" + val contentType = "Content-Type: $mimeType\r\n\r\n" + val closingBoundary = "\r\n--$BOUNDARY--\r\n" + + // Add form parameters overhead if any + val formParamsOverhead = options.formParams?.entries?.sumOf { (key, value) -> + val paramHeader = "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" + val paramValue = "$value\r\n" + (paramHeader + paramValue + boundary).toByteArray(StandardCharsets.UTF_8).size + } ?: 0 + + return (boundary.toByteArray(StandardCharsets.UTF_8).size + + fileHeader.toByteArray(StandardCharsets.UTF_8).size + + contentType.toByteArray(StandardCharsets.UTF_8).size + + closingBoundary.toByteArray(StandardCharsets.UTF_8).size + + formParamsOverhead).toLong() + } + + /** + * Handles multipart form data uploads. + */ + private suspend fun handleMultipartUpload( + connection: HttpURLConnection, + options: IONFLTRUploadOptions, + file: File, + emit: suspend (IONFLTRTransferResult) -> Unit + ): Long { + var totalBytesWritten: Long + + connection.outputStream.use { connOutputStream -> + BufferedOutputStream(connOutputStream).use { outputStream -> + // Handle multipart form data + val boundary = "--$BOUNDARY\r\n" + val boundaryBytes = boundary.toByteArray(StandardCharsets.UTF_8) + outputStream.write(boundaryBytes) - if (useChunkedMode) { - connection.setChunkedStreamingMode(BUFFER_SIZE) - connection.setRequestProperty("Transfer-Encoding", "chunked") - } else { - if (!isPostOrPutMethod(options.httpOptions.method)) { - connection.setFixedLengthStreamingMode(fileSize) - } else { - // Calculate total size including multipart overhead - val multipartOverheadBytes = if (isPostOrPutMethod(options.httpOptions.method)) { - val boundary = "--$BOUNDARY\r\n" - val fileHeader = "Content-Disposition: form-data; name=\"${options.fileKey}\"; filename=\"${file.name}\"\r\n" - val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" - val contentType = "Content-Type: $mimeType\r\n\r\n" - val closingBoundary = "\r\n--$BOUNDARY--\r\n" - - // Add form parameters overhead if any - val formParamsOverhead = options.formParams?.entries?.sumOf { (key, value) -> - val paramHeader = "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" - val paramValue = "$value\r\n" - (paramHeader + paramValue + boundary).toByteArray(StandardCharsets.UTF_8).size - } ?: 0 - - boundary.toByteArray(StandardCharsets.UTF_8).size + - fileHeader.toByteArray(StandardCharsets.UTF_8).size + - contentType.toByteArray(StandardCharsets.UTF_8).size + - closingBoundary.toByteArray(StandardCharsets.UTF_8).size + - formParamsOverhead - } else 0 - - connection.setFixedLengthStreamingMode(fileSize + multipartOverheadBytes) - } - } + // Calculate multipart overhead for form parameters and keep track of written bytes + totalBytesWritten = boundaryBytes.size.toLong() - // Set content type if not already set - if (!options.httpOptions.headers.containsKey("Content-Type")) { - val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" - if (isPostOrPutMethod(options.httpOptions.method)) { - connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY") - } else { - connection.setRequestProperty("Content-Type", mimeType) - } + // Write additional form parameters if any + options.formParams?.forEach { (key, value) -> + val paramHeader = "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" + val paramValue = "$value\r\n" + val paramBytes = (paramHeader + paramValue).toByteArray(StandardCharsets.UTF_8) + outputStream.write(paramBytes) + totalBytesWritten += paramBytes.size + + // Write boundary for next part + outputStream.write(boundaryBytes) + totalBytesWritten += boundaryBytes.size } - connection.doOutput = true - connection.connect() + val fileHeader = "Content-Disposition: form-data; name=\"${options.fileKey}\"; filename=\"${file.name}\"\r\n" + val fileHeaderBytes = fileHeader.toByteArray(StandardCharsets.UTF_8) + outputStream.write(fileHeaderBytes) - // Start uploading - connection.outputStream.use { connOutputStream -> - BufferedOutputStream(connOutputStream).use { outputStream -> - if (isPostOrPutMethod(options.httpOptions.method)) { - // Handle multipart form data - val boundary = "--$BOUNDARY\r\n" - val boundaryBytes = boundary.toByteArray(StandardCharsets.UTF_8) - outputStream.write(boundaryBytes) - - // Calculate multipart overhead for form parameters and keep track of written bytes - totalBytesWritten = boundaryBytes.size.toLong() - - // Write additional form parameters if any - options.formParams?.forEach { (key, value) -> - val paramHeader = "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" - val paramValue = "$value\r\n" - val paramBytes = (paramHeader + paramValue).toByteArray(StandardCharsets.UTF_8) - outputStream.write(paramBytes) - totalBytesWritten += paramBytes.size - - // Write boundary for next part - outputStream.write(boundaryBytes) - totalBytesWritten += boundaryBytes.size - } - - val fileHeader = "Content-Disposition: form-data; name=\"${options.fileKey}\"; filename=\"${file.name}\"\r\n" - val fileHeaderBytes = fileHeader.toByteArray(StandardCharsets.UTF_8) - outputStream.write(fileHeaderBytes) - - val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" - val contentType = "Content-Type: $mimeType\r\n\r\n" - val contentTypeBytes = contentType.toByteArray(StandardCharsets.UTF_8) - outputStream.write(contentTypeBytes) - - // Calculate closing boundary - val closingBoundary = "\r\n--$BOUNDARY--\r\n" - val closingBoundaryBytes = closingBoundary.toByteArray(StandardCharsets.UTF_8) - - // Calculate total multipart overhead bytes - val multipartOverheadBytes = boundaryBytes.size + fileHeaderBytes.size + - contentTypeBytes.size + closingBoundaryBytes.size - - // Actual total size includes file size plus multipart overhead - val totalSize = fileSize + multipartOverheadBytes - - // Update totalBytesWritten to include file header and content type - totalBytesWritten += fileHeaderBytes.size + contentTypeBytes.size - - // Write file content - totalBytesWritten = writeFileWithProgress( - file = file, - outputStream = outputStream, - totalBytesWritten = totalBytesWritten, - totalSize = totalSize, - emit = { emit(it) } - ) - - outputStream.write(closingBoundaryBytes) - totalBytesWritten += closingBoundaryBytes.size - - // Final update to ensure we account for the closing boundary - emit( - IONFLTRTransferResult.Ongoing( - IONFLTRProgressStatus( - bytes = totalBytesWritten, - contentLength = totalSize, - lengthComputable = true - ) - ) - ) - } else { - // Direct upload (not multipart) - totalBytesWritten = writeFileWithProgress( - file = file, - outputStream = outputStream, - totalBytesWritten = 0, - totalSize = fileSize, - emit = { emit(it) } - ) - } - } - } + val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" + val contentType = "Content-Type: $mimeType\r\n\r\n" + val contentTypeBytes = contentType.toByteArray(StandardCharsets.UTF_8) + outputStream.write(contentTypeBytes) - // Check response - val responseCode = connection.responseCode - val responseBody = if (responseCode in 200..299) { - connection.inputStream.bufferedReader().readText() - } else { - connection.errorStream?.bufferedReader()?.readText()?.also { - throw IONFLTRException.HttpError( - responseCode.toString(), - it, - connection.headerFields - ) - } - } + // Calculate closing boundary + val closingBoundary = "\r\n--$BOUNDARY--\r\n" + val closingBoundaryBytes = closingBoundary.toByteArray(StandardCharsets.UTF_8) + + // Calculate total multipart overhead bytes + val multipartOverheadBytes = boundaryBytes.size + fileHeaderBytes.size + + contentTypeBytes.size + closingBoundaryBytes.size + + // Actual total size includes file size plus multipart overhead + val totalSize = file.length() + multipartOverheadBytes + + // Update totalBytesWritten to include file header and content type + totalBytesWritten += fileHeaderBytes.size + contentTypeBytes.size - // Return success + // Write file content + totalBytesWritten = writeFileWithProgress( + file = file, + outputStream = outputStream, + totalBytesWritten = totalBytesWritten, + totalSize = totalSize, + emit = { emit(it) } + ) + + outputStream.write(closingBoundaryBytes) + totalBytesWritten += closingBoundaryBytes.size + + // Final update to ensure we account for the closing boundary emit( - IONFLTRTransferResult.Complete( - IONFLTRTransferComplete( - totalBytes = if (isPostOrPutMethod(options.httpOptions.method)) totalBytesWritten else file.length(), - responseCode = responseCode.toString(), - responseBody = responseBody, - headers = connection.headerFields + IONFLTRTransferResult.Ongoing( + IONFLTRProgressStatus( + bytes = totalBytesWritten, + contentLength = totalSize, + lengthComputable = true ) ) ) - } finally { - connection.disconnect() } - }.getOrThrow() - }.flowOn(Dispatchers.IO) + } + + return totalBytesWritten + } + + /** + * Handles direct (non-multipart) file uploads. + */ + private suspend fun handleDirectUpload( + connection: HttpURLConnection, + file: File, + emit: suspend (IONFLTRTransferResult) -> Unit + ): Long { + var totalBytesWritten: Long + + connection.outputStream.use { connOutputStream -> + BufferedOutputStream(connOutputStream).use { outputStream -> + // Direct upload (not multipart) + totalBytesWritten = writeFileWithProgress( + file = file, + outputStream = outputStream, + totalBytesWritten = 0, + totalSize = file.length(), + emit = { emit(it) } + ) + } + } + + return totalBytesWritten + } + + /** + * Processes the upload response and emits completion. + */ + private suspend fun processUploadResponse( + connection: HttpURLConnection, + options: IONFLTRUploadOptions, + file: File, + totalBytesWritten: Long, + emit: suspend (IONFLTRTransferResult) -> Unit + ) { + // Check response + val responseCode = connection.responseCode + val responseBody = if (responseCode in 200..299) { + connection.inputStream.bufferedReader().readText() + } else { + connection.errorStream?.bufferedReader()?.readText()?.also { + throw IONFLTRException.HttpError( + responseCode.toString(), + it, + connection.headerFields + ) + } + } + + // Return success + emit( + IONFLTRTransferResult.Complete( + IONFLTRTransferComplete( + totalBytes = if (isPostOrPutMethod(options.httpOptions.method)) totalBytesWritten else file.length(), + responseCode = responseCode.toString(), + responseBody = responseBody, + headers = connection.headerFields + ) + ) + ) + } /** * Validates the URL and file path for transfer operations. From 3bdf1ab5929ebf86b8efcfab3f22b246c10fb844 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro <pedro.gustavo.bilro@outsystems.com> Date: Fri, 4 Apr 2025 15:37:43 +0100 Subject: [PATCH 06/10] refactor: Reorder code and avoid duplication --- .../ionfiletransferlib/IONFLTRController.kt | 424 ++++++++---------- .../helpers/IONFLTRInputsValidator.kt | 17 + 2 files changed, 201 insertions(+), 240 deletions(-) diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt index 1aeee68..e38ade0 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -21,7 +21,6 @@ import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.net.HttpURLConnection -import java.nio.charset.StandardCharsets /** * Entry point in IONFileTransferLib-Android @@ -41,7 +40,9 @@ class IONFLTRController internal constructor( companion object { private const val BUFFER_SIZE = 8192 // 8KB buffer size - private const val BOUNDARY = "----IONFLTRBoundary" + private const val BOUNDARY = "++++IONFLTRBoundary" + private const val LINE_START = "--" + private const val LINE_END = "\r\n" } /** @@ -54,11 +55,11 @@ class IONFLTRController internal constructor( runCatchingIONFLTRExceptions { // Prepare for download val (targetFile, connection) = prepareForDownload(options) - + try { // Execute the download and handle response - val (responseCode, contentLength) = beginDownload(connection) - + val contentLength = beginDownload(connection) + // Perform the actual file download with progress reporting val totalBytesRead = downloadFileWithProgress( connection = connection, @@ -66,26 +67,66 @@ class IONFLTRController internal constructor( contentLength = contentLength, emit = { emit(it) } ) - + // Emit completion - emitDownloadCompletion( - responseCode = responseCode, - totalBytesRead = totalBytesRead, - headers = connection.headerFields, - emit = { emit(it) } + emit( + IONFLTRTransferResult.Complete( + IONFLTRTransferComplete( + totalBytes = totalBytesRead, + responseCode = connection.responseCode.toString(), + responseBody = null, + headers = connection.headerFields + ) + ) ) } finally { connection.disconnect() } }.getOrThrow() }.flowOn(Dispatchers.IO) - + + /** + * Uploads a file from a local path to a remote URL. + * + * @param options The upload options including URL, file path, and other configuration + * @return A Flow of [IONFLTRTransferResult] to track progress and completion + */ + fun uploadFile(options: IONFLTRUploadOptions): Flow<IONFLTRTransferResult> = flow { + runCatchingIONFLTRExceptions { + // Prepare for upload + val (file, connection) = prepareForUpload(options) + + try { + val useChunkedMode = options.chunkedMode || file.length() == -1L + + // Configure connection based on upload mode + val multiPartFormData = + configureConnectionForUpload(connection, options, file, useChunkedMode) + + connection.doOutput = true + connection.connect() + + // Perform the upload + val totalBytesWritten: Long = if (multiPartFormData != null) { + handleMultipartUpload(connection, multiPartFormData, file, emit = { emit(it) }) + } else { + handleDirectUpload(connection, file, emit = { emit(it) }) + } + + // Process the response + processUploadResponse(connection, totalBytesWritten, emit = { emit(it) }) + } finally { + connection.disconnect() + } + }.getOrThrow() + }.flowOn(Dispatchers.IO) + /** * Prepares for download by validating inputs, creating directories and setting up connection. */ private fun prepareForDownload(options: IONFLTRDownloadOptions): Pair<File, HttpURLConnection> { // Validate inputs - validateTransferInputs(options.url, options.filePath) + inputsValidator.validateTransferInputs(options.url, options.filePath) // Create parent directories if needed val targetFile = File(options.filePath) @@ -93,35 +134,27 @@ class IONFLTRController internal constructor( // Setup connection val connection = connectionHelper.setupConnection(options.url, options.httpOptions) - + return Pair(targetFile, connection) } - + /** * Begins the download process and checks the response code. - * - * @return Pair of response code and content length + * + * @return the content length associated with the download request */ - private fun beginDownload(connection: HttpURLConnection): Pair<Int, Long> { + private fun beginDownload(connection: HttpURLConnection): Long { connection.connect() - + // Check response code - val responseCode = connection.responseCode - if (responseCode < 200 || responseCode > 299) { - val errorBody = connection.errorStream?.bufferedReader()?.readText() - throw IONFLTRException.HttpError( - responseCode.toString(), - errorBody, - connection.headerFields - ) - } - + connection.assertSuccessHttpResponse() + // Get content length if available val contentLength = connection.contentLength.toLong() - - return Pair(responseCode, contentLength) + + return contentLength } - + /** * Downloads the file content with progress reporting. */ @@ -138,11 +171,11 @@ class IONFLTRController internal constructor( var bytesRead: Int var totalBytesRead: Long = 0 val lengthComputable = contentLength > 0 - + while (inputStream.read(buffer).also { bytesRead = it } != -1) { outputStream.write(buffer, 0, bytesRead) totalBytesRead += bytesRead - + // Emit progress emit( IONFLTRTransferResult.Ongoing( @@ -154,68 +187,12 @@ class IONFLTRController internal constructor( ) ) } - + totalBytesRead } } } } - - /** - * Emits download completion event. - */ - private suspend fun emitDownloadCompletion( - responseCode: Int, - totalBytesRead: Long, - headers: Map<String, List<String>>?, - emit: suspend (IONFLTRTransferResult) -> Unit - ) { - emit( - IONFLTRTransferResult.Complete( - IONFLTRTransferComplete( - totalBytes = totalBytesRead, - responseCode = responseCode.toString(), - responseBody = null, - headers = headers - ) - ) - ) - } - - /** - * Uploads a file from a local path to a remote URL. - * - * @param options The upload options including URL, file path, and other configuration - * @return A Flow of [IONFLTRTransferResult] to track progress and completion - */ - fun uploadFile(options: IONFLTRUploadOptions): Flow<IONFLTRTransferResult> = flow { - runCatchingIONFLTRExceptions { - // Prepare for upload - val (file, connection) = prepareForUpload(options) - - try { - val useChunkedMode = options.chunkedMode || file.length() == -1L - - // Configure connection based on upload mode - configureConnectionForUpload(connection, options, file, useChunkedMode) - - connection.doOutput = true - connection.connect() - - // Perform the upload - val totalBytesWritten: Long = if (isPostOrPutMethod(options.httpOptions.method)) { - handleMultipartUpload(connection, options, file, emit = { emit(it) }) - } else { - handleDirectUpload(connection, file, emit = { emit(it) }) - } - - // Process the response - processUploadResponse(connection, options, file, totalBytesWritten, emit = { emit(it) }) - } finally { - connection.disconnect() - } - }.getOrThrow() - }.flowOn(Dispatchers.IO) /** * Writes a file to an output stream and emits progress updates. @@ -242,156 +219,138 @@ class IONFLTRController internal constructor( while (inputStream.read(buffer).also { bytesRead = it } != -1) { outputStream.write(buffer, 0, bytesRead) currentTotalBytes += bytesRead - - // Emit progress - emit( - IONFLTRTransferResult.Ongoing( - IONFLTRProgressStatus( - bytes = currentTotalBytes, - contentLength = totalSize, - lengthComputable = true - ) - ) - ) + emit(createUploadFileProgress(bytes = currentTotalBytes, total = totalSize)) } } } currentTotalBytes } - + /** * Prepares for upload by validating inputs and setting up connection. */ private fun prepareForUpload(options: IONFLTRUploadOptions): Pair<File, HttpURLConnection> { // Validate inputs - validateTransferInputs(options.url, options.filePath) - + inputsValidator.validateTransferInputs(options.url, options.filePath) + // Check if file exists val file = File(options.filePath) if (!file.exists()) { throw IONFLTRException.FileDoesNotExist() } - + // Setup connection val connection = connectionHelper.setupConnection(options.url, options.httpOptions) - + return Pair(file, connection) } - + /** * Configures the connection for upload based on the upload mode. + * + * @return multi-part form data to append to beggining and end */ private fun configureConnectionForUpload( connection: HttpURLConnection, options: IONFLTRUploadOptions, file: File, useChunkedMode: Boolean - ) { - if (useChunkedMode) { - connection.setChunkedStreamingMode(BUFFER_SIZE) - connection.setRequestProperty("Transfer-Encoding", "chunked") - } else { - if (!isPostOrPutMethod(options.httpOptions.method)) { - connection.setFixedLengthStreamingMode(file.length()) - } else { - // Calculate total size including multipart overhead - val multipartOverheadBytes = calculateMultipartOverhead(options, file) - connection.setFixedLengthStreamingMode(file.length() + multipartOverheadBytes) - } - } - + ): Pair<String, String>? { + var multiPartUpload = false // Set content type if not already set if (!options.httpOptions.headers.containsKey("Content-Type")) { - val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" + val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) + ?: "application/octet-stream" if (isPostOrPutMethod(options.httpOptions.method)) { - connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY") + multiPartUpload = true + connection.setRequestProperty( + "Content-Type", + "multipart/form-data; boundary=$BOUNDARY" + ) } else { connection.setRequestProperty("Content-Type", mimeType) } } + + if (useChunkedMode) { + connection.setChunkedStreamingMode(BUFFER_SIZE) + connection.setRequestProperty("Transfer-Encoding", "chunked") + } else if (!multiPartUpload) { + connection.setFixedLengthStreamingMode(file.length()) + } else { + val multipartData = createMultipartData(options, file.name) + // Calculate total size including multipart overhead + val multipartByteArray = (multipartData.first + multipartData.second).toByteArray() + connection.setFixedLengthStreamingMode(file.length() + multipartByteArray.size) + return multipartData + } + + return null } - + /** - * Calculates the overhead bytes for multipart uploads. + * Create the multipart-form data that will be added to the upload request body + * + * There are two parts: content to be added before the file data, and after the file + * + * @param options the options to configure the upload + * @param fileName name of the file to upload + * @return pair of multipart content to upload before and after file data */ - private fun calculateMultipartOverhead(options: IONFLTRUploadOptions, file: File): Long { - val boundary = "--$BOUNDARY\r\n" - val fileHeader = "Content-Disposition: form-data; name=\"${options.fileKey}\"; filename=\"${file.name}\"\r\n" - val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" - val contentType = "Content-Type: $mimeType\r\n\r\n" - val closingBoundary = "\r\n--$BOUNDARY--\r\n" - - // Add form parameters overhead if any - val formParamsOverhead = options.formParams?.entries?.sumOf { (key, value) -> - val paramHeader = "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" - val paramValue = "$value\r\n" - (paramHeader + paramValue + boundary).toByteArray(StandardCharsets.UTF_8).size - } ?: 0 - - return (boundary.toByteArray(StandardCharsets.UTF_8).size + - fileHeader.toByteArray(StandardCharsets.UTF_8).size + - contentType.toByteArray(StandardCharsets.UTF_8).size + - closingBoundary.toByteArray(StandardCharsets.UTF_8).size + - formParamsOverhead).toLong() + private fun createMultipartData( + options: IONFLTRUploadOptions, + fileName: String + ): Pair<String, String> { + val boundary = "$LINE_START$BOUNDARY$LINE_END" + + val beforeData = buildString { + // Write additional form parameters if any + options.formParams?.forEach { (key, value) -> + append(boundary) + val paramHeader = "Content-Disposition: form-data; name=\"$key\"$LINE_END$LINE_END" + val paramValue = "$value$LINE_END" + val param = (paramHeader + paramValue) + append(param) + } + append(boundary) + val fileHeader = + "Content-Disposition: form-data; name=\"${options.fileKey}\"; filename=\"${fileName}\"$LINE_END" + append(fileHeader) + val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) + ?: "application/octet-stream" + val contentType = "Content-Type: $mimeType$LINE_END$LINE_END" + append(contentType) + } + + val afterData = "$LINE_END$LINE_START$BOUNDARY$LINE_START$LINE_END" + + return Pair(beforeData, afterData) } - + /** * Handles multipart form data uploads. */ private suspend fun handleMultipartUpload( connection: HttpURLConnection, - options: IONFLTRUploadOptions, + multipartExtraData: Pair<String, String>, file: File, emit: suspend (IONFLTRTransferResult) -> Unit ): Long { - var totalBytesWritten: Long - + var totalBytesWritten: Long = 0 + connection.outputStream.use { connOutputStream -> BufferedOutputStream(connOutputStream).use { outputStream -> - // Handle multipart form data - val boundary = "--$BOUNDARY\r\n" - val boundaryBytes = boundary.toByteArray(StandardCharsets.UTF_8) - outputStream.write(boundaryBytes) - - // Calculate multipart overhead for form parameters and keep track of written bytes - totalBytesWritten = boundaryBytes.size.toLong() - - // Write additional form parameters if any - options.formParams?.forEach { (key, value) -> - val paramHeader = "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" - val paramValue = "$value\r\n" - val paramBytes = (paramHeader + paramValue).toByteArray(StandardCharsets.UTF_8) - outputStream.write(paramBytes) - totalBytesWritten += paramBytes.size - - // Write boundary for next part - outputStream.write(boundaryBytes) - totalBytesWritten += boundaryBytes.size - } - - val fileHeader = "Content-Disposition: form-data; name=\"${options.fileKey}\"; filename=\"${file.name}\"\r\n" - val fileHeaderBytes = fileHeader.toByteArray(StandardCharsets.UTF_8) - outputStream.write(fileHeaderBytes) - - val mimeType = options.mimeType ?: fileHelper.getMimeType(options.filePath) ?: "application/octet-stream" - val contentType = "Content-Type: $mimeType\r\n\r\n" - val contentTypeBytes = contentType.toByteArray(StandardCharsets.UTF_8) - outputStream.write(contentTypeBytes) - - // Calculate closing boundary - val closingBoundary = "\r\n--$BOUNDARY--\r\n" - val closingBoundaryBytes = closingBoundary.toByteArray(StandardCharsets.UTF_8) - - // Calculate total multipart overhead bytes - val multipartOverheadBytes = boundaryBytes.size + fileHeaderBytes.size + - contentTypeBytes.size + closingBoundaryBytes.size - + val beforeDataByteArray = multipartExtraData.first.toByteArray() + val afterDataByteArray = multipartExtraData.second.toByteArray() + // Actual total size includes file size plus multipart overhead - val totalSize = file.length() + multipartOverheadBytes - - // Update totalBytesWritten to include file header and content type - totalBytesWritten += fileHeaderBytes.size + contentTypeBytes.size - + val totalSize = file.length() + beforeDataByteArray.size + afterDataByteArray.size + + // write multipart form content before file + totalBytesWritten += beforeDataByteArray.size + outputStream.write(beforeDataByteArray) + emit(createUploadFileProgress(bytes = totalBytesWritten, total = totalSize)) + // Write file content totalBytesWritten = writeFileWithProgress( file = file, @@ -400,26 +359,17 @@ class IONFLTRController internal constructor( totalSize = totalSize, emit = { emit(it) } ) - - outputStream.write(closingBoundaryBytes) - totalBytesWritten += closingBoundaryBytes.size - - // Final update to ensure we account for the closing boundary - emit( - IONFLTRTransferResult.Ongoing( - IONFLTRProgressStatus( - bytes = totalBytesWritten, - contentLength = totalSize, - lengthComputable = true - ) - ) - ) + + // write multipart form content after file + outputStream.write(afterDataByteArray) + totalBytesWritten += afterDataByteArray.size + emit(createUploadFileProgress(bytes = totalBytesWritten, total = totalSize)) } } - + return totalBytesWritten } - + /** * Handles direct (non-multipart) file uploads. */ @@ -429,7 +379,7 @@ class IONFLTRController internal constructor( emit: suspend (IONFLTRTransferResult) -> Unit ): Long { var totalBytesWritten: Long - + connection.outputStream.use { connOutputStream -> BufferedOutputStream(connOutputStream).use { outputStream -> // Direct upload (not multipart) @@ -442,39 +392,35 @@ class IONFLTRController internal constructor( ) } } - + return totalBytesWritten } - + + private fun createUploadFileProgress(bytes: Long, total: Long) = + IONFLTRTransferResult.Ongoing( + status = IONFLTRProgressStatus( + bytes = bytes, contentLength = total, lengthComputable = true + ) + ) + /** * Processes the upload response and emits completion. */ private suspend fun processUploadResponse( connection: HttpURLConnection, - options: IONFLTRUploadOptions, - file: File, totalBytesWritten: Long, emit: suspend (IONFLTRTransferResult) -> Unit ) { // Check response + connection.assertSuccessHttpResponse() val responseCode = connection.responseCode - val responseBody = if (responseCode in 200..299) { - connection.inputStream.bufferedReader().readText() - } else { - connection.errorStream?.bufferedReader()?.readText()?.also { - throw IONFLTRException.HttpError( - responseCode.toString(), - it, - connection.headerFields - ) - } - } - + val responseBody = connection.inputStream.bufferedReader().readText() + // Return success emit( IONFLTRTransferResult.Complete( IONFLTRTransferComplete( - totalBytes = if (isPostOrPutMethod(options.httpOptions.method)) totalBytesWritten else file.length(), + totalBytes = totalBytesWritten, responseCode = responseCode.toString(), responseBody = responseBody, headers = connection.headerFields @@ -483,24 +429,22 @@ class IONFLTRController internal constructor( ) } - /** - * Validates the URL and file path for transfer operations. - * - * @param url The URL to validate - * @param filePath The file path to validate - * @throws IONFLTRException if validation fails - */ - private fun validateTransferInputs(url: String, filePath: String) { - when { - url.isBlank() -> throw IONFLTRException.EmptyURL(url) - !inputsValidator.isURLValid(url) -> throw IONFLTRException.InvalidURL(url) - !inputsValidator.isPathValid(filePath) -> throw IONFLTRException.InvalidPath(filePath) + private fun HttpURLConnection.assertSuccessHttpResponse() { + if (responseCode in 200..299) { + return // successful response + } + errorStream?.bufferedReader()?.readText()?.also { + throw IONFLTRException.HttpError( + responseCode.toString(), + it, + headerFields + ) } } - + /** * Checks if the HTTP method is either POST or PUT. - * + * * @param method The HTTP method to check * @return True if the method is POST or PUT, false otherwise */ diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt index 06774f6..dd5b89a 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt @@ -1,8 +1,25 @@ package io.ionic.libs.ionfiletransferlib.helpers +import io.ionic.libs.ionfiletransferlib.model.IONFLTRException import java.util.regex.Pattern internal class IONFLTRInputsValidator { + + /** + * Validates the URL and file path for transfer operations. + * + * @param url The URL to validate + * @param filePath The file path to validate + * @throws IONFLTRException if validation fails + */ + fun validateTransferInputs(url: String, filePath: String) { + when { + url.isBlank() -> throw IONFLTRException.EmptyURL(url) + !isURLValid(url) -> throw IONFLTRException.InvalidURL(url) + !isPathValid(filePath) -> throw IONFLTRException.InvalidPath(filePath) + } + } + /** * Boolean method to check if a given file path is valid * @param path The file path to check From fe39975400cedd765ee235e4de462ee50aff0eb3 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro <pedro.gustavo.bilro@outsystems.com> Date: Fri, 4 Apr 2025 15:42:18 +0100 Subject: [PATCH 07/10] fix: Remove withContext inside flow block To avoid runtime error PR comment: https://github.com/ionic-team/ion-android-filetransfer/pull/1#discussion_r2027499759 --- .../ionfiletransferlib/IONFLTRController.kt | 58 +++++++++---------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt index e38ade0..615a9f4 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.withContext import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File @@ -163,33 +162,31 @@ class IONFLTRController internal constructor( targetFile: File, contentLength: Long, emit: suspend (IONFLTRTransferResult) -> Unit - ): Long = withContext(Dispatchers.IO) { - BufferedInputStream(connection.inputStream).use { inputStream -> - FileOutputStream(targetFile).use { fileOut -> - BufferedOutputStream(fileOut).use { outputStream -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - var totalBytesRead: Long = 0 - val lengthComputable = contentLength > 0 - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - - // Emit progress - emit( - IONFLTRTransferResult.Ongoing( - IONFLTRProgressStatus( - bytes = totalBytesRead, - contentLength = contentLength, - lengthComputable = lengthComputable - ) + ): Long = BufferedInputStream(connection.inputStream).use { inputStream -> + FileOutputStream(targetFile).use { fileOut -> + BufferedOutputStream(fileOut).use { outputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead: Long = 0 + val lengthComputable = contentLength > 0 + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + // Emit progress + emit( + IONFLTRTransferResult.Ongoing( + IONFLTRProgressStatus( + bytes = totalBytesRead, + contentLength = contentLength, + lengthComputable = lengthComputable ) ) - } - - totalBytesRead + ) } + + totalBytesRead } } } @@ -203,13 +200,14 @@ class IONFLTRController internal constructor( * @param totalSize The total size to report in progress updates * @param emit Function to emit progress updates */ - private suspend fun writeFileWithProgress( + @Suppress("BlockingMethodInNonBlockingContext") // method will always be called from IO scope + private suspend fun uploadFileWithProgress( file: File, outputStream: BufferedOutputStream, totalBytesWritten: Long, totalSize: Long, emit: suspend (IONFLTRTransferResult) -> Unit - ): Long = withContext(Dispatchers.IO) { + ): Long { var currentTotalBytes = totalBytesWritten FileInputStream(file).use { fileInputStream -> BufferedInputStream(fileInputStream).use { inputStream -> @@ -223,7 +221,7 @@ class IONFLTRController internal constructor( } } } - currentTotalBytes + return currentTotalBytes } /** @@ -352,7 +350,7 @@ class IONFLTRController internal constructor( emit(createUploadFileProgress(bytes = totalBytesWritten, total = totalSize)) // Write file content - totalBytesWritten = writeFileWithProgress( + totalBytesWritten = uploadFileWithProgress( file = file, outputStream = outputStream, totalBytesWritten = totalBytesWritten, @@ -383,7 +381,7 @@ class IONFLTRController internal constructor( connection.outputStream.use { connOutputStream -> BufferedOutputStream(connOutputStream).use { outputStream -> // Direct upload (not multipart) - totalBytesWritten = writeFileWithProgress( + totalBytesWritten = uploadFileWithProgress( file = file, outputStream = outputStream, totalBytesWritten = 0, From bc4c9c24c68a5e40086d79a2e343849eaa711a8a Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro <pedro.gustavo.bilro@outsystems.com> Date: Fri, 4 Apr 2025 15:47:23 +0100 Subject: [PATCH 08/10] fix: Support gzip encoding PR comment: https://github.com/ionic-team/ion-android-filetransfer/pull/1#discussion_r2027485696 --- .../io/ionic/libs/ionfiletransferlib/IONFLTRController.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt index 615a9f4..92b0d4d 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -167,8 +167,10 @@ class IONFLTRController internal constructor( BufferedOutputStream(fileOut).use { outputStream -> val buffer = ByteArray(BUFFER_SIZE) var bytesRead: Int + val lengthComputable = connection.contentEncoding.let { + it == null || it.equals("gzip", ignoreCase = true) + } && contentLength > 0 var totalBytesRead: Long = 0 - val lengthComputable = contentLength > 0 while (inputStream.read(buffer).also { bytesRead = it } != -1) { outputStream.write(buffer, 0, bytesRead) @@ -270,6 +272,9 @@ class IONFLTRController internal constructor( } } + // gzip to allow for better progress tracking + connection.setRequestProperty("Accept-Encoding", "gzip") + if (useChunkedMode) { connection.setChunkedStreamingMode(BUFFER_SIZE) connection.setRequestProperty("Transfer-Encoding", "chunked") From 0b0908ebba27f155f97793bebe8a1ede939c26e5 Mon Sep 17 00:00:00 2001 From: OS-pedrogustavobilro <pedro.gustavo.bilro@outsystems.com> Date: Fri, 4 Apr 2025 16:14:04 +0100 Subject: [PATCH 09/10] fix: support content:// URIs in upload PR comment: https://github.com/ionic-team/ion-android-filetransfer/pull/1#discussion_r2026538451 --- .../ionfiletransferlib/IONFLTRController.kt | 35 ++++---- .../helpers/IONFLTRFileHelper.kt | 87 ++++++++++++++++++- 2 files changed, 100 insertions(+), 22 deletions(-) diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt index 92b0d4d..fa24a9a 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -1,5 +1,7 @@ package io.ionic.libs.ionfiletransferlib +import android.content.Context +import io.ionic.libs.ionfiletransferlib.helpers.FileToUploadInfo import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRConnectionHelper import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRFileHelper import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRInputsValidator @@ -17,7 +19,6 @@ import kotlinx.coroutines.flow.flowOn import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File -import java.io.FileInputStream import java.io.FileOutputStream import java.net.HttpURLConnection @@ -31,9 +32,9 @@ class IONFLTRController internal constructor( private val fileHelper: IONFLTRFileHelper, private val connectionHelper: IONFLTRConnectionHelper ) { - constructor() : this( + constructor(context: Context) : this( inputsValidator = IONFLTRInputsValidator(), - fileHelper = IONFLTRFileHelper(), + fileHelper = IONFLTRFileHelper(contentResolver = context.contentResolver), connectionHelper = IONFLTRConnectionHelper() ) @@ -96,7 +97,7 @@ class IONFLTRController internal constructor( val (file, connection) = prepareForUpload(options) try { - val useChunkedMode = options.chunkedMode || file.length() == -1L + val useChunkedMode = options.chunkedMode || file.size == -1L // Configure connection based on upload mode val multiPartFormData = @@ -202,16 +203,15 @@ class IONFLTRController internal constructor( * @param totalSize The total size to report in progress updates * @param emit Function to emit progress updates */ - @Suppress("BlockingMethodInNonBlockingContext") // method will always be called from IO scope private suspend fun uploadFileWithProgress( - file: File, + file: FileToUploadInfo, outputStream: BufferedOutputStream, totalBytesWritten: Long, totalSize: Long, emit: suspend (IONFLTRTransferResult) -> Unit ): Long { var currentTotalBytes = totalBytesWritten - FileInputStream(file).use { fileInputStream -> + file.inputStream.use { fileInputStream -> BufferedInputStream(fileInputStream).use { inputStream -> val buffer = ByteArray(BUFFER_SIZE) var bytesRead: Int @@ -229,15 +229,12 @@ class IONFLTRController internal constructor( /** * Prepares for upload by validating inputs and setting up connection. */ - private fun prepareForUpload(options: IONFLTRUploadOptions): Pair<File, HttpURLConnection> { + private fun prepareForUpload(options: IONFLTRUploadOptions): Pair<FileToUploadInfo, HttpURLConnection> { // Validate inputs inputsValidator.validateTransferInputs(options.url, options.filePath) // Check if file exists - val file = File(options.filePath) - if (!file.exists()) { - throw IONFLTRException.FileDoesNotExist() - } + val file = fileHelper.getFileToUploadInfo(options.filePath) // Setup connection val connection = connectionHelper.setupConnection(options.url, options.httpOptions) @@ -253,7 +250,7 @@ class IONFLTRController internal constructor( private fun configureConnectionForUpload( connection: HttpURLConnection, options: IONFLTRUploadOptions, - file: File, + file: FileToUploadInfo, useChunkedMode: Boolean ): Pair<String, String>? { var multiPartUpload = false @@ -279,12 +276,12 @@ class IONFLTRController internal constructor( connection.setChunkedStreamingMode(BUFFER_SIZE) connection.setRequestProperty("Transfer-Encoding", "chunked") } else if (!multiPartUpload) { - connection.setFixedLengthStreamingMode(file.length()) + connection.setFixedLengthStreamingMode(file.size) } else { val multipartData = createMultipartData(options, file.name) // Calculate total size including multipart overhead val multipartByteArray = (multipartData.first + multipartData.second).toByteArray() - connection.setFixedLengthStreamingMode(file.length() + multipartByteArray.size) + connection.setFixedLengthStreamingMode(file.size + multipartByteArray.size) return multipartData } @@ -336,7 +333,7 @@ class IONFLTRController internal constructor( private suspend fun handleMultipartUpload( connection: HttpURLConnection, multipartExtraData: Pair<String, String>, - file: File, + file: FileToUploadInfo, emit: suspend (IONFLTRTransferResult) -> Unit ): Long { var totalBytesWritten: Long = 0 @@ -347,7 +344,7 @@ class IONFLTRController internal constructor( val afterDataByteArray = multipartExtraData.second.toByteArray() // Actual total size includes file size plus multipart overhead - val totalSize = file.length() + beforeDataByteArray.size + afterDataByteArray.size + val totalSize = file.size + beforeDataByteArray.size + afterDataByteArray.size // write multipart form content before file totalBytesWritten += beforeDataByteArray.size @@ -378,7 +375,7 @@ class IONFLTRController internal constructor( */ private suspend fun handleDirectUpload( connection: HttpURLConnection, - file: File, + file: FileToUploadInfo, emit: suspend (IONFLTRTransferResult) -> Unit ): Long { var totalBytesWritten: Long @@ -390,7 +387,7 @@ class IONFLTRController internal constructor( file = file, outputStream = outputStream, totalBytesWritten = 0, - totalSize = file.length(), + totalSize = file.size, emit = { emit(it) } ) } diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt index 8945515..9f35ce9 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt @@ -1,10 +1,48 @@ package io.ionic.libs.ionfiletransferlib.helpers +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns import android.webkit.MimeTypeMap import io.ionic.libs.ionfiletransferlib.model.IONFLTRException import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import androidx.core.net.toUri + +internal class IONFLTRFileHelper(val contentResolver: ContentResolver) { + /** + * Gets relevant data for file transfer (namely, upload) based on the provided file path + * + * @param filePath the path or uri to the file + * @return a [FileToUploadInfo] object + */ + fun getFileToUploadInfo(filePath: String): FileToUploadInfo { + return if (filePath.startsWith("content://")) { + val uri = filePath.toUri() + val cursor = + contentResolver.query(uri, null, null, null, null)!! + cursor.use { + val fileName = getNameForContentUri(cursor) + val fileSize = getSizeForContentUri(cursor, uri) + val inputStream = contentResolver.openInputStream(uri) + ?: throw IONFLTRException.FileDoesNotExist() + FileToUploadInfo(fileName, fileSize, inputStream) + } + } else { + val filePathWithoutPrefix = filePath.removePrefix("file://") + val fileObject = File(filePathWithoutPrefix) + if (!fileObject.exists()) { + throw IONFLTRException.FileDoesNotExist() + } + FileToUploadInfo(fileObject.name, fileObject.length(), FileInputStream(fileObject)) + } + } + -internal class IONFLTRFileHelper { /** * Gets a MIME type based on the provided file path * @@ -15,7 +53,7 @@ internal class IONFLTRFileHelper { MimeTypeMap.getFileExtensionFromUrl(filePath)?.let { extension -> MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) } - + /** * Creates parent directories for a file if they don't exist * @@ -32,4 +70,47 @@ internal class IONFLTRFileHelper { } } } -} \ No newline at end of file + + /** + * Gets the size of the that the content uri is pointing to. + * + * Will try to open the file and get its size if the android [Cursor] does not have the necessary column. + * + * @param cursor the android [Cursor] containing information about the uri + * @param uri the content uri of the file, to try to open the file as a fallback if the cursor has no information + * @return the size of the file, or 0 if it cannot be retrieved; throws exceptions in case file cannot be opened + */ + private fun getSizeForContentUri(cursor: Cursor, uri: Uri): Long = + cursor.getColumnIndex(OpenableColumns.SIZE).let { index -> + if (index >= 0) { + cursor.getString(index).toLongOrNull() + } else { + null + } + } ?: contentResolver.openAssetFileDescriptor(uri, "r")?.use { + it.length + } ?: 0L + + /** + * Gets the name of a file in content uri + * + * @param cursor the android [Cursor] containing information about the uri + * @return the name of the file, or exception if not found + */ + private fun getNameForContentUri(cursor: Cursor): String { + val columnIndex = cursor.getColumnIndexForNames( + columnNames = listOf( + OpenableColumns.DISPLAY_NAME, + MediaStore.MediaColumns.DISPLAY_NAME, + DocumentsContract.Document.COLUMN_DISPLAY_NAME + ) + ) + return columnIndex?.let { cursor.getString(columnIndex) }!! + } + + private fun Cursor.getColumnIndexForNames( + columnNames: List<String> + ): Int? = columnNames.firstNotNullOfOrNull { getColumnIndex(it).takeIf { index -> index >= 0 } } +} + +internal data class FileToUploadInfo(val name: String, val size: Long, val inputStream: InputStream) \ No newline at end of file From b31714ee170fac84ebb9d78e64b5b5f0405a9772 Mon Sep 17 00:00:00 2001 From: Chace Daniels <chace.daniels@outsystems.com> Date: Mon, 21 Apr 2025 17:45:26 -0500 Subject: [PATCH 10/10] refactor: cleanup connection code and forced assertions --- .../ionfiletransferlib/IONFLTRController.kt | 117 ++++++++++-------- .../helpers/IONFLTRConnectionHelper.kt | 38 ++++++ .../helpers/IONFLTRFileHelper.kt | 11 +- .../helpers/IONFLTRInputsValidator.kt | 4 +- .../model/IONFLTRException.kt | 4 +- 5 files changed, 111 insertions(+), 63 deletions(-) diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt index fa24a9a..33c8ad3 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -5,7 +5,9 @@ import io.ionic.libs.ionfiletransferlib.helpers.FileToUploadInfo import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRConnectionHelper import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRFileHelper import io.ionic.libs.ionfiletransferlib.helpers.IONFLTRInputsValidator +import io.ionic.libs.ionfiletransferlib.helpers.assertSuccessHttpResponse import io.ionic.libs.ionfiletransferlib.helpers.runCatchingIONFLTRExceptions +import io.ionic.libs.ionfiletransferlib.helpers.use import io.ionic.libs.ionfiletransferlib.model.IONFLTRDownloadOptions import io.ionic.libs.ionfiletransferlib.model.IONFLTRException import io.ionic.libs.ionfiletransferlib.model.IONFLTRProgressStatus @@ -56,13 +58,13 @@ class IONFLTRController internal constructor( // Prepare for download val (targetFile, connection) = prepareForDownload(options) - try { + connection.use { conn -> // Execute the download and handle response - val contentLength = beginDownload(connection) + val contentLength = beginDownload(conn) // Perform the actual file download with progress reporting val totalBytesRead = downloadFileWithProgress( - connection = connection, + connection = conn, targetFile = targetFile, contentLength = contentLength, emit = { emit(it) } @@ -73,14 +75,12 @@ class IONFLTRController internal constructor( IONFLTRTransferResult.Complete( IONFLTRTransferComplete( totalBytes = totalBytesRead, - responseCode = connection.responseCode.toString(), + responseCode = conn.responseCode.toString(), responseBody = null, - headers = connection.headerFields + headers = conn.headerFields ) ) ) - } finally { - connection.disconnect() } }.getOrThrow() }.flowOn(Dispatchers.IO) @@ -96,27 +96,19 @@ class IONFLTRController internal constructor( // Prepare for upload val (file, connection) = prepareForUpload(options) - try { - val useChunkedMode = options.chunkedMode || file.size == -1L - - // Configure connection based on upload mode - val multiPartFormData = - configureConnectionForUpload(connection, options, file, useChunkedMode) - - connection.doOutput = true - connection.connect() + connection.use { conn -> + // Execute the upload and handle response + val multiPartFormData = beginUpload(conn, options, file) // Perform the upload val totalBytesWritten: Long = if (multiPartFormData != null) { - handleMultipartUpload(connection, multiPartFormData, file, emit = { emit(it) }) + handleMultipartUpload(conn, multiPartFormData, file, emit = { emit(it) }) } else { - handleDirectUpload(connection, file, emit = { emit(it) }) + handleDirectUpload(conn, file, emit = { emit(it) }) } // Process the response - processUploadResponse(connection, totalBytesWritten, emit = { emit(it) }) - } finally { - connection.disconnect() + processUploadResponse(conn, totalBytesWritten, emit = { emit(it) }) } }.getOrThrow() }.flowOn(Dispatchers.IO) @@ -155,6 +147,30 @@ class IONFLTRController internal constructor( return contentLength } + /** + * Begins the upload process by configuring the connection and connecting. + * + * @param connection The HTTP connection to configure + * @param options The upload options + * @param file Information about the file to upload + * @return multi-part form data to append to beginning and end if needed, null otherwise + */ + private fun beginUpload( + connection: HttpURLConnection, + options: IONFLTRUploadOptions, + file: FileToUploadInfo + ): Pair<String, String>? { + val useChunkedMode = options.chunkedMode || file.size == -1L + + // Configure connection based on upload mode + val multiPartFormData = configureConnectionForUpload(connection, options, file, useChunkedMode) + + connection.doOutput = true + connection.connect() + + return multiPartFormData + } + /** * Downloads the file content with progress reporting. */ @@ -211,16 +227,14 @@ class IONFLTRController internal constructor( emit: suspend (IONFLTRTransferResult) -> Unit ): Long { var currentTotalBytes = totalBytesWritten - file.inputStream.use { fileInputStream -> - BufferedInputStream(fileInputStream).use { inputStream -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - currentTotalBytes += bytesRead - emit(createUploadFileProgress(bytes = currentTotalBytes, total = totalSize)) - } + file.inputStream.buffered().use { inputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + currentTotalBytes += bytesRead + emit(createUploadFileProgress(bytes = currentTotalBytes, total = totalSize)) } } return currentTotalBytes @@ -245,7 +259,7 @@ class IONFLTRController internal constructor( /** * Configures the connection for upload based on the upload mode. * - * @return multi-part form data to append to beggining and end + * @return multi-part form data to append to beginning and end */ private fun configureConnectionForUpload( connection: HttpURLConnection, @@ -351,14 +365,16 @@ class IONFLTRController internal constructor( outputStream.write(beforeDataByteArray) emit(createUploadFileProgress(bytes = totalBytesWritten, total = totalSize)) - // Write file content - totalBytesWritten = uploadFileWithProgress( - file = file, - outputStream = outputStream, - totalBytesWritten = totalBytesWritten, - totalSize = totalSize, - emit = { emit(it) } - ) + // Write file content (skip reading the file if it's empty) + if (file.size > 0) { + totalBytesWritten = uploadFileWithProgress( + file = file, + outputStream = outputStream, + totalBytesWritten = totalBytesWritten, + totalSize = totalSize, + emit = { emit(it) } + ) + } // write multipart form content after file outputStream.write(afterDataByteArray) @@ -378,6 +394,12 @@ class IONFLTRController internal constructor( file: FileToUploadInfo, emit: suspend (IONFLTRTransferResult) -> Unit ): Long { + if (file.size == 0L) { + // For empty files, still emit a progress event showing 0 bytes + emit(createUploadFileProgress(bytes = 0, total = 0)) + return 0L + } + var totalBytesWritten: Long connection.outputStream.use { connOutputStream -> @@ -429,19 +451,6 @@ class IONFLTRController internal constructor( ) } - private fun HttpURLConnection.assertSuccessHttpResponse() { - if (responseCode in 200..299) { - return // successful response - } - errorStream?.bufferedReader()?.readText()?.also { - throw IONFLTRException.HttpError( - responseCode.toString(), - it, - headerFields - ) - } - } - /** * Checks if the HTTP method is either POST or PUT. * @@ -451,4 +460,4 @@ class IONFLTRController internal constructor( private fun isPostOrPutMethod(method: String): Boolean { return method.equals("POST", ignoreCase = true) || method.equals("PUT", ignoreCase = true) } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRConnectionHelper.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRConnectionHelper.kt index 63c5cf5..03ed379 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRConnectionHelper.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRConnectionHelper.kt @@ -1,10 +1,48 @@ package io.ionic.libs.ionfiletransferlib.helpers +import io.ionic.libs.ionfiletransferlib.model.IONFLTRException import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferHttpOptions import java.net.HttpURLConnection import java.net.URL import java.nio.charset.StandardCharsets +/** + * Extension function to use HttpURLConnection with the Kotlin use pattern + * because HttpURLConnection doesn't implement Closeable. + * + * @param block The function to execute on the connection before closing it + * @return The result of the block function + */ +inline fun <R> HttpURLConnection.use(block: (HttpURLConnection) -> R): R { + try { + return block(this) + } finally { + this.disconnect() + } +} + + +/** + * Extension function to assert that an HTTP response was successful (2xx status code). + * If the response was not successful, throws an IONFLTRException.HttpError with details + * from the error stream. + * + * @throws IONFLTRException.HttpError if the response code is not in the 200-299 range + */ + +fun HttpURLConnection.assertSuccessHttpResponse() { + if (responseCode in 200..299) { + return // successful response + } + errorStream?.bufferedReader()?.readText()?.also { + throw IONFLTRException.HttpError( + responseCode.toString(), + it, + headerFields + ) + } +} + /** * Helper class for setting up HTTP connections with proper configuration. */ diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt index 9f35ce9..455f972 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt @@ -23,10 +23,11 @@ internal class IONFLTRFileHelper(val contentResolver: ContentResolver) { fun getFileToUploadInfo(filePath: String): FileToUploadInfo { return if (filePath.startsWith("content://")) { val uri = filePath.toUri() - val cursor = - contentResolver.query(uri, null, null, null, null)!! + val cursor = contentResolver.query(uri, null, null, null, null) + ?: throw IONFLTRException.FileDoesNotExist() cursor.use { val fileName = getNameForContentUri(cursor) + ?: throw IONFLTRException.FileDoesNotExist() val fileSize = getSizeForContentUri(cursor, uri) val inputStream = contentResolver.openInputStream(uri) ?: throw IONFLTRException.FileDoesNotExist() @@ -95,9 +96,9 @@ internal class IONFLTRFileHelper(val contentResolver: ContentResolver) { * Gets the name of a file in content uri * * @param cursor the android [Cursor] containing information about the uri - * @return the name of the file, or exception if not found + * @return the name of the file, or null if no display name column was found */ - private fun getNameForContentUri(cursor: Cursor): String { + private fun getNameForContentUri(cursor: Cursor): String? { val columnIndex = cursor.getColumnIndexForNames( columnNames = listOf( OpenableColumns.DISPLAY_NAME, @@ -105,7 +106,7 @@ internal class IONFLTRFileHelper(val contentResolver: ContentResolver) { DocumentsContract.Document.COLUMN_DISPLAY_NAME ) ) - return columnIndex?.let { cursor.getString(columnIndex) }!! + return columnIndex?.let { cursor.getString(columnIndex) } } private fun Cursor.getColumnIndexForNames( diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt index dd5b89a..e7798ef 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt @@ -25,7 +25,7 @@ internal class IONFLTRInputsValidator { * @param path The file path to check * @return true if path is valid, false otherwise */ - fun isPathValid(path: String?): Boolean { + private fun isPathValid(path: String?): Boolean { return !path.isNullOrBlank() } @@ -34,7 +34,7 @@ internal class IONFLTRInputsValidator { * @param url The URL to check * @return true if URL is valid, false otherwise */ - fun isURLValid(url: String): Boolean { + private fun isURLValid(url: String): Boolean { val pattern = Pattern.compile("http[s]?://(([^/:.[:space:]]+(.[^/:.[:space:]]+)*)|([0-9](.[0-9]{3})))(:[0-9]+)?((/[^?#[:space:]]+)([^#[:space:]]+)?(#.+)?)?") return pattern.matcher(url).find() diff --git a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt index 54baf1b..57f2990 100644 --- a/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRException.kt @@ -13,10 +13,10 @@ sealed class IONFLTRException( IONFLTRException("The provided path is either null or empty.") class EmptyURL(val url: String?) : - IONFLTRException("The provided url is either null or empty.") + IONFLTRException("The provided URL is either null or empty.") class InvalidURL(val url: String) : - IONFLTRException("The provided url is not valid.") + IONFLTRException("The provided URL is not valid.") class FileDoesNotExist(override val cause: Throwable? = null) : IONFLTRException("The specified file does not exist", cause)