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 423ed4a..0000000 Binary files a/docs/assets/createRepositoryButton.png and /dev/null differ diff --git a/docs/assets/repositoryNameExample.png b/docs/assets/repositoryNameExample.png deleted file mode 100644 index a381131..0000000 Binary files a/docs/assets/repositoryNameExample.png and /dev/null differ diff --git a/docs/assets/useThisTemplateButton.png b/docs/assets/useThisTemplateButton.png deleted file mode 100644 index 22dab19..0000000 Binary files a/docs/assets/useThisTemplateButton.png and /dev/null differ 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..33c8ad3 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/IONFLTRController.kt @@ -0,0 +1,463 @@ +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 +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 +import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferComplete +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 java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection + +/** + * 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, + private val connectionHelper: IONFLTRConnectionHelper +) { + constructor(context: Context) : this( + inputsValidator = IONFLTRInputsValidator(), + fileHelper = IONFLTRFileHelper(contentResolver = context.contentResolver), + connectionHelper = IONFLTRConnectionHelper() + ) + + companion object { + private const val BUFFER_SIZE = 8192 // 8KB buffer size + private const val BOUNDARY = "++++IONFLTRBoundary" + private const val LINE_START = "--" + private const val LINE_END = "\r\n" + } + + /** + * 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) + + connection.use { conn -> + // Execute the download and handle response + val contentLength = beginDownload(conn) + + // Perform the actual file download with progress reporting + val totalBytesRead = downloadFileWithProgress( + connection = conn, + targetFile = targetFile, + contentLength = contentLength, + emit = { emit(it) } + ) + + // Emit completion + emit( + IONFLTRTransferResult.Complete( + IONFLTRTransferComplete( + totalBytes = totalBytesRead, + responseCode = conn.responseCode.toString(), + responseBody = null, + headers = conn.headerFields + ) + ) + ) + } + }.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) + + 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(conn, multiPartFormData, file, emit = { emit(it) }) + } else { + handleDirectUpload(conn, file, emit = { emit(it) }) + } + + // Process the response + processUploadResponse(conn, totalBytesWritten, emit = { emit(it) }) + } + }.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 + inputsValidator.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 the content length associated with the download request + */ + private fun beginDownload(connection: HttpURLConnection): Long { + connection.connect() + + // Check response code + connection.assertSuccessHttpResponse() + + // Get content length if available + val contentLength = connection.contentLength.toLong() + + 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. + */ + private suspend fun downloadFileWithProgress( + connection: HttpURLConnection, + targetFile: File, + contentLength: Long, + emit: suspend (IONFLTRTransferResult) -> Unit + ): Long = BufferedInputStream(connection.inputStream).use { inputStream -> + FileOutputStream(targetFile).use { fileOut -> + 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 + + 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 + } + } + } + + /** + * 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 uploadFileWithProgress( + file: FileToUploadInfo, + outputStream: BufferedOutputStream, + totalBytesWritten: Long, + totalSize: Long, + emit: suspend (IONFLTRTransferResult) -> Unit + ): Long { + var currentTotalBytes = totalBytesWritten + 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 + } + + /** + * Prepares for upload by validating inputs and setting up connection. + */ + private fun prepareForUpload(options: IONFLTRUploadOptions): Pair<FileToUploadInfo, HttpURLConnection> { + // Validate inputs + inputsValidator.validateTransferInputs(options.url, options.filePath) + + // Check if file exists + val file = fileHelper.getFileToUploadInfo(options.filePath) + + // 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 beginning and end + */ + private fun configureConnectionForUpload( + connection: HttpURLConnection, + options: IONFLTRUploadOptions, + file: FileToUploadInfo, + useChunkedMode: Boolean + ): 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" + if (isPostOrPutMethod(options.httpOptions.method)) { + multiPartUpload = true + connection.setRequestProperty( + "Content-Type", + "multipart/form-data; boundary=$BOUNDARY" + ) + } else { + connection.setRequestProperty("Content-Type", mimeType) + } + } + + // gzip to allow for better progress tracking + connection.setRequestProperty("Accept-Encoding", "gzip") + + if (useChunkedMode) { + connection.setChunkedStreamingMode(BUFFER_SIZE) + connection.setRequestProperty("Transfer-Encoding", "chunked") + } else if (!multiPartUpload) { + 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.size + multipartByteArray.size) + return multipartData + } + + return null + } + + /** + * 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 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, + multipartExtraData: Pair<String, String>, + file: FileToUploadInfo, + emit: suspend (IONFLTRTransferResult) -> Unit + ): Long { + var totalBytesWritten: Long = 0 + + connection.outputStream.use { connOutputStream -> + BufferedOutputStream(connOutputStream).use { outputStream -> + val beforeDataByteArray = multipartExtraData.first.toByteArray() + val afterDataByteArray = multipartExtraData.second.toByteArray() + + // Actual total size includes file size plus multipart overhead + val totalSize = file.size + 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 (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) + totalBytesWritten += afterDataByteArray.size + emit(createUploadFileProgress(bytes = totalBytesWritten, total = totalSize)) + } + } + + return totalBytesWritten + } + + /** + * Handles direct (non-multipart) file uploads. + */ + private suspend fun handleDirectUpload( + connection: HttpURLConnection, + 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 -> + BufferedOutputStream(connOutputStream).use { outputStream -> + // Direct upload (not multipart) + totalBytesWritten = uploadFileWithProgress( + file = file, + outputStream = outputStream, + totalBytesWritten = 0, + totalSize = file.size, + emit = { emit(it) } + ) + } + } + + 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, + totalBytesWritten: Long, + emit: suspend (IONFLTRTransferResult) -> Unit + ) { + // Check response + connection.assertSuccessHttpResponse() + val responseCode = connection.responseCode + val responseBody = connection.inputStream.bufferedReader().readText() + + // Return success + emit( + IONFLTRTransferResult.Complete( + IONFLTRTransferComplete( + totalBytes = totalBytesWritten, + responseCode = responseCode.toString(), + responseBody = responseBody, + headers = connection.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 + */ + 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/IONFLTRConnectionHelper.kt b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRConnectionHelper.kt new file mode 100644 index 0000000..03ed379 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRConnectionHelper.kt @@ -0,0 +1,108 @@ +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. + */ +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 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..455f972 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRFileHelper.kt @@ -0,0 +1,117 @@ +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) + ?: 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() + 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)) + } + } + + + /** + * 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) + } + } + } + + /** + * 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 null if no display name column was 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 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..e7798ef --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/helpers/IONFLTRInputsValidator.kt @@ -0,0 +1,42 @@ +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 + * @return true if path is valid, false otherwise + */ + private 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 + */ + 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() + } +} \ 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..57f2990 --- /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, List<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..e5e1fd0 --- /dev/null +++ b/src/main/kotlin/io/ionic/libs/ionfiletransferlib/model/IONFLTRTransferOptions.kt @@ -0,0 +1,60 @@ +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 formParams Additional form parameters to include in multipart/form-data uploads + * @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 formParams: Map<String, String>? = null, + 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..1fd7fb0 --- /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, List<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 c209e78..0000000 Binary files a/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ 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 b2dfe3d..0000000 Binary files a/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ 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 4f0f1d6..0000000 Binary files a/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ 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 62b611d..0000000 Binary files a/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ 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 948a307..0000000 Binary files a/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ 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 1b9a695..0000000 Binary files a/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ 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 28d4b77..0000000 Binary files a/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ 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 9287f50..0000000 Binary files a/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ 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 aa7d642..0000000 Binary files a/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ 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 9126ae3..0000000 Binary files a/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ 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