diff --git a/.github/workflows/on_push_workflow.yml b/.github/workflows/on_push_workflow.yml
index 53e5b556..55433193 100644
--- a/.github/workflows/on_push_workflow.yml
+++ b/.github/workflows/on_push_workflow.yml
@@ -39,11 +39,19 @@ jobs:
storage-core-remote:
needs: storage-core-local
name: Publish Core Package to Sonatype
+ secrets: inherit
+ strategy:
+ matrix:
+ package-name:
+ [
+ "core",
+ "core-restful-common"
+ ]
uses: ./.github/workflows/build_and_publish_to_maven_remotely.yml
with:
- package-name: :packages:core
+ package-name: :packages:${{ matrix.package-name }}
destination-repository: snapshot
- secrets: inherit
+
# Build and publish snapshots of all plugins to Sonatype
# this workflow uses local projects
@@ -58,7 +66,9 @@ jobs:
"plugin-googledrive-gms",
"plugin-googledrive-non-gms",
"plugin-onedrive",
+ "plugin-onedrive-restful",
"plugin-dropbox",
+ "plugin-dropbox-restful"
]
uses: ./.github/workflows/build_and_publish_to_maven_remotely.yml
with:
diff --git a/.github/workflows/publish_core.yml b/.github/workflows/publish_core.yml
index dc38b7b5..06f30af7 100644
--- a/.github/workflows/publish_core.yml
+++ b/.github/workflows/publish_core.yml
@@ -15,7 +15,15 @@ jobs:
publish:
name: Publish Core Package
uses: ./.github/workflows/build_and_publish_to_maven_remotely.yml
+ secrets: inherit
+ strategy:
+ matrix:
+ package-name:
+ [
+ "core",
+ "core-restful-common"
+ ]
with:
- package-name: :packages:core
+ package-name: :packages:${{ matrix.package-name }}
destination-repository: ${{ github.event.inputs.destination-repository }}
- secrets: inherit
+
diff --git a/.github/workflows/publish_plugin_dropbox_restful.yml b/.github/workflows/publish_plugin_dropbox_restful.yml
new file mode 100644
index 00000000..954ad063
--- /dev/null
+++ b/.github/workflows/publish_plugin_dropbox_restful.yml
@@ -0,0 +1,21 @@
+name: Publish Dropbox Plugin
+
+on:
+ workflow_dispatch:
+ inputs:
+ destination-repository:
+ type: choice
+ required: true
+ description: Destination repository
+ options:
+ - snapshot
+ - release
+
+jobs:
+ publish:
+ name: Publish Dropbox restful Plugin
+ uses: ./.github/workflows/build_and_publish_to_maven_remotely.yml
+ with:
+ package-name: :packages:plugin-dropbox-restful
+ destination-repository: ${{ github.event.inputs.destination-repository }}
+ secrets: inherit
diff --git a/.github/workflows/publish_plugin_onedrive_restful.yml b/.github/workflows/publish_plugin_onedrive_restful.yml
new file mode 100644
index 00000000..57d179d3
--- /dev/null
+++ b/.github/workflows/publish_plugin_onedrive_restful.yml
@@ -0,0 +1,21 @@
+name: Publish Onedrive Plugin
+
+on:
+ workflow_dispatch:
+ inputs:
+ destination-repository:
+ type: choice
+ required: true
+ description: Destination repository
+ options:
+ - snapshot
+ - release
+
+jobs:
+ publish:
+ name: Publish Onedrive Plugin
+ uses: ./.github/workflows/build_and_publish_to_maven_remotely.yml
+ with:
+ package-name: :packages:plugin-onedrive-restful
+ destination-repository: ${{ github.event.inputs.destination-repository }}
+ secrets: inherit
diff --git a/apps/storage-sample/build.gradle.kts b/apps/storage-sample/build.gradle.kts
index 9b8e6ccc..0a9eba29 100644
--- a/apps/storage-sample/build.gradle.kts
+++ b/apps/storage-sample/build.gradle.kts
@@ -140,16 +140,21 @@ dependencies {
// Use local implementation instead of dependencies
if (useLocalProjects) {
implementation(project(":packages:core"))
+ implementation(project(":packages:core-restful-common"))
implementation(project(":packages:plugin-googledrive-gms"))
implementation(project(":packages:plugin-googledrive-non-gms"))
implementation(project(":packages:plugin-onedrive"))
+ implementation(project(":packages:plugin-onedrive-restful"))
implementation(project(":packages:plugin-dropbox"))
+ implementation(project(":packages:plugin-dropbox-restful"))
} else {
implementation("com.openmobilehub.android.storage:core:2.0.6-alpha")
implementation("com.openmobilehub.android.storage:plugin-googledrive-gms:2.1.0-alpha")
implementation("com.openmobilehub.android.storage:plugin-googledrive-non-gms:2.1.0-alpha")
implementation("com.openmobilehub.android.storage:plugin-onedrive:2.1.0-alpha")
implementation("com.openmobilehub.android.storage:plugin-dropbox:2.1.0-alpha")
+ implementation("com.openmobilehub.android.storage:plugin-onedrive-restful:2.1.0-alpha")
+ implementation("com.openmobilehub.android.storage:plugin-dropbox-restful:2.1.0-alpha")
}
testImplementation(Libs.junit)
diff --git a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/di/AuthModule.kt b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/di/AuthModule.kt
index 0b587dc0..e601d100 100644
--- a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/di/AuthModule.kt
+++ b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/di/AuthModule.kt
@@ -46,8 +46,10 @@ class AuthModule {
): OmhAuthClient {
return when (sessionRepository.getStorageAuthProvider()) {
StorageAuthProvider.GOOGLE -> googleAuthClient.get()
- StorageAuthProvider.DROPBOX -> dropboxAuthClient.get()
- StorageAuthProvider.MICROSOFT -> microsoftAuthClient.get()
+ StorageAuthProvider.DROPBOX, StorageAuthProvider.DROPBOX_RESTFUL ->
+ dropboxAuthClient.get()
+ StorageAuthProvider.MICROSOFT, StorageAuthProvider.MICROSOFT_RESTFUL ->
+ microsoftAuthClient.get()
}
}
diff --git a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/di/StorageModule.kt b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/di/StorageModule.kt
index 530642ab..80de61ca 100644
--- a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/di/StorageModule.kt
+++ b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/di/StorageModule.kt
@@ -21,9 +21,11 @@ import com.openmobilehub.android.auth.core.OmhAuthClient
import com.openmobilehub.android.storage.core.OmhStorageClient
import com.openmobilehub.android.storage.core.OmhStorageProvider
import com.openmobilehub.android.storage.plugin.dropbox.DropboxOmhStorageFactory
+import com.openmobilehub.android.storage.plugin.dropbox.restful.DropboxRestfulOmhStorageClientFactory
import com.openmobilehub.android.storage.plugin.googledrive.gms.GoogleDriveGmsConstants
import com.openmobilehub.android.storage.plugin.googledrive.nongms.GoogleDriveNonGmsConstants
import com.openmobilehub.android.storage.plugin.onedrive.OneDriveOmhStorageFactory
+import com.openmobilehub.android.storage.plugin.onedrive.restful.OneDriveRestfulOmhStorageClientFactory
import com.openmobilehub.android.storage.sample.domain.model.StorageAuthProvider
import com.openmobilehub.android.storage.sample.domain.repository.SessionRepository
import dagger.Module
@@ -37,6 +39,7 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
+@Suppress("LongParameterList")
class StorageModule {
@Provides
@@ -44,12 +47,16 @@ class StorageModule {
@Named("google") googleStorageClient: Provider,
@Named("dropbox") dropboxStorageClient: Provider,
@Named("microsoft") microsoftStorageClient: Provider,
+ @Named("dropbox_restful") dropboxRestfulStorageClient: Provider,
+ @Named("onedrive_restful") microsoftRestfulStorageClient: Provider,
sessionRepository: SessionRepository
): OmhStorageClient {
return when (sessionRepository.getStorageAuthProvider()) {
StorageAuthProvider.GOOGLE -> googleStorageClient.get()
StorageAuthProvider.DROPBOX -> dropboxStorageClient.get()
StorageAuthProvider.MICROSOFT -> microsoftStorageClient.get()
+ StorageAuthProvider.DROPBOX_RESTFUL -> dropboxRestfulStorageClient.get()
+ StorageAuthProvider.MICROSOFT_RESTFUL -> microsoftRestfulStorageClient.get()
}
}
@@ -81,4 +88,17 @@ class StorageModule {
return OneDriveOmhStorageFactory().getStorageClient(omhAuthClient)
}
+ @Named("dropbox_restful")
+ @Provides
+ @Singleton
+ fun providesDropboxRestfulOmhStorageClient(omhAuthClient: OmhAuthClient): OmhStorageClient {
+ return DropboxRestfulOmhStorageClientFactory().getStorageClient(omhAuthClient)
+ }
+
+ @Named("onedrive_restful")
+ @Provides
+ @Singleton
+ fun providesOneDriveRestfulOmhStorageClient(omhAuthClient: OmhAuthClient): OmhStorageClient {
+ return OneDriveRestfulOmhStorageClientFactory().getStorageClient(omhAuthClient)
+ }
}
\ No newline at end of file
diff --git a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/domain/model/StorageAuthProvider.kt b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/domain/model/StorageAuthProvider.kt
index 4ebda14c..be3d4e5a 100644
--- a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/domain/model/StorageAuthProvider.kt
+++ b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/domain/model/StorageAuthProvider.kt
@@ -19,5 +19,7 @@ package com.openmobilehub.android.storage.sample.domain.model
enum class StorageAuthProvider {
GOOGLE,
DROPBOX,
- MICROSOFT
+ MICROSOFT,
+ DROPBOX_RESTFUL,
+ MICROSOFT_RESTFUL
}
\ No newline at end of file
diff --git a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/FileViewerViewModel.kt b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/FileViewerViewModel.kt
index 287de9fe..31098a53 100644
--- a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/FileViewerViewModel.kt
+++ b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/FileViewerViewModel.kt
@@ -115,15 +115,15 @@ class FileViewerViewModel @Inject constructor(
private val isPermanentlyDeleteSupported: Boolean =
when (storageAuthProvider) {
StorageAuthProvider.GOOGLE -> true
- StorageAuthProvider.DROPBOX -> false
- StorageAuthProvider.MICROSOFT -> false
+ StorageAuthProvider.DROPBOX, StorageAuthProvider.DROPBOX_RESTFUL -> false
+ StorageAuthProvider.MICROSOFT, StorageAuthProvider.MICROSOFT_RESTFUL -> false
}
private val isFolderUpdateSupported: Boolean =
when (storageAuthProvider) {
StorageAuthProvider.GOOGLE -> true
- StorageAuthProvider.DROPBOX -> false
- StorageAuthProvider.MICROSOFT -> false
+ StorageAuthProvider.DROPBOX, StorageAuthProvider.DROPBOX_RESTFUL -> false
+ StorageAuthProvider.MICROSOFT, StorageAuthProvider.MICROSOFT_RESTFUL -> false
}
init {
diff --git a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/metadata/FileMetadataDialog.kt b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/metadata/FileMetadataDialog.kt
index d14dcee1..37aa3985 100644
--- a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/metadata/FileMetadataDialog.kt
+++ b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/metadata/FileMetadataDialog.kt
@@ -104,37 +104,37 @@ class FileMetadataDialog : BottomSheetDialogFragment() {
header.fileName.text = getString(R.string.file_name, file.name)
- fileId.label.text = getString(R.string.file_id, file.id)
- fileCreatedTime.label.text =
+ fileIdLabel.text = getString(R.string.file_id, file.id)
+ fileCreatedTimeLabel.text =
getString(R.string.file_created_time, file.createdTime?.toRFC3339String())
- fileModifiedTime.label.text =
+ fileModifiedTimeLabel.text =
getString(R.string.file_modified_time, file.modifiedTime?.toRFC3339String())
- fileParentId.label.text = getString(R.string.file_parent_id, file.parentId)
- fileMimeType.label.text = getString(R.string.file_mime_type, mimeType)
- fileExtension.label.text = getString(R.string.file_extension, extension)
- fileSize.label.text = getString(R.string.file_size, size.toString())
+ fileParentIdLabel.text = getString(R.string.file_parent_id, file.parentId)
+ fileMimeTypeLabel.text = getString(R.string.file_mime_type, mimeType)
+ fileExtensionLabel.text = getString(R.string.file_extension, extension)
+ fileSizeLabel.text = getString(R.string.file_size, size.toString())
if (file.isFolder()) {
- fileMimeType.label.visibility = View.GONE
- fileExtension.label.visibility = View.GONE
- fileSize.label.visibility = View.GONE
+ fileMimeTypeLabel.visibility = View.GONE
+ fileExtensionLabel.visibility = View.GONE
+ fileSizeLabel.visibility = View.GONE
}
when (originalMetadata) {
is GoogleDriveFile -> { // Google Drive GMS
- extraMetadata.label.text = originalMetadata.toString()
+ extraMetadata.text = originalMetadata.toString()
}
is String -> { // Google Drive Non-GMS
- extraMetadata.label.text = originalMetadata
+ extraMetadata.text = originalMetadata
}
is DriveItem -> { // OneDrive
- extraMetadata.label.text = originalMetadata.serializeToString()
+ extraMetadata.text = originalMetadata.serializeToString()
}
is Metadata -> { // Dropbox
- extraMetadata.label.text = originalMetadata.toString()
+ extraMetadata.text = originalMetadata.toString()
}
}
}
@@ -161,4 +161,3 @@ class FileMetadataDialog : BottomSheetDialogFragment() {
}
}
}
-
diff --git a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/FilePermissionsViewModel.kt b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/FilePermissionsViewModel.kt
index fc616d6d..5edd4490 100644
--- a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/FilePermissionsViewModel.kt
+++ b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/FilePermissionsViewModel.kt
@@ -66,15 +66,15 @@ class FilePermissionsViewModel @Inject constructor(
private val isDeletingInheritedPermissionsSupported: Boolean =
when (storageAuthProvider) {
StorageAuthProvider.GOOGLE -> true
- StorageAuthProvider.DROPBOX -> true
- StorageAuthProvider.MICROSOFT -> false
+ StorageAuthProvider.DROPBOX, StorageAuthProvider.DROPBOX_RESTFUL -> true
+ StorageAuthProvider.MICROSOFT, StorageAuthProvider.MICROSOFT_RESTFUL -> false
}
@StringRes val permissionCaveats: Int? =
when (storageAuthProvider) {
StorageAuthProvider.GOOGLE -> null
- StorageAuthProvider.DROPBOX -> R.string.permission_caveats_dropbox
- StorageAuthProvider.MICROSOFT -> null
+ StorageAuthProvider.DROPBOX, StorageAuthProvider.DROPBOX_RESTFUL -> R.string.permission_caveats_dropbox
+ StorageAuthProvider.MICROSOFT, StorageAuthProvider.MICROSOFT_RESTFUL -> null
}
fun getPermissions(file: OmhStorageEntity) {
diff --git a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/create/CreatePermissionViewModel.kt b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/create/CreatePermissionViewModel.kt
index 5e6ae8b7..01fc29ac 100644
--- a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/create/CreatePermissionViewModel.kt
+++ b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/create/CreatePermissionViewModel.kt
@@ -48,11 +48,11 @@ class CreatePermissionViewModel @Inject constructor(
val roles = OmhPermissionRole.values().filter { it != OmhPermissionRole.OWNER }.toTypedArray()
var disabledRoles: Set = when (storageAuthProvider) {
StorageAuthProvider.GOOGLE -> emptySet()
- StorageAuthProvider.DROPBOX -> setOf(
+ StorageAuthProvider.DROPBOX, StorageAuthProvider.DROPBOX_RESTFUL -> setOf(
OmhPermissionRole.READER
)
- StorageAuthProvider.MICROSOFT -> setOf(
+ StorageAuthProvider.MICROSOFT, StorageAuthProvider.MICROSOFT_RESTFUL -> setOf(
OmhPermissionRole.COMMENTER
)
}
@@ -61,8 +61,10 @@ class CreatePermissionViewModel @Inject constructor(
MutableStateFlow(
when (storageAuthProvider) {
StorageAuthProvider.GOOGLE -> OmhPermissionRole.READER
- StorageAuthProvider.DROPBOX -> OmhPermissionRole.COMMENTER
- StorageAuthProvider.MICROSOFT -> OmhPermissionRole.READER
+ StorageAuthProvider.DROPBOX, StorageAuthProvider.DROPBOX_RESTFUL
+ -> OmhPermissionRole.COMMENTER
+ StorageAuthProvider.MICROSOFT, StorageAuthProvider.MICROSOFT_RESTFUL
+ -> OmhPermissionRole.READER
}
)
val role: StateFlow = _role
@@ -78,13 +80,13 @@ class CreatePermissionViewModel @Inject constructor(
val types = PermissionType.values()
val disabledTypes: Set = when (storageAuthProvider) {
StorageAuthProvider.GOOGLE -> emptySet()
- StorageAuthProvider.DROPBOX -> setOf(
+ StorageAuthProvider.DROPBOX, StorageAuthProvider.DROPBOX_RESTFUL -> setOf(
PermissionType.ANYONE,
PermissionType.DOMAIN,
PermissionType.GROUP,
)
- StorageAuthProvider.MICROSOFT -> setOf(
+ StorageAuthProvider.MICROSOFT, StorageAuthProvider.MICROSOFT_RESTFUL -> setOf(
PermissionType.ANYONE,
PermissionType.DOMAIN
)
diff --git a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/edit/EditPermissionViewModel.kt b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/edit/EditPermissionViewModel.kt
index f5cbecdb..c2e87468 100644
--- a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/edit/EditPermissionViewModel.kt
+++ b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/file_viewer/dialog/permissions/edit/EditPermissionViewModel.kt
@@ -36,16 +36,17 @@ class EditPermissionViewModel @Inject constructor(
val roles = OmhPermissionRole.values().filter { it != OmhPermissionRole.OWNER }.toTypedArray()
var role: OmhPermissionRole = when (storageAuthProvider) {
StorageAuthProvider.GOOGLE -> OmhPermissionRole.READER
- StorageAuthProvider.DROPBOX -> OmhPermissionRole.COMMENTER
- StorageAuthProvider.MICROSOFT -> OmhPermissionRole.READER
+ StorageAuthProvider.DROPBOX, StorageAuthProvider.DROPBOX_RESTFUL
+ -> OmhPermissionRole.COMMENTER
+ StorageAuthProvider.MICROSOFT, StorageAuthProvider.MICROSOFT_RESTFUL
+ -> OmhPermissionRole.READER
}
val disabledRoles: Set = when (storageAuthProvider) {
StorageAuthProvider.GOOGLE -> emptySet()
- StorageAuthProvider.DROPBOX -> setOf(
+ StorageAuthProvider.DROPBOX, StorageAuthProvider.DROPBOX_RESTFUL -> setOf(
OmhPermissionRole.READER
)
-
- StorageAuthProvider.MICROSOFT -> setOf(
+ StorageAuthProvider.MICROSOFT, StorageAuthProvider.MICROSOFT_RESTFUL -> setOf(
OmhPermissionRole.COMMENTER
)
}
diff --git a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/login/LoginFragment.kt b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/login/LoginFragment.kt
index 74c1ac43..85fbb570 100644
--- a/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/login/LoginFragment.kt
+++ b/apps/storage-sample/src/main/java/com/openmobilehub/android/storage/sample/presentation/login/LoginFragment.kt
@@ -67,9 +67,21 @@ class LoginFragment : BaseFragment initializeEvent()
- LoginViewEvent.LoginWithDropboxClicked -> loginClickedEvent(StorageAuthProvider.DROPBOX)
- LoginViewEvent.LoginWithGoogleClicked -> loginClickedEvent(StorageAuthProvider.GOOGLE)
- LoginViewEvent.LoginWithMicrosoftClicked -> loginClickedEvent(StorageAuthProvider.MICROSOFT)
+ LoginViewEvent.LoginWithDropboxClicked ->
+ loginClickedEvent(StorageAuthProvider.DROPBOX)
+ LoginViewEvent.LoginWithGoogleClicked ->
+ loginClickedEvent(StorageAuthProvider.GOOGLE)
+ LoginViewEvent.LoginWithMicrosoftClicked ->
+ loginClickedEvent(StorageAuthProvider.MICROSOFT)
+ LoginViewEvent.LoginWithDropboxRestfulClicked ->
+ loginClickedEvent(StorageAuthProvider.DROPBOX_RESTFUL)
+ LoginViewEvent.LoginWithMicrosoftRestfulClicked ->
+ loginClickedEvent(StorageAuthProvider.MICROSOFT_RESTFUL)
}
}
diff --git a/apps/storage-sample/src/main/res/layout/dialog_file_metadata.xml b/apps/storage-sample/src/main/res/layout/dialog_file_metadata.xml
index b021a1d7..a61c5af5 100644
--- a/apps/storage-sample/src/main/res/layout/dialog_file_metadata.xml
+++ b/apps/storage-sample/src/main/res/layout/dialog_file_metadata.xml
@@ -36,33 +36,54 @@
app:layout_constraintTop_toBottomOf="@+id/header"
app:layout_constraintVertical_bias="0.0">
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="24dp"
+ android:layout_marginBottom="24dp" />
diff --git a/apps/storage-sample/src/main/res/layout/fragment_login.xml b/apps/storage-sample/src/main/res/layout/fragment_login.xml
index 42354b38..12a0cd42 100644
--- a/apps/storage-sample/src/main/res/layout/fragment_login.xml
+++ b/apps/storage-sample/src/main/res/layout/fragment_login.xml
@@ -52,6 +52,13 @@
android:layout_marginBottom="16dp"
android:text="@string/login_dropbox" />
+
+
+
+
\ No newline at end of file
diff --git a/apps/storage-sample/src/main/res/values/strings.xml b/apps/storage-sample/src/main/res/values/strings.xml
index 4e088b3f..dc31578a 100644
--- a/apps/storage-sample/src/main/res/values/strings.xml
+++ b/apps/storage-sample/src/main/res/values/strings.xml
@@ -3,7 +3,9 @@
Login with Google
Login with Dropbox
+ Login with Dropbox + REST API
Login with Microsoft
+ Login with Microsoft + REST API
Login failed. Result code: %1$d, error message: %1$s
Login
diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt
index 04fba67a..3c3b9334 100644
--- a/buildSrc/src/main/kotlin/Dependencies.kt
+++ b/buildSrc/src/main/kotlin/Dependencies.kt
@@ -20,6 +20,9 @@ object Libs {
val okHttp by lazy { "com.squareup.okhttp3:okhttp:${Versions.okhttp}" }
val okHttpLoggingInterceptor by lazy { "com.squareup.okhttp3:logging-interceptor:${Versions.okhttp}" }
+ // Jackson (Kotlin module)
+ val jacksonKotlin by lazy { "com.fasterxml.jackson.module:jackson-module-kotlin:${Versions.jacksonKotlin}" }
+
// Coroutines
val coroutinesCore by lazy { "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}" }
val coroutinesAndroid by lazy { "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutines}" }
diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt
index 73f564a7..b5d98d93 100644
--- a/buildSrc/src/main/kotlin/Versions.kt
+++ b/buildSrc/src/main/kotlin/Versions.kt
@@ -68,6 +68,9 @@ object Versions {
// Json
const val json = "20240303"
+ // Jackson
+ const val jacksonKotlin = "2.15.0"
+
// Gson
const val httpClientGson = "1.44.1"
diff --git a/packages/core-restful-common/.gitignore b/packages/core-restful-common/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/packages/core-restful-common/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/packages/core-restful-common/build.gradle.kts b/packages/core-restful-common/build.gradle.kts
new file mode 100644
index 00000000..49b9a86d
--- /dev/null
+++ b/packages/core-restful-common/build.gradle.kts
@@ -0,0 +1,39 @@
+plugins {
+ `android-base-lib`
+}
+
+android {
+ namespace = "com.openmobilehub.android.storage.core.restful.common"
+
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+}
+
+val useLocalProjects = project.rootProject.extra["useLocalProjects"] as Boolean
+
+dependencies {
+ if (useLocalProjects) {
+ api(project(":packages:core"))
+ } else {
+ api("com.openmobilehub.android.storage:core:2.1.0-alpha")
+ }
+
+ // slf4j
+ implementation(Libs.slf4jApi)
+
+ // Retrofit setup
+ implementation(Libs.retrofit)
+ implementation(Libs.retrofitJacksonConverter)
+ implementation(Libs.okHttp)
+ implementation(Libs.okHttpLoggingInterceptor)
+ implementation(Libs.jacksonKotlin)
+
+ // Test dependencies
+ testImplementation(kotlin("test"))
+ testImplementation(Libs.junit)
+ testImplementation(Libs.mockk)
+ testImplementation(Libs.coroutineTesting)
+ testImplementation(Libs.json)
+ testImplementation(Libs.slf4jAndroid)
+}
\ No newline at end of file
diff --git a/packages/core-restful-common/consumer-rules.pro b/packages/core-restful-common/consumer-rules.pro
new file mode 100644
index 00000000..6984b6ce
--- /dev/null
+++ b/packages/core-restful-common/consumer-rules.pro
@@ -0,0 +1,100 @@
+### Jackson rules
+-keepattributes *Annotation*,EnclosingMethod,Signature
+-keepnames class com.fasterxml.jackson.** { *; }
+-dontwarn com.fasterxml.jackson.databind.**
+-keep class org.codehaus.** { *; }
+-keepclassmembers public final enum org.codehaus.jackson.annotate.JsonAutoDetect$Visibility {
+ public static final org.codehaus.jackson.annotate.JsonAutoDetect$Visibility *; }
+-keep public class your.class.** {
+ public void set*(***);
+ public *** get*();
+}
+
+### Retrofit 2
+# Platform calls Class.forName on types which do not exist on Android to determine platform.
+-dontnote retrofit2.Platform
+# Platform used when running on RoboVM on iOS. Will not be used at runtime.
+-dontnote retrofit2.Platform$IOS$MainThreadExecutor
+# Platform used when running on Java 8 VMs. Will not be used at runtime.
+-dontwarn retrofit2.Platform$Java8
+# Retain generic type information for use by reflection by converters and adapters.
+-keepattributes Signature
+# Retain declared checked exceptions for use by a Proxy instance.
+-keepattributes Exceptions
+
+-dontwarn retrofit2.adapter.rxjava.CompletableHelper$** # https://github.com/square/retrofit/issues/2034
+#To use Single instead of Observable in Retrofit interface
+-keepnames class rx.Single
+#Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and
+# EnclosingMethod is required to use InnerClasses.
+-keepattributes Signature, InnerClasses, EnclosingMethod
+# Retain service method parameters when optimizing.
+-keepclassmembers,allowshrinking,allowobfuscation interface * {
+ @retrofit2.http.* ;
+}
+# Retrofit does reflection on method and parameter annotations.
+-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
+# Ignore annotation used for build tooling.
+-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
+# Ignore JSR 305 annotations for embedding nullability information.
+-dontwarn javax.annotation.**
+# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath.
+-dontwarn kotlin.Unit
+# Top-level functions that can only be used by Kotlin.
+-dontwarn retrofit2.KotlinExtensions
+-dontwarn retrofit2.KotlinExtensions$*
+# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
+# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
+-if interface * { @retrofit2.http.* ; }
+-keep,allowobfuscation interface <1>
+
+### OkHttp3
+-dontwarn okhttp3.**
+-dontwarn okio.**
+-dontwarn javax.annotation.**
+# A resource is loaded with a relative path so the package of this class must be preserved.
+-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
+
+### Kotlin Coroutine
+# https://github.com/Kotlin/kotlinx.coroutines/blob/master/README.md
+# ServiceLoader support
+-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
+-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
+-keepnames class kotlinx.coroutines.android.AndroidExceptionPreHandler {}
+-keepnames class kotlinx.coroutines.android.AndroidDispatcherFactory {}
+# Most of volatile fields are updated with AFU and should not be mangled
+-keepclassmembernames class kotlinx.** {
+ volatile ;
+}
+# Same story for the standard library's SafeContinuation that also uses AtomicReferenceFieldUpdater
+-keepclassmembernames class kotlin.coroutines.SafeContinuation {
+ volatile ;
+}
+# https://github.com/Kotlin/kotlinx.atomicfu/issues/57
+-dontwarn kotlinx.atomicfu.**
+
+-dontwarn kotlinx.coroutines.flow.**
+
+### Kotlin
+#https://stackoverflow.com/questions/33547643/how-to-use-kotlin-with-proguard
+#https://medium.com/@AthorNZ/kotlin-metadata-jackson-and-proguard-f64f51e5ed32
+-keepclassmembers class **$WhenMappings {
+ ;
+}
+-keep class kotlin.Metadata { *; }
+-keepclassmembers class kotlin.Metadata {
+ public ;
+}
+
+# Jackson
+# Source: https://github.com/FasterXML/jackson-docs/wiki/JacksonOnAndroid
+-keep class com.fasterxml.jackson.databind.ObjectMapper {
+ public ;
+ protected ;
+}
+-keep class com.fasterxml.jackson.databind.ObjectWriter {
+ public ** writeValueAsString(**);
+}
+-keepnames class com.fasterxml.jackson.** { *; }
+-dontwarn com.fasterxml.jackson.databind.**
+-keep @com.fasterxml.jackson.annotation.JsonIgnoreProperties class * { *; }
diff --git a/packages/core-restful-common/gradle.properties b/packages/core-restful-common/gradle.properties
new file mode 100644
index 00000000..3265e42e
--- /dev/null
+++ b/packages/core-restful-common/gradle.properties
@@ -0,0 +1,3 @@
+artifactId=core-restful-common
+version=2.1.0-alpha
+description=Common components for the RESTful API implementation of the OMH Storage API
\ No newline at end of file
diff --git a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/mapper/LocalFileToMimeType.kt b/packages/core-restful-common/src/main/java/com/openmobilehub/android/storage/core/restful/common/data/mapper/LocalFileToMimeType.kt
similarity index 75%
rename from packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/mapper/LocalFileToMimeType.kt
rename to packages/core-restful-common/src/main/java/com/openmobilehub/android/storage/core/restful/common/data/mapper/LocalFileToMimeType.kt
index 51883253..c9f0730f 100644
--- a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/mapper/LocalFileToMimeType.kt
+++ b/packages/core-restful-common/src/main/java/com/openmobilehub/android/storage/core/restful/common/data/mapper/LocalFileToMimeType.kt
@@ -1,4 +1,4 @@
-package com.openmobilehub.android.storage.plugin.googledrive.nongms.data.mapper
+package com.openmobilehub.android.storage.core.restful.common.data.mapper
import android.webkit.MimeTypeMap
import java.io.File
diff --git a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/service/retrofit/StorageAuthenticator.kt b/packages/core-restful-common/src/main/java/com/openmobilehub/android/storage/core/restful/common/data/repository/StorageAuthenticator.kt
similarity index 68%
rename from packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/service/retrofit/StorageAuthenticator.kt
rename to packages/core-restful-common/src/main/java/com/openmobilehub/android/storage/core/restful/common/data/repository/StorageAuthenticator.kt
index ab476af6..f3947591 100644
--- a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/service/retrofit/StorageAuthenticator.kt
+++ b/packages/core-restful-common/src/main/java/com/openmobilehub/android/storage/core/restful/common/data/repository/StorageAuthenticator.kt
@@ -14,25 +14,30 @@
* limitations under the License.
*/
-package com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.retrofit
+package com.openmobilehub.android.storage.core.restful.common.data.repository
import com.openmobilehub.android.auth.core.OmhAuthClient
-import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.utils.accessToken
+import com.openmobilehub.android.storage.core.restful.common.utils.accessToken
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
-internal class StorageAuthenticator(private val omhAuthClient: OmhAuthClient) : Authenticator {
+class StorageAuthenticator(private val omhAuthClient: OmhAuthClient) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val refreshedToken = omhAuthClient.accessToken ?: return null
return response.request
.newBuilder()
.header(
- name = GoogleStorageApiServiceProvider.HEADER_AUTHORIZATION_NAME,
- value = GoogleStorageApiServiceProvider.BEARER.format(refreshedToken)
+ name = HEADER_AUTHORIZATION_NAME,
+ value = BEARER.format(refreshedToken)
)
.build()
}
+
+ companion object {
+ const val HEADER_AUTHORIZATION_NAME = "Authorization"
+ const val BEARER = "Bearer %s"
+ }
}
diff --git a/packages/core-restful-common/src/main/java/com/openmobilehub/android/storage/core/restful/common/utils/Extensions.kt b/packages/core-restful-common/src/main/java/com/openmobilehub/android/storage/core/restful/common/utils/Extensions.kt
new file mode 100644
index 00000000..1bf5840f
--- /dev/null
+++ b/packages/core-restful-common/src/main/java/com/openmobilehub/android/storage/core/restful/common/utils/Extensions.kt
@@ -0,0 +1,36 @@
+package com.openmobilehub.android.storage.core.restful.common.utils
+
+import com.openmobilehub.android.auth.core.OmhAuthClient
+import com.openmobilehub.android.storage.core.model.OmhStorageException
+import com.openmobilehub.android.storage.core.utils.unescapeUnicode
+import okhttp3.ResponseBody
+import retrofit2.HttpException
+import retrofit2.Response
+import java.io.ByteArrayOutputStream
+
+fun ResponseBody?.toByteArrayOutputStream(): ByteArrayOutputStream {
+ val outputStream = ByteArrayOutputStream()
+
+ if (this == null) {
+ return outputStream
+ }
+
+ byteStream().use { inputStream ->
+ inputStream.copyTo(outputStream)
+ }
+
+ return outputStream
+}
+
+fun Response.toApiException(): OmhStorageException.ApiException =
+ OmhStorageException.ApiException(
+ code(),
+ errorBody()?.string()?.unescapeUnicode(),
+ HttpException(this)
+ )
+
+val Response.isNotSuccessful: Boolean
+ get() = !isSuccessful
+
+val OmhAuthClient.accessToken: String?
+ get() = getCredentials().accessToken
diff --git a/packages/core/src/main/java/com/openmobilehub/android/storage/core/model/OmhStorageException.kt b/packages/core/src/main/java/com/openmobilehub/android/storage/core/model/OmhStorageException.kt
index d62468fb..cc6751b6 100644
--- a/packages/core/src/main/java/com/openmobilehub/android/storage/core/model/OmhStorageException.kt
+++ b/packages/core/src/main/java/com/openmobilehub/android/storage/core/model/OmhStorageException.kt
@@ -29,4 +29,14 @@ sealed class OmhStorageException(
class ApiException(val statusCode: Int? = null, message: String? = null, cause: Throwable? = null) :
OmhStorageException(message, cause)
+
+ class DownloadException(
+ override val message: String? = "Error downloading file",
+ override val cause: Throwable? = null
+ ) : OmhStorageException(message, cause)
+
+ class UpdateException(
+ override val message: String? = "Error updating file",
+ override val cause: Throwable?
+ ) : OmhStorageException(message, cause)
}
diff --git a/packages/core/src/main/java/com/openmobilehub/android/storage/core/utils/JSONObjectExt.kt b/packages/core/src/main/java/com/openmobilehub/android/storage/core/utils/JSONObjectExt.kt
new file mode 100644
index 00000000..2c503c22
--- /dev/null
+++ b/packages/core/src/main/java/com/openmobilehub/android/storage/core/utils/JSONObjectExt.kt
@@ -0,0 +1,16 @@
+package com.openmobilehub.android.storage.core.utils
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+fun JSONArray?.toJSONObjectList(): List {
+ if (this == null) return emptyList()
+ val list = mutableListOf()
+ for (i in 0 until this.length()) {
+ val jsonObject = this.optJSONObject(i)
+ if (jsonObject != null) {
+ list.add(jsonObject)
+ }
+ }
+ return list
+}
diff --git a/packages/core/src/main/java/com/openmobilehub/android/storage/core/utils/StringExtensions.kt b/packages/core/src/main/java/com/openmobilehub/android/storage/core/utils/StringExtensions.kt
index 2071794f..cf2b7ca2 100644
--- a/packages/core/src/main/java/com/openmobilehub/android/storage/core/utils/StringExtensions.kt
+++ b/packages/core/src/main/java/com/openmobilehub/android/storage/core/utils/StringExtensions.kt
@@ -30,15 +30,52 @@ fun String.removeSpecialCharacters(): String {
return this.replace("[^a-zA-Z0-9.]".toRegex(), "_")
}
+@Suppress("MagicNumber")
+fun String.escapeUnicode(): String =
+ this.map {
+ if (it.code <= 127 || it == '/' || it == '.') {
+ it.toString()
+ } else {
+ "\\u%04x".format(it.code)
+ }
+ }.joinToString("")
+
+@Suppress("MagicNumber")
+fun String.unescapeUnicode(): String {
+ val regex = Regex("""\\u([0-9a-fA-F]{4})""")
+ return regex.replace(this) { matchResult ->
+ val hexValue = matchResult.groupValues[1]
+ val intValue = hexValue.toInt(16)
+ intValue.toChar().toString()
+ }
+}
+
+@Suppress("ReturnCount", "MagicNumber")
fun String.fromRFC3339StringToDate(): Date? {
- val rfc3339Format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
- rfc3339Format.timeZone = TimeZone.getTimeZone("UTC")
+ if (this.isEmpty()) return null
- return try {
- rfc3339Format.parse(this)
- } catch (e: ParseException) {
- null
+ // Use regex to check if the given string has fractional seconds
+ val processed = this.replace(Regex("^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})\\.(\\d+)(Z)$")) { mr ->
+ val prefix = mr.groupValues[1]
+ val fraction = mr.groupValues[2]
+ val suffix = mr.groupValues[3]
+ val truncated = if (fraction.length > 3) fraction.substring(0, 3) else fraction.padEnd(3, '0')
+ "$prefix.$truncated$suffix"
+ }
+ val patterns = listOf(
+ "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", // with fractional (normalized to 3)
+ "yyyy-MM-dd'T'HH:mm:ss'Z'" // without fractional
+ )
+ for (pattern in patterns) {
+ try {
+ val sdf = SimpleDateFormat(pattern, Locale.US)
+ sdf.timeZone = TimeZone.getTimeZone("UTC")
+ return sdf.parse(processed)
+ } catch (_: ParseException) {
+ // continue
+ }
}
+ return null
}
fun String.splitPathToParts(): List {
diff --git a/packages/core/src/test/java/com/openmobilehub/android/storage/core/utils/StringExtensionsTest.kt b/packages/core/src/test/java/com/openmobilehub/android/storage/core/utils/StringExtensionsTest.kt
index 837872d1..05b0e123 100644
--- a/packages/core/src/test/java/com/openmobilehub/android/storage/core/utils/StringExtensionsTest.kt
+++ b/packages/core/src/test/java/com/openmobilehub/android/storage/core/utils/StringExtensionsTest.kt
@@ -90,6 +90,57 @@ class StringExtensionsTest {
assertNull(result)
}
+ @Test
+ fun `given a string with escaped unicode characters, when unescaping unicode, then return string with actual unicode characters`() {
+ // Arrange
+ val input = "You don\\u2019t have permission to perform this action."
+ val expected = "You don’t have permission to perform this action."
+
+ // Act
+ val result = input.unescapeUnicode()
+
+ // Assert
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `given a string without escaped unicode, when unescaping unicode, then return original string`() {
+ // Arrange
+ val input = "Regular string without unicode"
+ val expected = "Regular string without unicode"
+
+ // Act
+ val result = input.unescapeUnicode()
+
+ // Assert
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `given a string with multiple escaped unicode characters, when unescaping unicode, then return string with all characters unescaped`() {
+ // Arrange
+ val input = "\\u0048\\u0065\\u006c\\u006c\\u006f \\u0057\\u006f\\u0072\\u006c\\u0064"
+ val expected = "Hello World"
+
+ // Act
+ val result = input.unescapeUnicode()
+
+ // Assert
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `test unescapeUnicode with multibyte characters`() {
+ val input = "\u3042\u3044\u3046\u3048\u304A"
+ val expected = "あいうえお"
+
+ // Act
+ val result = input.unescapeUnicode()
+
+ // Assert
+ assertEquals(expected, result)
+ }
+
@Test
fun `test splitPathToParts`() {
assertEquals(listOf("abc"), "abc".splitPathToParts())
@@ -98,4 +149,34 @@ class StringExtensionsTest {
assertEquals(listOf("a", "b", "c"), "/a/b/c".splitPathToParts())
assertEquals(listOf("a", "b", "c"), "/a/b/c/".splitPathToParts())
}
+
+ @Test
+ fun `given RFC3339 without fractional seconds, when converted to date, then return correct date`() {
+ val input = "2024-05-01T00:00:00Z"
+ val expected = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US).apply {
+ timeZone = java.util.TimeZone.getTimeZone("UTC")
+ }.parse(input)
+ val result = input.fromRFC3339StringToDate()
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `given RFC3339 with 7 fractional digits, when converted to date, then truncate to millis`() {
+ val input = "2024-05-01T00:00:00.1234567Z"
+ val expected = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).apply {
+ timeZone = java.util.TimeZone.getTimeZone("UTC")
+ }.parse("2024-05-01T00:00:00.123Z")
+ val result = input.fromRFC3339StringToDate()
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `given RFC3339 with 2 fractional digits, when converted to date, then pad to millis`() {
+ val input = "2024-05-01T00:00:00.12Z"
+ val expected = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).apply {
+ timeZone = java.util.TimeZone.getTimeZone("UTC")
+ }.parse("2024-05-01T00:00:00.120Z")
+ val result = input.fromRFC3339StringToDate()
+ assertEquals(expected, result)
+ }
}
diff --git a/packages/plugin-dropbox-restful/.gitignore b/packages/plugin-dropbox-restful/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/packages/plugin-dropbox-restful/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/packages/plugin-dropbox-restful/README.md b/packages/plugin-dropbox-restful/README.md
new file mode 100644
index 00000000..c52ad3f4
--- /dev/null
+++ b/packages/plugin-dropbox-restful/README.md
@@ -0,0 +1,52 @@
+Module plugin-dropbox-restful
+
+
+
+ 
+
+
Android OMH Storage - Dropbox (RESTful)
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+Dropbox Implementation of OMH Storage API using Dropbox's own HTTP REST-ful API.
+
+Different from plugin-dropbox, this plugin does not depend on Dropbox SDK for Java/Android, which enables usage scenarios without restrictions imposed by Dropbox SDK -
+
+Dropbox SDK requires additional setup to allow SDK access the API credentials from within the app; this plugin allows API credentials be provisioned outside the app.
+
+## Usage
+
+### Set up your Dropbox application
+
+Setup the Dropbox application from Dropbox's app console, the same as mentioned in original Dropbox plugin.
+
+Next, you need to setup a Redirect URI. You can use Custom URI as most Android apps would do; however for better user experience you may consider Google's recommended [verified app links](https://developer.android.com/training/app-links/verify-android-applinks) method.
+
+Apart from this, you should be able to use the plugin as-is, similar to plugin-dropbox.
+
+### Caveats
+
+Almost all known issues in plugin-dropbox also applies to this plugin, please refer to [plugin-dropbox's documentation](https://openmobilehub.github.io/android-omh-storage/docs/plugin-dropbox) for details.
+
+Additionally,
+
+- createPermission() for email addresses unknown to Dropbox may still succeed, but not visible in getFilePermissions(). You may need to go back to Dropbox web console and clear the permissions by hand.
+
+### Escape Hatch
+
+This plugin does not provides an escape hatch to access the native Dropbox Android SDK, as it uses REST API instead. If needed, you can use credentials from OmhAuthClient to authorise your own REST API client.
+
+## License
+
+- See [LICENSE](https://github.com/openmobilehub/android-omh-storage/blob/main/LICENSE)
diff --git a/packages/plugin-dropbox-restful/build.gradle.kts b/packages/plugin-dropbox-restful/build.gradle.kts
new file mode 100644
index 00000000..60b20f58
--- /dev/null
+++ b/packages/plugin-dropbox-restful/build.gradle.kts
@@ -0,0 +1,60 @@
+plugins {
+ `android-base-lib`
+}
+
+android {
+ namespace = "com.openmobilehub.android.storage.plugin.dropbox.restful"
+
+ defaultConfig {
+ buildConfigField(
+ type = "String",
+ name = "DROPBOX_API_URL",
+ value = getRequiredValueFromEnvOrProperties("dropboxApiUrl"),
+ )
+ buildConfigField(
+ type = "String",
+ name = "DROPBOX_CONTENT_API_URL",
+ value = getRequiredValueFromEnvOrProperties("dropboxContentApiUrl"),
+ )
+ }
+
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+}
+
+val useLocalProjects = project.rootProject.extra["useLocalProjects"] as Boolean
+
+dependencies {
+ if (useLocalProjects) {
+ api(project(":packages:core"))
+ implementation(project(":packages:core-restful-common"))
+ } else {
+ api("com.openmobilehub.android.storage:core:2.1.0-alpha")
+ implementation("com.openmobilehub.android.storage:core-restful-common:2.1.0-alpha")
+ }
+
+ // Omh Auth
+ api(Libs.omhGoogleNonGmsAuthLibrary)
+
+ // slf4j
+ implementation(Libs.slf4jApi)
+
+ // Retrofit setup
+ implementation(Libs.retrofit)
+ implementation(Libs.retrofitJacksonConverter)
+ implementation(Libs.okHttp)
+ implementation(Libs.okHttpLoggingInterceptor)
+ implementation(Libs.jacksonKotlin)
+
+ implementation(Libs.coroutinesCore)
+ implementation(Libs.coroutinesAndroid)
+
+ // Test dependencies
+ testImplementation(kotlin("test"))
+ testImplementation(Libs.junit)
+ testImplementation(Libs.mockk)
+ testImplementation(Libs.coroutineTesting)
+ testImplementation(Libs.json)
+ testImplementation(Libs.slf4jAndroid)
+}
\ No newline at end of file
diff --git a/packages/plugin-dropbox-restful/consumer-rules.pro b/packages/plugin-dropbox-restful/consumer-rules.pro
new file mode 100644
index 00000000..6984b6ce
--- /dev/null
+++ b/packages/plugin-dropbox-restful/consumer-rules.pro
@@ -0,0 +1,100 @@
+### Jackson rules
+-keepattributes *Annotation*,EnclosingMethod,Signature
+-keepnames class com.fasterxml.jackson.** { *; }
+-dontwarn com.fasterxml.jackson.databind.**
+-keep class org.codehaus.** { *; }
+-keepclassmembers public final enum org.codehaus.jackson.annotate.JsonAutoDetect$Visibility {
+ public static final org.codehaus.jackson.annotate.JsonAutoDetect$Visibility *; }
+-keep public class your.class.** {
+ public void set*(***);
+ public *** get*();
+}
+
+### Retrofit 2
+# Platform calls Class.forName on types which do not exist on Android to determine platform.
+-dontnote retrofit2.Platform
+# Platform used when running on RoboVM on iOS. Will not be used at runtime.
+-dontnote retrofit2.Platform$IOS$MainThreadExecutor
+# Platform used when running on Java 8 VMs. Will not be used at runtime.
+-dontwarn retrofit2.Platform$Java8
+# Retain generic type information for use by reflection by converters and adapters.
+-keepattributes Signature
+# Retain declared checked exceptions for use by a Proxy instance.
+-keepattributes Exceptions
+
+-dontwarn retrofit2.adapter.rxjava.CompletableHelper$** # https://github.com/square/retrofit/issues/2034
+#To use Single instead of Observable in Retrofit interface
+-keepnames class rx.Single
+#Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and
+# EnclosingMethod is required to use InnerClasses.
+-keepattributes Signature, InnerClasses, EnclosingMethod
+# Retain service method parameters when optimizing.
+-keepclassmembers,allowshrinking,allowobfuscation interface * {
+ @retrofit2.http.* ;
+}
+# Retrofit does reflection on method and parameter annotations.
+-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
+# Ignore annotation used for build tooling.
+-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
+# Ignore JSR 305 annotations for embedding nullability information.
+-dontwarn javax.annotation.**
+# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath.
+-dontwarn kotlin.Unit
+# Top-level functions that can only be used by Kotlin.
+-dontwarn retrofit2.KotlinExtensions
+-dontwarn retrofit2.KotlinExtensions$*
+# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
+# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
+-if interface * { @retrofit2.http.* ; }
+-keep,allowobfuscation interface <1>
+
+### OkHttp3
+-dontwarn okhttp3.**
+-dontwarn okio.**
+-dontwarn javax.annotation.**
+# A resource is loaded with a relative path so the package of this class must be preserved.
+-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
+
+### Kotlin Coroutine
+# https://github.com/Kotlin/kotlinx.coroutines/blob/master/README.md
+# ServiceLoader support
+-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
+-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
+-keepnames class kotlinx.coroutines.android.AndroidExceptionPreHandler {}
+-keepnames class kotlinx.coroutines.android.AndroidDispatcherFactory {}
+# Most of volatile fields are updated with AFU and should not be mangled
+-keepclassmembernames class kotlinx.** {
+ volatile ;
+}
+# Same story for the standard library's SafeContinuation that also uses AtomicReferenceFieldUpdater
+-keepclassmembernames class kotlin.coroutines.SafeContinuation {
+ volatile ;
+}
+# https://github.com/Kotlin/kotlinx.atomicfu/issues/57
+-dontwarn kotlinx.atomicfu.**
+
+-dontwarn kotlinx.coroutines.flow.**
+
+### Kotlin
+#https://stackoverflow.com/questions/33547643/how-to-use-kotlin-with-proguard
+#https://medium.com/@AthorNZ/kotlin-metadata-jackson-and-proguard-f64f51e5ed32
+-keepclassmembers class **$WhenMappings {
+ ;
+}
+-keep class kotlin.Metadata { *; }
+-keepclassmembers class kotlin.Metadata {
+ public ;
+}
+
+# Jackson
+# Source: https://github.com/FasterXML/jackson-docs/wiki/JacksonOnAndroid
+-keep class com.fasterxml.jackson.databind.ObjectMapper {
+ public ;
+ protected ;
+}
+-keep class com.fasterxml.jackson.databind.ObjectWriter {
+ public ** writeValueAsString(**);
+}
+-keepnames class com.fasterxml.jackson.** { *; }
+-dontwarn com.fasterxml.jackson.databind.**
+-keep @com.fasterxml.jackson.annotation.JsonIgnoreProperties class * { *; }
diff --git a/packages/plugin-dropbox-restful/docs/README.md b/packages/plugin-dropbox-restful/docs/README.md
new file mode 100644
index 00000000..c2afebd8
--- /dev/null
+++ b/packages/plugin-dropbox-restful/docs/README.md
@@ -0,0 +1,7 @@
+---
+title: Dropbox
+layout: default
+nav_order: 7
+---
+
+{% include_relative _README_ORIGINAL.md %}
diff --git a/packages/plugin-dropbox-restful/gradle.properties b/packages/plugin-dropbox-restful/gradle.properties
new file mode 100644
index 00000000..cbe4d170
--- /dev/null
+++ b/packages/plugin-dropbox-restful/gradle.properties
@@ -0,0 +1,5 @@
+artifactId=plugin-dropbox-restful
+version=2.1.0-alpha
+dropboxApiUrl="https://api.dropboxapi.com/"
+dropboxContentApiUrl="https://content.dropboxapi.com/"
+description=The RESTful API implementation of the OMH Storage API on Dropbox
\ No newline at end of file
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/Constants.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/Constants.kt
new file mode 100644
index 00000000..537159bf
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/Constants.kt
@@ -0,0 +1,10 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful
+
+internal object Constants {
+
+ @JvmStatic
+ val ROOT_FOLDER = ""
+
+ @JvmStatic
+ val emailRegex = Regex("^['#&A-Za-z0-9._%+-]+@[A-Za-z0-9-][A-Za-z0-9.-]*\\.[A-Za-z]{2,15}$")
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/DropboxRestfulOmhStorageClient.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/DropboxRestfulOmhStorageClient.kt
new file mode 100644
index 00000000..60d725f3
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/DropboxRestfulOmhStorageClient.kt
@@ -0,0 +1,162 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful
+
+import com.openmobilehub.android.auth.core.OmhAuthClient
+import com.openmobilehub.android.storage.core.OmhStorageClient
+import com.openmobilehub.android.storage.core.model.OmhCreatePermission
+import com.openmobilehub.android.storage.core.model.OmhFileVersion
+import com.openmobilehub.android.storage.core.model.OmhPermission
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.core.model.OmhStorageEntity
+import com.openmobilehub.android.storage.core.model.OmhStorageMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.repository.DropboxRestfulFileRepository
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toOmhFile
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toOmhFolder
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FileMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FolderMetadata
+import java.io.ByteArrayOutputStream
+import java.io.File
+
+@Suppress("TooManyFunctions")
+internal class DropboxRestfulOmhStorageClient(
+ authClient: OmhAuthClient,
+ private val fileRepository: DropboxRestfulFileRepository
+) : OmhStorageClient(authClient) {
+
+ override val rootFolder: String = Constants.ROOT_FOLDER
+
+ override suspend fun listFiles(parentId: String): List {
+ return fileRepository.getFilesList(parentId)
+ }
+
+ override suspend fun search(query: String): List {
+ return fileRepository.search(query).toListOfOmhStorageEntity()
+ }
+
+ override suspend fun createFileWithMimeType(
+ name: String,
+ mimeType: String,
+ parentId: String
+ ): OmhStorageEntity? {
+ throw UnsupportedOperationException(
+ "Dropbox does not support creating files with mime types. Use createFileWithExtension instead."
+ )
+ }
+
+ override suspend fun createFileWithExtension(
+ name: String,
+ extension: String,
+ parentId: String
+ ): OmhStorageEntity? {
+ return fileRepository.createFile("$name.$extension", parentId)
+ }
+
+ override suspend fun createFolder(name: String, parentId: String): OmhStorageEntity? {
+ return fileRepository.createFolder(name, parentId)
+ }
+
+ override suspend fun deleteFile(id: String) {
+ fileRepository.deleteFile(id)
+ }
+
+ override suspend fun permanentlyDeleteFile(id: String) {
+ fileRepository.permanentlyDeleteFile(id)
+ }
+
+ override suspend fun uploadFile(localFileToUpload: File, parentId: String?): OmhStorageEntity? {
+ return fileRepository.uploadFile(localFileToUpload, parentId)
+ }
+
+ override suspend fun downloadFile(fileId: String): ByteArrayOutputStream {
+ return fileRepository.downloadFile(fileId)
+ }
+
+ /**
+ * To align with plugin-dropbox behaviour, although Dropbox does has API for exporting
+ * certain files.
+ */
+ override suspend fun exportFile(
+ fileId: String,
+ exportedMimeType: String
+ ): ByteArrayOutputStream {
+ // return fileRepository.exportFile(fileId, exportedMimeType)
+ throw UnsupportedOperationException("Exporting files is not supported in Dropbox")
+ }
+
+ override suspend fun updateFile(localFileToUpload: File, fileId: String): OmhStorageEntity? {
+ return fileRepository.updateFile(localFileToUpload, fileId)
+ }
+
+ override suspend fun getFileVersions(fileId: String): List {
+ return fileRepository.getFileRevisions(fileId)
+ }
+
+ override suspend fun downloadFileVersion(
+ fileId: String,
+ versionId: String
+ ): ByteArrayOutputStream {
+ return fileRepository.downloadFileVersion(fileId, versionId)
+ }
+
+ override suspend fun getFileMetadata(fileId: String): OmhStorageMetadata? {
+ return fileRepository.getFileMetadata(fileId)
+ }
+
+ override suspend fun getFilePermissions(fileId: String): List {
+ return fileRepository.getNodePermission(fileId)
+ }
+
+ override suspend fun deletePermission(fileId: String, permissionId: String) {
+ fileRepository.deleteNodePermission(fileId, permissionId)
+ }
+
+ override suspend fun updatePermission(
+ fileId: String,
+ permissionId: String,
+ role: OmhPermissionRole
+ ): OmhPermission? {
+ return fileRepository.updateNodePermission(
+ id = fileId,
+ permissionId = permissionId,
+ role = role
+ )
+ }
+
+ override suspend fun createPermission(
+ fileId: String,
+ permission: OmhCreatePermission,
+ sendNotificationEmail: Boolean,
+ emailMessage: String?
+ ): OmhPermission? {
+ return fileRepository.createNodePermission(
+ id = fileId,
+ permission = permission,
+ quiet = !sendNotificationEmail,
+ customMessage = emailMessage
+ )
+ }
+
+ override suspend fun getWebUrl(fileId: String): String? {
+ return fileRepository.getTemporaryLink(fileId)
+ }
+
+ override suspend fun resolvePath(path: String): OmhStorageEntity? {
+ return fileRepository.getNodeMetadata(path = path)?.let { metadata ->
+ when (metadata) {
+ is FolderMetadata -> metadata.toOmhFolder(rootFolder)
+ is FileMetadata -> metadata.toOmhFile(rootFolder)
+ }
+ }
+ }
+
+ override suspend fun getStorageUsage(): Long {
+ return fileRepository.getSpaceUsage().used
+ }
+
+ override suspend fun getStorageQuota(): Long {
+ return fileRepository.getSpaceUsage().allocation.allocated
+ }
+
+ override fun getProviderSdk(): Any {
+ throw UnsupportedOperationException("Not implemented for Dropbox Restful client")
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/DropboxRestfulOmhStorageClientFactory.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/DropboxRestfulOmhStorageClientFactory.kt
new file mode 100644
index 00000000..35db6ef6
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/DropboxRestfulOmhStorageClientFactory.kt
@@ -0,0 +1,18 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful
+
+import com.openmobilehub.android.auth.core.OmhAuthClient
+import com.openmobilehub.android.storage.core.OmhStorageClient
+import com.openmobilehub.android.storage.core.OmhStorageFactory
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.repository.DropboxRestfulFileRepository
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.retrofit.DropboxRetrofitImpl
+
+class DropboxRestfulOmhStorageClientFactory : OmhStorageFactory {
+ override fun getStorageClient(authClient: OmhAuthClient): OmhStorageClient {
+ val retrofit = DropboxRetrofitImpl(authClient)
+ val repository = DropboxRestfulFileRepository(
+ retrofit.dropboxApiService,
+ retrofit.dropboxContentApiService
+ )
+ return DropboxRestfulOmhStorageClient(authClient, repository)
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/DropboxApiService.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/DropboxApiService.kt
new file mode 100644
index 00000000..73064745
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/DropboxApiService.kt
@@ -0,0 +1,195 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.AddFileSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.AddFolderSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.CheckShareJobStatusRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ContinueRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.CreateFolderRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.DeleteFileSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.DeleteFolderSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.GetFileRevisionsRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.GetFileSharingMetadataRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.GetFolderSharingMetadataRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ListFileSharedMembersRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ListFolderRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ListFolderSharedMembersRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.NodeMetadataRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.PathRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.SearchFileRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ShareFolderRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.UpdateFileSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.UpdateFolderSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.CreateFolderResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.CurrentUserAccountResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FileMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.GetSpaceUsageResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ListFileRevisionsResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ListFolderResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ListFolderResponseDeserializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ShareJobResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.SharedFileMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.SharedFolderMetadata
+import okhttp3.ResponseBody
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+@Suppress("TooManyFunctions")
+internal interface DropboxApiService {
+ companion object {
+ private const val FILES = "2/files"
+ private const val USERS = "2/users"
+ private const val SHARING = "2/sharing"
+ private const val CONTINUE = "continue"
+
+ private const val CREATE_FOLDER = "create_folder_v2"
+ private const val LIST_FOLDER = "list_folder"
+ private const val DELETE = "delete"
+ private const val PERMANENT_DELETE = "permanently_delete"
+
+ private const val GET_METADATA = "get_metadata"
+ private const val GET_USER_INFO = "get_current_account"
+ private const val GET_SPACE_USAGE = "get_space_usage"
+ private const val GET_REVISION_LIST = "list_revisions"
+
+ // metadata
+ private const val GET_FILE_METADATA = "get_file_metadata"
+ private const val GET_FOLDER_METADATA = "get_folder_metadata"
+
+ // Sharing
+ private const val SHARE_FOLDER = "share_folder"
+ private const val GET_JOB_STATUS = "check_share_job_status"
+ private const val ADD_FILE_MEMBER = "add_file_member"
+ private const val ADD_FOLDER_MEMBER = "add_folder_member"
+ private const val UPDATE_FILE_MEMBER = "update_file_member"
+ private const val UPDATE_FOLDER_MEMBER = "update_folder_member"
+ private const val LIST_FILE_MEMBERS = "list_file_members"
+ private const val LIST_FOLDER_MEMBERS = "list_folder_members"
+ private const val DELETE_FILE_MEMBERS = "remove_file_member_2"
+ private const val DELETE_FOLDER_MEMBERS = "remove_folder_member"
+
+ // Search
+ private const val SEARCH = "search_v2"
+ private const val SEARCH_CONTINUE_V2 = "continue_v2"
+ }
+
+ @POST("$FILES/$CREATE_FOLDER")
+ suspend fun createFolder(
+ @Body body: CreateFolderRequest,
+ ): Response
+
+ @POST("$FILES/$GET_METADATA")
+ suspend fun getNodeMetaData(
+ @Body body: NodeMetadataRequest,
+ ): Response
+
+ @POST("$FILES/$LIST_FOLDER")
+ @JsonDeserialize(using = ListFolderResponseDeserializer::class)
+ suspend fun getFilesList(
+ @Body body: ListFolderRequestBody,
+ ): Response
+
+ @POST("$FILES/$GET_REVISION_LIST")
+ suspend fun getFileRevisionList(
+ @Body body: GetFileRevisionsRequest,
+ ): Response
+
+ @POST("$FILES/$LIST_FOLDER/continue")
+ @JsonDeserialize(using = ListFolderResponseDeserializer::class)
+ suspend fun continueGetFilesList(
+ @Body body: ContinueRequest,
+ ): Response
+
+ @POST("$FILES/$DELETE")
+ suspend fun deleteFile(@Body body: PathRequestBody): Response
+
+ @POST("$FILES/$PERMANENT_DELETE")
+ suspend fun permanentlyDeleteFile(@Body body: PathRequestBody): Response
+
+ @POST("$FILES/$GET_USER_INFO")
+ suspend fun getCurrentAccount(): Response
+
+ @POST("$USERS/$GET_SPACE_USAGE")
+ suspend fun getSpaceUsage(): Response
+
+ @POST("$SHARING/$GET_FILE_METADATA")
+ suspend fun getFileSharingMetadata(
+ @Body body: GetFileSharingMetadataRequestBody,
+ ): Response
+
+ @POST("$SHARING/$GET_FOLDER_METADATA")
+ suspend fun getFolderSharingMetadata(
+ @Body body: GetFolderSharingMetadataRequestBody,
+ ): Response
+
+ @POST("$SHARING/$LIST_FILE_MEMBERS")
+ suspend fun listFileSharedMembers(
+ @Body body: ListFileSharedMembersRequest,
+ ): Response
+
+ @POST("$SHARING/$LIST_FILE_MEMBERS/$CONTINUE")
+ suspend fun continueListFileSharedMembers(
+ @Body body: ContinueRequest,
+ ): Response
+
+ @POST("$SHARING/$LIST_FOLDER_MEMBERS")
+ suspend fun listFolderSharedMembers(
+ @Body body: ListFolderSharedMembersRequest,
+ ): Response
+
+ @POST("$SHARING/$LIST_FOLDER_MEMBERS/$CONTINUE")
+ suspend fun continueListFolderSharedMembers(
+ @Body body: ContinueRequest,
+ ): Response
+
+ @POST("$SHARING/$ADD_FILE_MEMBER")
+ suspend fun addFileSharedMember(
+ @Body body: AddFileSharedMemberRequest
+ ): Response
+
+ @POST("$SHARING/$SHARE_FOLDER")
+ suspend fun shareFolder(
+ @Body body: ShareFolderRequestBody
+ ): Response
+
+ @POST("$SHARING/$GET_JOB_STATUS")
+ suspend fun checkShareJobStatus(
+ @Body body: CheckShareJobStatusRequest
+ ): Response
+
+ @POST("$SHARING/$ADD_FOLDER_MEMBER")
+ suspend fun addFolderSharedMember(
+ @Body body: AddFolderSharedMemberRequest
+ ): Response
+
+ @POST("$SHARING/$UPDATE_FILE_MEMBER")
+ suspend fun updateFileSharedMember(
+ @Body body: UpdateFileSharedMemberRequest
+ ): Response
+
+ @POST("$SHARING/$UPDATE_FOLDER_MEMBER")
+ suspend fun updateFolderSharedMember(
+ @Body body: UpdateFolderSharedMemberRequest
+ ): Response
+
+ @POST("$SHARING/$DELETE_FILE_MEMBERS")
+ suspend fun deleteFileSharedMembers(
+ @Body body: DeleteFileSharedMemberRequest
+ ): Response
+
+ @POST("$SHARING/$DELETE_FOLDER_MEMBERS")
+ suspend fun deleteFolderSharedMembers(
+ @Body body: DeleteFolderSharedMemberRequest
+ ): Response
+
+ @POST("$FILES/$SEARCH")
+ suspend fun search(
+ @Body body: SearchFileRequest
+ ): Response
+
+ @POST("$FILES/$SEARCH_CONTINUE_V2")
+ suspend fun continueSearch(
+ @Body body: SearchFileRequest.SearchByCursor
+ ): Response
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/DropboxContentApiService.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/DropboxContentApiService.kt
new file mode 100644
index 00000000..7af4dbf4
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/DropboxContentApiService.kt
@@ -0,0 +1,69 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data
+
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FileMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.GetTemporaryLinkResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.UploadSessionResponse
+import okhttp3.RequestBody
+import okhttp3.ResponseBody
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.Header
+import retrofit2.http.POST
+
+internal interface DropboxContentApiService {
+
+ companion object {
+ private const val FILES = "2/files"
+
+ private const val DROPBOX_API_ARG = "Dropbox-API-Arg"
+ private const val CONTENT_TYPE = "Content-Type"
+
+ private const val UPLOAD = "upload"
+ private const val UPLOAD_SESSION = "upload_session"
+ private const val START = "start"
+ private const val APPEND = "append_v2"
+ private const val FINISH = "finish"
+
+ private const val DOWNLOAD = "download"
+
+ private const val GET_TEMPORARY_LINK = "get_temporary_link"
+
+ private const val EXPORT = "export"
+ }
+
+ @POST("$FILES/$UPLOAD")
+ suspend fun createFile(
+ @Header(DROPBOX_API_ARG) createRequest: String,
+ @Body filePart: RequestBody,
+ ): Response
+
+ @POST("$FILES/$UPLOAD_SESSION/$START")
+ suspend fun createUploadFileSession(
+ @Header(DROPBOX_API_ARG) uploadSessionRequest: String,
+ @Header(CONTENT_TYPE) contentType: String? = "application/octet-stream",
+ ):
+ Response
+
+ @POST("$FILES/$UPLOAD_SESSION/$APPEND")
+ suspend fun continueUploadFile(
+ @Header(DROPBOX_API_ARG) appendUploadSessionRequest: String,
+ @Body filePart: RequestBody,
+ ): Response
+
+ @POST("$FILES/$UPLOAD_SESSION/$FINISH")
+ suspend fun finishUploadFile(
+ @Header(DROPBOX_API_ARG) finishUploadSessionRequestBody: String,
+ @Body filePart: RequestBody,
+ ): Response
+
+ @POST("$FILES/$DOWNLOAD")
+ suspend fun downloadFile(@Header(DROPBOX_API_ARG) pathRequestJson: String): Response
+
+ @POST("$FILES/$EXPORT")
+ suspend fun exportFile(@Header(DROPBOX_API_ARG) exportFileRequestJson: String): Response
+
+ @POST("$FILES/$GET_TEMPORARY_LINK")
+ suspend fun getTemporaryLink(
+ @Header(DROPBOX_API_ARG) pathRequestJson: String
+ ): Response
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/repository/DropboxRestfulFileRepository.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/repository/DropboxRestfulFileRepository.kt
new file mode 100644
index 00000000..330e155e
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/repository/DropboxRestfulFileRepository.kt
@@ -0,0 +1,1000 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.repository
+
+import com.openmobilehub.android.storage.core.model.OmhCreatePermission
+import com.openmobilehub.android.storage.core.model.OmhFileVersion
+import com.openmobilehub.android.storage.core.model.OmhIdentity
+import com.openmobilehub.android.storage.core.model.OmhPermission
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.core.model.OmhStorageEntity
+import com.openmobilehub.android.storage.core.model.OmhStorageException
+import com.openmobilehub.android.storage.core.model.OmhStorageMetadata
+import com.openmobilehub.android.storage.core.restful.common.utils.toApiException
+import com.openmobilehub.android.storage.core.restful.common.utils.toByteArrayOutputStream
+import com.openmobilehub.android.storage.core.utils.escapeUnicode
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.DropboxApiService
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.DropboxContentApiService
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.retrofit.DropboxRetrofitImpl
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.AddFileSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.AddFolderSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.AppendUploadSessionRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.CheckShareJobStatusRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ContinueRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.CreateFileRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.CreateFolderRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.DeleteFileSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.DeleteFolderSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ExportFileRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.FinishUploadSessionRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.GetFileRevisionsRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.GetFileSharingMetadataRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.GetFolderSharingMetadataRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ListFileSharedMembersRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ListFolderRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ListFolderSharedMembersRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.NodeMetadataRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.PathRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.SearchFileRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ShareFolderRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.UpdateFileSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.UpdateFolderSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.UploadSessionCursor
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.UploadSessionRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.AddFolderMember
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.groupsToOmhPermissionList
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toMemberSelector
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toOmhFile
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toOmhFileRevisions
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toOmhFolder
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.usersToOmhPermissionList
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FileMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FolderMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.GetSpaceUsageResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.NodeMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.SearchResultResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ShareFolderResponse
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.ResponseBody
+import org.json.JSONObject
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import java.io.ByteArrayOutputStream
+import java.io.File
+
+@Suppress(
+ "TooManyFunctions",
+ "MagicNumber",
+ "UnusedPrivateMember",
+ "LargeClass",
+ "NestedBlockDepth",
+ "ThrowsCount",
+ "LongMethod"
+)
+
+internal class DropboxRestfulFileRepository(
+ private val apiService: DropboxApiService,
+ private val contentApiService: DropboxContentApiService,
+ smallFileLimitInMB: Int = 150,
+) {
+ private val smallFileLimit = smallFileLimitInMB * ONE_MEGABYTE
+
+ companion object {
+ private const val ONE_MEGABYTE = 1024 * 1024
+ private const val APPLICATION_OCTET_STREAM = "application/octet-stream"
+ private val APPLICATION_OCTET_STREAM_MEDIA_TYPE =
+ requireNotNull(APPLICATION_OCTET_STREAM.toMediaTypeOrNull())
+ private val EMPTY_BYTE_ARRAY = "".toByteArray()
+
+ // Share job polling constants
+ private const val SHARE_JOB_INITIAL_DELAY_MS = 500L
+ private const val SHARE_JOB_MAX_DELAY_MS = 5000L
+ private const val SHARE_JOB_MAX_ATTEMPTS = 30
+ private const val SHARE_JOB_BACKOFF_MULTIPLIER = 1.5
+
+ @JvmStatic
+ private val logger: Logger =
+ LoggerFactory.getLogger(DropboxRestfulFileRepository::class.java)
+ }
+
+ suspend fun createFolder(
+ name: String,
+ parentId: String?,
+ ): OmhStorageEntity.OmhFolder? {
+ val parentFolder =
+ if (!parentId.isNullOrEmpty()) {
+ getNodeMetadata(path = parentId)
+ } else {
+ null
+ }
+ return apiService.createFolder(
+ CreateFolderRequest(
+ path = "${parentFolder?.path ?: ""}/$name"
+ ),
+ ).body()?.metadata?.toOmhFolder(parentFolder?.id ?: "")
+ }
+
+ suspend fun createFile(
+ name: String,
+ parentId: String?,
+ ): OmhStorageEntity.OmhFile? {
+ return uploadSmallFile(
+ name,
+ EMPTY_BYTE_ARRAY.toRequestBody(
+ APPLICATION_OCTET_STREAM_MEDIA_TYPE
+ ),
+ parentId
+ )
+ }
+
+ suspend fun deleteFile(fileId: String): Boolean {
+ apiService.deleteFile(PathRequestBody(fileId))
+ return true
+ }
+
+ suspend fun permanentlyDeleteFile(fileId: String): Boolean {
+ val response = apiService.permanentlyDeleteFile(PathRequestBody(fileId))
+ return if (response.isSuccessful) {
+ true
+ } else {
+ throw response.toApiException()
+ }
+ }
+
+ suspend fun getFileRevisions(
+ fileId: String,
+ ): List {
+ // Dropbox API requires the file path to get revisions
+ // so we need to fetch the metadata first
+ val nodeMetadata = getNodeMetadata(id = fileId)
+ if (nodeMetadata != null) {
+ val response = apiService.getFileRevisionList(
+ GetFileRevisionsRequest(path = nodeMetadata.path, mode = "path")
+ )
+
+ return if (response.isSuccessful) {
+ response.body()!!.toOmhFileRevisions()
+ } else {
+ throw response.toApiException()
+ }
+ } else {
+ return emptyList()
+ }
+ }
+
+ @Suppress("MagicNumber")
+ suspend fun downloadFile(
+ fileId: String
+ ): ByteArrayOutputStream {
+ val downloadResponse = contentApiService.downloadFile(serialize(PathRequestBody(fileId)))
+
+ if (downloadResponse.isSuccessful) {
+ return downloadResponse.body().toByteArrayOutputStream()
+ } else {
+ throw downloadResponse.toApiException()
+ }
+ }
+
+ @Suppress("UnusedPrivateMember")
+ suspend fun downloadFileVersion(
+ fileId: String, // File ID is not used for Dropbox
+ versionId: String,
+ ): ByteArrayOutputStream {
+ val response = contentApiService.downloadFile(serialize(PathRequestBody("rev:$versionId")))
+
+ return if (response.isSuccessful) {
+ response.body().toByteArrayOutputStream()
+ } else {
+ throw response.toApiException()
+ }
+ }
+
+ /**
+ * Implemented as best effort only. Unused for the time being
+ */
+ @Suppress("MagicNumber")
+ suspend fun exportFile(
+ fileId: String,
+ exportedMimeType: String,
+ ): ByteArrayOutputStream {
+ val fileMetaData = getNodeMetadata(id = fileId)
+
+ return fileMetaData?.run {
+ require(fileMetaData is FileMetadata)
+ if (false == fileMetaData.exportInfo?.exportOptions?.contains(exportedMimeType)) {
+ throw IllegalArgumentException(
+ "Requested type $exportedMimeType not supported by file" +
+ " Supported types: " +
+ fileMetaData.exportInfo.exportOptions.joinToString(",")
+ )
+ }
+ val response = contentApiService.exportFile(
+ serialize(ExportFileRequest(fileId, exportedMimeType))
+ )
+ if (response.isSuccessful) {
+ response.body().toByteArrayOutputStream()
+ } else {
+ throw response.toApiException()
+ }
+ } ?: throw OmhStorageException.ApiException(
+ 404,
+ "File not found",
+ null,
+ )
+ }
+
+ suspend fun getFilesList(parentId: String): List {
+ val retval = mutableListOf()
+ var result =
+ apiService.getFilesList(
+ ListFolderRequestBody(
+ path = parentId.ifEmpty { "" },
+ ),
+ ).body()
+ var hasMore = result?.hasMore
+ var cursor = result?.cursor
+ result?.entries?.let { list ->
+ retval.addAll(
+ list.map { nodeMetaData ->
+ when (nodeMetaData) {
+ is FileMetadata ->
+ nodeMetaData.toOmhFile(parentId)
+ is FolderMetadata ->
+ nodeMetaData.toOmhFolder(parentId)
+ }
+ }
+ )
+ }
+ while (true == hasMore && cursor != null) {
+ result =
+ apiService.continueGetFilesList(
+ ContinueRequest(cursor),
+ ).body()
+ hasMore = result?.hasMore
+ cursor = result?.cursor
+ result?.entries?.let { list ->
+ retval.addAll(
+ list.map { nodeMetaData ->
+ when (nodeMetaData) {
+ is FileMetadata ->
+ nodeMetaData.toOmhFile(parentId)
+ is FolderMetadata ->
+ nodeMetaData.toOmhFolder(parentId)
+ }
+ }
+ )
+ }
+ }
+ return retval
+ }
+
+ suspend fun updateFile(
+ localFileToUpload: File,
+ fileId: String,
+ ): OmhStorageEntity.OmhFile? {
+ val file = getNodeMetadata(id = fileId) as? FileMetadata
+ val folder = getNodeMetadata(path = file?.path?.substringBeforeLast('/'))
+ val parentId = folder?.id ?: ""
+ return if (localFileToUpload.length() < smallFileLimit) {
+ uploadSmallFile(
+ localFileToUpload.name,
+ localFileToUpload.asRequestBody(contentType = APPLICATION_OCTET_STREAM_MEDIA_TYPE),
+ parentId,
+ createMode = CreateFileRequestBody.MODE_OVERWRITE,
+ )
+ } else {
+ uploadBigFile(localFileToUpload, parentId, mode = CreateFileRequestBody.MODE_OVERWRITE)
+ }
+ }
+
+ suspend fun uploadFile(
+ localFileToUpload: File,
+ parentId: String?,
+ ): OmhStorageEntity.OmhFile? {
+ return if (localFileToUpload.length() < smallFileLimit) {
+ uploadSmallFile(
+ localFileToUpload.name,
+ localFileToUpload.asRequestBody(contentType = APPLICATION_OCTET_STREAM_MEDIA_TYPE),
+ parentId,
+ )
+ } else {
+ uploadBigFile(localFileToUpload, parentId)
+ }
+ }
+
+ suspend fun getTemporaryLink(
+ fileId: String,
+ ): String? {
+ return when (val fileMetaData = getNodeMetadata(id = fileId)) {
+ is FileMetadata -> apiService.getFileSharingMetadata(
+ GetFileSharingMetadataRequestBody(fileMetaData.id)
+ ).body()?.previewUrl
+ is FolderMetadata -> if (fileMetaData.sharingInfo != null &&
+ (false == fileMetaData.sharingInfo.sharedFolderId?.isEmpty())
+ ) {
+ apiService.getFolderSharingMetadata(
+ GetFolderSharingMetadataRequestBody(fileMetaData.sharingInfo.sharedFolderId)
+ ).body()?.previewUrl
+ } else {
+ null
+ }
+ else -> null
+ }
+ }
+
+ suspend fun getSpaceUsage(): GetSpaceUsageResponse {
+ val response = apiService.getSpaceUsage()
+
+ return if (response.isSuccessful) {
+ response.body()!!
+ } else {
+ throw response.toApiException()
+ }
+ }
+
+ suspend fun getFileMetadata(
+ fileId: String,
+ ): OmhStorageMetadata? {
+ val jsonString = getNodeMetadataRaw(id = fileId)
+ return if (jsonString != null) {
+ val json = JSONObject(jsonString)
+ val objectMapper = DropboxRetrofitImpl.objectMapper
+ when (json.getString(NodeMetadata.ATTR_TAG)) {
+ NodeMetadata.TAG_FILE -> {
+ val fileMetadata: FileMetadata =
+ objectMapper.readerFor(FileMetadata::class.java).readValue(jsonString)
+ OmhStorageMetadata(
+ entity = fileMetadata.toOmhFile(parentId = ""),
+ originalMetadata = json
+ )
+ }
+ NodeMetadata.TAG_FOLDER -> {
+ val folderMetadata: FolderMetadata =
+ objectMapper.readerFor(FolderMetadata::class.java).readValue(jsonString)
+ OmhStorageMetadata(
+ entity = folderMetadata.toOmhFolder(parentId = ""),
+ originalMetadata = json
+ )
+ }
+ else -> null
+ }
+ } else {
+ null
+ }
+ }
+
+ suspend fun getNodeMetadata(
+ id: String? = null,
+ path: String? = null,
+ ): NodeMetadata? {
+ val response = getNodeMetadataRaw(id, path)
+ if (response == null) {
+ return null
+ } else {
+ val json = JSONObject(response)
+ return jsonToNodeMetadata(json)
+ }
+ }
+
+ suspend fun search(
+ query: String,
+ ): SearchResultResponse {
+ val matchesList = mutableListOf()
+ var response = searchRaw(SearchFileRequest(query = query))
+ var hasMore = response.optBoolean("has_more", false)
+ var cursor = response.optString("cursor", "")
+
+ // Add initial matches
+ if (response.has("matches")) {
+ val matches = response.getJSONArray("matches")
+ for (i in 0 until matches.length()) {
+ val item = matches.getJSONObject(i)
+ matchesList.add(jsonToNodeMetadataForSearchResult(item))
+ }
+ } else {
+ throw OmhStorageException.ApiException(
+ 400,
+ "Invalid search response",
+ null,
+ )
+ }
+
+ // Continue fetching while has_more is true and cursor is present
+ while (hasMore && cursor.isNotEmpty()) {
+ response = JSONObject(
+ apiService.continueSearch(
+ SearchFileRequest.SearchByCursor(cursor)
+ ).body()?.string() ?: "{}"
+ )
+ val matches = response.optJSONArray("matches")
+ if (matches != null) {
+ for (i in 0 until matches.length()) {
+ val item = matches.getJSONObject(i)
+ matchesList.add(jsonToNodeMetadataForSearchResult(item))
+ }
+ }
+ hasMore = response.optBoolean("has_more", false)
+ cursor = response.optString("cursor", "")
+ }
+
+ return SearchResultResponse(matches = matchesList)
+ }
+
+ suspend fun getNodePermission(id: String): List {
+ val node = getNodeMetadata(id = id)
+ return when (node) {
+ is FileMetadata -> getFileSharedMembersInternal(id)
+ is FolderMetadata -> {
+ if (node.sharedFolderId == null) {
+ // Folder is not yet shared, so no permissions
+ emptyList()
+ } else {
+ getFolderSharedMembersInternal(requireNotNull(node.sharedFolderId))
+ }
+ }
+ else -> throw OmhStorageException.ApiException(
+ 404,
+ "Node not found",
+ null,
+ )
+ }
+ }
+
+ suspend fun createNodePermission(
+ id: String,
+ permission: OmhCreatePermission,
+ addMessageAsComment: Boolean = false,
+ customMessage: String? = null,
+ quiet: Boolean = false,
+ ): OmhPermission {
+ val nodeMetadata = getNodeMetadata(id = id)
+ return when (nodeMetadata) {
+ is FileMetadata -> {
+ val response = apiService.addFileSharedMember(
+ AddFileSharedMemberRequest(
+ members = listOf(
+ (permission as OmhCreatePermission.CreateIdentityPermission)
+ .toMemberSelector(),
+ ),
+ accessLevel = permission.role,
+ addMessageAsComment = addMessageAsComment,
+ customMessage = customMessage,
+ quiet = quiet,
+ fileId = id
+ )
+ )
+ if (response.isSuccessful) {
+ // Re-fetch permissions and return the created one
+ findCreatedPermissionOrThrow(id, permission)
+ } else {
+ throw response.toApiException()
+ }
+ }
+ is FolderMetadata -> {
+ val sharedFolderId: String = if (nodeMetadata.sharedFolderId != null) {
+ nodeMetadata.sharedFolderId
+ } else {
+ // Need to share the folder first and wait for completion
+ val objectMapper = DropboxRetrofitImpl.objectMapper
+ val job = requireNotNull(apiService.shareFolder(ShareFolderRequestBody(id, true)).body())
+
+ // Poll job status with retry mechanism
+ val completedResponseString = pollShareJobUntilComplete(job.jobId)
+ val shareFolderResponse: ShareFolderResponse =
+ objectMapper.readerFor(ShareFolderResponse::class.java)
+ .readValue(completedResponseString)
+ shareFolderResponse.sharedFolderId
+ }
+ val response = apiService.addFolderSharedMember(
+ AddFolderSharedMemberRequest(
+ members = listOf(
+ AddFolderMember(
+ member =
+ (permission as OmhCreatePermission.CreateIdentityPermission)
+ .toMemberSelector(),
+ accessLevel = permission.role
+ )
+ ),
+ customMessage = customMessage,
+ quiet = quiet,
+ sharedFolderId = sharedFolderId
+ )
+ )
+ if (response.isSuccessful) {
+ // Re-fetch permissions and return the created one
+ findCreatedPermissionOrThrow(id, permission)
+ } else {
+ throw response.toApiException()
+ }
+ }
+ else -> throw OmhStorageException.ApiException(
+ 404,
+ "Node not found",
+ null,
+ )
+ }
+ }
+
+ private fun shareJobIsCompleted(response: ResponseBody?): Boolean {
+ return (
+ response != null && JSONObject(response.string()).let { json ->
+ json.has(".tag") && json.getString(".tag") == "complete"
+ }
+ )
+ }
+
+ /**
+ * Polls the share job status until completion with exponential backoff.
+ *
+ * @param jobId The job ID to poll
+ * @return The completed response string
+ * @throws OmhStorageException.ApiException if job fails or times out
+ */
+ private suspend fun pollShareJobUntilComplete(jobId: String): String {
+ var attempt = 0
+ var delayMs = SHARE_JOB_INITIAL_DELAY_MS
+
+ while (attempt < SHARE_JOB_MAX_ATTEMPTS) {
+ val response = apiService.checkShareJobStatus(CheckShareJobStatusRequest(jobId))
+
+ if (!response.isSuccessful) {
+ throw response.toApiException()
+ }
+
+ val responseBody = requireNotNull(response.body())
+ val responseString = responseBody.string()
+ val json = JSONObject(responseString)
+
+ when {
+ json.has(".tag") && json.getString(".tag") == "complete" -> {
+ // Job completed successfully, return the response string
+ logger.debug("Share job $jobId completed after $attempt attempts")
+ return responseString
+ }
+ json.has(".tag") && json.getString(".tag") == "failed" -> {
+ // Job failed
+ val errorMessage = json.optString("error", "Share job failed")
+ logger.error("Share job $jobId failed: $errorMessage")
+ throw OmhStorageException.ApiException(
+ 500,
+ "Share job failed: $errorMessage",
+ null
+ )
+ }
+ json.has(".tag") && json.getString(".tag") == "in_progress" -> {
+ // Job still in progress, wait and retry
+ logger.debug("Share job $jobId still in progress, attempt ${attempt + 1}/$SHARE_JOB_MAX_ATTEMPTS")
+ delay(delayMs)
+
+ // Exponential backoff with max delay cap
+ delayMs = minOf(
+ (delayMs * SHARE_JOB_BACKOFF_MULTIPLIER).toLong(),
+ SHARE_JOB_MAX_DELAY_MS
+ )
+ attempt++
+ }
+ else -> {
+ // Unknown status
+ logger.warn("Unknown share job status for job $jobId: ${json.optString(".tag", "unknown")}")
+ delay(delayMs)
+ delayMs = minOf(
+ (delayMs * SHARE_JOB_BACKOFF_MULTIPLIER).toLong(),
+ SHARE_JOB_MAX_DELAY_MS
+ )
+ attempt++
+ }
+ }
+ }
+
+ // Timeout reached
+ logger.error("Share job $jobId timed out after $SHARE_JOB_MAX_ATTEMPTS attempts")
+ throw OmhStorageException.ApiException(
+ 408,
+ "Share job timed out after $SHARE_JOB_MAX_ATTEMPTS attempts",
+ null
+ )
+ }
+
+ suspend fun updateNodePermission(
+ id: String,
+ permissionId: String,
+ role: OmhPermissionRole
+ ): OmhPermission {
+ val metadata = getNodeMetadata(id = id)
+ return when (metadata) {
+ is FileMetadata -> {
+ val response = apiService.updateFileSharedMember(
+ UpdateFileSharedMemberRequest(
+ accessLevel = role,
+ member = permissionId.toMemberSelector(),
+ fileId = id
+ )
+ )
+ if (response.isSuccessful) {
+ // Re-fetch permissions and return the updated one by permissionId
+ findUpdatedPermissionOrThrow(id, permissionId)
+ } else {
+ throw response.toApiException()
+ }
+ }
+ is FolderMetadata -> {
+ val sharedFolderId = metadata.sharedFolderId ?: throw OmhStorageException.ApiException(
+ 404,
+ "Folder is not shared",
+ null
+ )
+ val response = apiService.updateFolderSharedMember(
+ UpdateFolderSharedMemberRequest(
+ accessLevel = role,
+ member = permissionId.toMemberSelector(),
+ sharedFolderId = sharedFolderId
+ )
+ )
+ if (response.isSuccessful) {
+ // Re-fetch permissions and return the updated one by permissionId
+ findUpdatedPermissionOrThrow(id, permissionId)
+ } else {
+ throw response.toApiException()
+ }
+ }
+ else -> throw OmhStorageException.ApiException(
+ 404,
+ "Node not found",
+ null,
+ )
+ }
+ }
+
+ suspend fun deleteNodePermission(
+ id: String,
+ permissionId: String,
+ ): Boolean {
+ val nodeMetadata = getNodeMetadata(id = id)
+ return when (nodeMetadata) {
+ is FileMetadata -> {
+ apiService.deleteFileSharedMembers(
+ DeleteFileSharedMemberRequest(
+ fileId = id,
+ memberId = permissionId
+ )
+ )
+ true
+ }
+ is FolderMetadata -> {
+ apiService.deleteFolderSharedMembers(
+ DeleteFolderSharedMemberRequest(
+ sharedFolderId = requireNotNull(nodeMetadata.sharedFolderId),
+ memberId = permissionId
+ )
+ )
+ true
+ }
+ else -> throw OmhStorageException.ApiException(
+ 404,
+ "Node not found",
+ null,
+ )
+ }
+ }
+
+ private fun jsonToNodeMetadata(
+ json: JSONObject,
+ ): NodeMetadata {
+ return jsonToNodeMetadataInternal(
+ json,
+ json.getString(NodeMetadata.ATTR_TAG)
+ )
+ }
+
+ private fun jsonToNodeMetadataForSearchResult(
+ json: JSONObject,
+ ): NodeMetadata {
+ val metadata = json.getJSONObject("metadata").getJSONObject("metadata")
+ return jsonToNodeMetadataInternal(
+ metadata,
+ metadata.getString(".tag")
+ )
+ }
+
+ private fun jsonToNodeMetadataInternal(
+ json: JSONObject,
+ tag: String
+ ): NodeMetadata {
+ val objectMapper = DropboxRetrofitImpl.objectMapper
+ return when (tag) {
+ NodeMetadata.TAG_FILE ->
+ objectMapper
+ .readerFor(FileMetadata::class.java)
+ .readValue(json.toString())
+ NodeMetadata.TAG_FOLDER ->
+ objectMapper
+ .readerFor(FolderMetadata::class.java)
+ .readValue(json.toString())
+ else -> throw IllegalArgumentException(
+ "Unknown node metadata type: $tag"
+ )
+ }
+ }
+
+ private suspend fun getNodeMetadataRaw(
+ id: String? = null,
+ path: String? = null,
+ ): String? {
+ val request =
+ if (id != null) {
+ NodeMetadataRequest(path = id)
+ } else {
+ NodeMetadataRequest(path = path ?: "")
+ }
+ return apiService.getNodeMetaData(request).body()?.string()
+ }
+
+ private suspend fun uploadSmallFile(
+ filename: String,
+ requestBody: RequestBody,
+ parentId: String?,
+ createMode: String = CreateFileRequestBody.MODE_ADD,
+ ): OmhStorageEntity.OmhFile? {
+ val parentFolder =
+ if (!parentId.isNullOrEmpty()) {
+ getNodeMetadata(path = parentId)
+ } else {
+ null
+ }
+
+ val path = "${parentFolder?.path ?: ""}/$filename"
+
+ return contentApiService.createFile(
+ serialize(
+ CreateFileRequestBody(
+ path = path,
+ mode = createMode
+ ),
+ ).escapeUnicode(),
+ requestBody,
+ ).body()?.toOmhFile(parentFolder?.id ?: "")
+ }
+
+ private suspend fun uploadBigFile(
+ localFileToUpload: File,
+ parentId: String?,
+ mode: String = CreateFileRequestBody.MODE_ADD,
+ ): OmhStorageEntity.OmhFile? {
+ val parentFolder =
+ if (!parentId.isNullOrEmpty()) {
+ getNodeMetadata(path = parentId)
+ } else {
+ null
+ }
+ val path = "${parentFolder?.path ?: ""}/${localFileToUpload.name}"
+
+ val session =
+ requireNotNull(
+ contentApiService.createUploadFileSession(
+ serialize(
+ UploadSessionRequest()
+ ).escapeUnicode()
+ ).body(),
+ )
+
+ val chunkSize = smallFileLimit
+ var bytesRead = 0
+ var offset = 0
+ val bytes = ByteArray(chunkSize)
+ val filebin = localFileToUpload.inputStream()
+
+ while (withContext(Dispatchers.IO) {
+ filebin.read(bytes)
+ }.also { bytesRead = it } != -1
+ ) {
+ contentApiService.continueUploadFile(
+ serialize(
+ AppendUploadSessionRequest(
+ cursor = UploadSessionCursor(offset.toLong(), session.sessionId),
+ ),
+ ),
+ bytes.toRequestBody(
+ contentType = APPLICATION_OCTET_STREAM_MEDIA_TYPE,
+ offset = 0,
+ byteCount = bytesRead,
+ ),
+ )
+ offset += bytesRead
+ bytes.fill(0)
+ }
+
+ val finishUploadResult =
+ requireNotNull(
+ contentApiService.finishUploadFile(
+ serialize(
+ FinishUploadSessionRequestBody(
+ CreateFileRequestBody(
+ path = path,
+ mode = mode
+ ),
+ UploadSessionCursor(offset.toLong(), session.sessionId),
+ ),
+ ).escapeUnicode(),
+ EMPTY_BYTE_ARRAY.toRequestBody(
+ contentType = APPLICATION_OCTET_STREAM_MEDIA_TYPE
+ ),
+ ),
+ )
+
+ return finishUploadResult.body()?.toOmhFile(parentFolder?.id ?: "")
+ }
+
+ private suspend fun searchRaw(searchRequest: SearchFileRequest): JSONObject {
+ val response = apiService.search(searchRequest)
+
+ return if (response.isSuccessful) {
+ JSONObject(requireNotNull(response.body()?.string()))
+ } else {
+ throw response.toApiException()
+ }
+ }
+
+ @Suppress("StringLiteralDuplication")
+ private suspend fun getFileSharedMembersInternal(
+ id: String,
+ ): List {
+ var response = apiService.listFileSharedMembers(ListFileSharedMembersRequest(id))
+ if (!response.isSuccessful) {
+ throw response.toApiException()
+ } else {
+ val retval = mutableListOf()
+
+ var responseJson = JSONObject(response.body()?.string() ?: "{}")
+ if (!responseJson.has("groups")) {
+ logger.error("Invalid response from Dropbox API")
+ if (logger.isDebugEnabled) {
+ logger.debug("Response: $responseJson")
+ }
+ throw OmhStorageException.ApiException(
+ 400,
+ "Invalid response from Dropbox API",
+ null,
+ )
+ } else {
+ var groups = responseJson.getJSONArray("groups").groupsToOmhPermissionList()
+ retval.addAll(groups)
+ var users = responseJson.getJSONArray("users").usersToOmhPermissionList()
+ retval.addAll(users)
+
+ var cursor = responseJson.optString("cursor", "")
+
+ while (!cursor.isNullOrEmpty()) {
+ response = apiService.continueListFileSharedMembers(
+ ContinueRequest(cursor)
+ )
+ responseJson = JSONObject(response.body()?.string() ?: "{}")
+
+ if (!responseJson.has("groups")) {
+ logger.error("Invalid response from Dropbox API")
+ if (logger.isDebugEnabled) {
+ logger.debug("Response: $responseJson")
+ }
+ return retval
+ } else {
+ cursor = responseJson.optString("cursor", "")
+ groups = responseJson.getJSONArray("groups").groupsToOmhPermissionList()
+ retval.addAll(groups)
+ users = responseJson.getJSONArray("users").usersToOmhPermissionList()
+ retval.addAll(users)
+ }
+ }
+
+ return retval
+ }
+ }
+ }
+
+ private suspend fun getFolderSharedMembersInternal(
+ id: String,
+ ): List {
+ var response = apiService.listFolderSharedMembers(ListFolderSharedMembersRequest(id))
+ if (!response.isSuccessful) {
+ throw response.toApiException()
+ } else {
+ val retval = mutableListOf()
+
+ var responseJson = JSONObject(response.body()?.string() ?: "{}")
+ if (!responseJson.has("groups")) {
+ logger.error("Invalid response from Dropbox API")
+ if (logger.isDebugEnabled) {
+ logger.debug("Response: $responseJson")
+ }
+ throw OmhStorageException.ApiException(
+ 400,
+ "Invalid response from Dropbox API",
+ null,
+ )
+ } else {
+ var groups = responseJson.getJSONArray("groups").groupsToOmhPermissionList()
+ retval.addAll(groups)
+ var users = responseJson.getJSONArray("users").usersToOmhPermissionList()
+ retval.addAll(users)
+
+ var cursor = responseJson.optString("cursor", "")
+
+ while (!cursor.isNullOrEmpty()) {
+ response = apiService.continueListFolderSharedMembers(
+ ContinueRequest(cursor)
+ )
+ responseJson = JSONObject(response.body()?.string() ?: "{}")
+
+ if (!responseJson.has("groups")) {
+ logger.error("Invalid response from Dropbox API")
+ if (logger.isDebugEnabled) {
+ logger.debug("Response: $responseJson")
+ }
+ return retval
+ } else {
+ cursor = responseJson.optString("cursor", "")
+ groups = responseJson.getJSONArray("groups").groupsToOmhPermissionList()
+ retval.addAll(groups)
+ users = responseJson.getJSONArray("users").usersToOmhPermissionList()
+ retval.addAll(users)
+ }
+ }
+
+ return retval
+ }
+ }
+ }
+
+ // Helper to locate the created permission in the refreshed list
+ private suspend fun findCreatedPermissionOrThrow(
+ id: String,
+ permission: OmhCreatePermission,
+ ): OmhPermission {
+ val permissions = getNodePermission(id)
+ val match = permissions.firstOrNull { perm ->
+ when (perm) {
+ is OmhPermission.IdentityPermission -> {
+ when (permission) {
+ is OmhCreatePermission.CreateIdentityPermission -> {
+ val recipient = permission.recipient
+ when (recipient) {
+ is com.openmobilehub.android.storage.core.model.OmhPermissionRecipient.User ->
+ (perm.identity as? OmhIdentity.User)
+ ?.emailAddress?.equals(recipient.emailAddress, ignoreCase = true) == true
+ is com.openmobilehub.android.storage.core.model.OmhPermissionRecipient.WithObjectId -> {
+ when (val ident = perm.identity) {
+ is OmhIdentity.User -> ident.id == recipient.id
+ is OmhIdentity.Group -> ident.id == recipient.id
+ else -> false
+ }
+ }
+ else -> false
+ }
+ }
+ }
+ }
+ }
+ }
+ return match ?: throw OmhStorageException.ApiException(
+ message = "Create succeeded but API failed to return expected permission"
+ )
+ }
+
+ private suspend fun findUpdatedPermissionOrThrow(
+ id: String,
+ permissionId: String,
+ ): OmhPermission {
+ val permissions = getNodePermission(id)
+ val match = permissions.firstOrNull { perm ->
+ (perm as? OmhPermission.IdentityPermission)?.id == permissionId
+ }
+ return match ?: throw OmhStorageException.ApiException(
+ message = "Updated succeeded but API failed to return expected permission"
+ )
+ }
+
+ private fun serialize(obj: Any): String =
+ DropboxRetrofitImpl.objectMapper.writeValueAsString(obj)
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/retrofit/DropboxRetrofitImpl.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/retrofit/DropboxRetrofitImpl.kt
new file mode 100644
index 00000000..db68d438
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/retrofit/DropboxRetrofitImpl.kt
@@ -0,0 +1,73 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.retrofit
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.module.SimpleModule
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import com.openmobilehub.android.auth.core.OmhAuthClient
+import com.openmobilehub.android.storage.core.restful.common.data.repository.StorageAuthenticator
+import com.openmobilehub.android.storage.core.restful.common.utils.accessToken
+import com.openmobilehub.android.storage.plugin.dropbox.restful.BuildConfig
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.DropboxApiService
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.DropboxContentApiService
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ListFolderResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ListFolderResponseDeserializer
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.jackson.JacksonConverterFactory
+
+internal class DropboxRetrofitImpl(private val omhAuthClient: OmhAuthClient) {
+
+ companion object {
+ val objectMapper: ObjectMapper = ObjectMapper().registerModule(KotlinModule())
+ }
+
+ val dropboxApiService: DropboxApiService = Retrofit.Builder()
+ .client(createOkHttpClient())
+ .addConverterFactory(createConverterFactory())
+ .baseUrl(BuildConfig.DROPBOX_API_URL)
+ .build().create(DropboxApiService::class.java)
+
+ val dropboxContentApiService: DropboxContentApiService = Retrofit.Builder()
+ .client(createOkHttpClient())
+ .addConverterFactory(createConverterFactory())
+ .baseUrl(BuildConfig.DROPBOX_CONTENT_API_URL)
+ .build().create(DropboxContentApiService::class.java)
+
+ private fun createOkHttpClient(): OkHttpClient {
+ val authenticator = StorageAuthenticator(omhAuthClient)
+ return OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ val request = setupRequestInterceptor(chain)
+ chain.proceed(request)
+ }
+ .addInterceptor(
+ HttpLoggingInterceptor().apply {
+ if (BuildConfig.DEBUG) setLevel(HttpLoggingInterceptor.Level.BODY)
+ },
+ )
+ .authenticator(authenticator)
+ .build()
+ }
+
+ private fun setupRequestInterceptor(chain: Interceptor.Chain) = chain
+ .request()
+ .newBuilder()
+ .addHeader(
+ StorageAuthenticator.HEADER_AUTHORIZATION_NAME,
+ StorageAuthenticator.BEARER.format(omhAuthClient.accessToken.orEmpty()),
+ )
+ .build()
+
+ private fun createConverterFactory() = JacksonConverterFactory.create(
+ ObjectMapper()
+ .registerModule(KotlinModule.Builder().build())
+ .registerModule(
+ SimpleModule().addDeserializer(
+ ListFolderResponse::class.java,
+ ListFolderResponseDeserializer()
+ )
+ ),
+ )
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/AddNodeSharedMemberRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/AddNodeSharedMemberRequest.kt
new file mode 100644
index 00000000..72c016c3
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/AddNodeSharedMemberRequest.kt
@@ -0,0 +1,58 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.annotation.JsonSerialize
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.AddFolderMember
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.AddFolderMemberListDeserializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.AddFolderMemberListSerializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.MemberSelector
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.MemberSelectorListDeserializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.MemberSelectorListSerializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.OmhPermissionRoleDeserializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.OmhPermissionRoleSerializer
+
+@Keep
+sealed interface AddNodeSharedMemberRequest {
+ val customMessage: String?
+ val quiet: Boolean
+}
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class AddFileSharedMemberRequest(
+ @JsonProperty("members")
+ @JsonSerialize(using = MemberSelectorListSerializer::class)
+ @JsonDeserialize(using = MemberSelectorListDeserializer::class)
+ val members: List,
+ @JsonProperty("access_level")
+ @JsonSerialize(using = OmhPermissionRoleSerializer::class)
+ @JsonDeserialize(using = OmhPermissionRoleDeserializer::class)
+ val accessLevel: OmhPermissionRole,
+ @JsonProperty("add_message_as_comment")
+ val addMessageAsComment: Boolean = false,
+ @JsonProperty("custom_message")
+ override val customMessage: String? = null,
+ @JsonProperty("quiet")
+ override val quiet: Boolean,
+ @JsonProperty("file")
+ val fileId: String
+) : AddNodeSharedMemberRequest
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class AddFolderSharedMemberRequest(
+ @JsonProperty("members")
+ @JsonSerialize(using = AddFolderMemberListSerializer::class)
+ @JsonDeserialize(using = AddFolderMemberListDeserializer::class)
+ val members: List,
+ @JsonProperty("custom_message")
+ override val customMessage: String? = null,
+ @JsonProperty("quiet")
+ override val quiet: Boolean,
+ @JsonProperty("shared_folder_id")
+ val sharedFolderId: String
+) : AddNodeSharedMemberRequest
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/AppendUploadSessionRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/AppendUploadSessionRequest.kt
new file mode 100644
index 00000000..bbf48661
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/AppendUploadSessionRequest.kt
@@ -0,0 +1,12 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+internal data class AppendUploadSessionRequest(
+ @JsonProperty("close")
+ val close: Boolean? = false,
+ @JsonProperty("cursor")
+ val cursor: UploadSessionCursor
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/CheckShareJobStatusRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/CheckShareJobStatusRequest.kt
new file mode 100644
index 00000000..ed69628b
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/CheckShareJobStatusRequest.kt
@@ -0,0 +1,10 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+data class CheckShareJobStatusRequest(
+ @JsonProperty("async_job_id")
+ val jobId: String
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ContinueRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ContinueRequest.kt
new file mode 100644
index 00000000..3fa58e7f
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ContinueRequest.kt
@@ -0,0 +1,10 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+data class ContinueRequest(
+ @JsonProperty("cursor")
+ val cursor: String
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/CreateFileRequestBody.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/CreateFileRequestBody.kt
new file mode 100644
index 00000000..9a354638
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/CreateFileRequestBody.kt
@@ -0,0 +1,23 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+internal data class CreateFileRequestBody(
+ @JsonProperty("autorename")
+ val autoRename: Boolean? = false,
+ @JsonProperty("mode")
+ val mode: String? = MODE_ADD,
+ @JsonProperty("mute")
+ val mute: Boolean? = false,
+ @JsonProperty("path")
+ val path: String,
+ @JsonProperty("strict_conflict")
+ val strictConflict: Boolean? = false
+) {
+ companion object {
+ const val MODE_ADD = "add"
+ const val MODE_OVERWRITE = "overwrite"
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/CreateFolderRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/CreateFolderRequest.kt
new file mode 100644
index 00000000..b3ede835
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/CreateFolderRequest.kt
@@ -0,0 +1,14 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class CreateFolderRequest(
+ @JsonProperty("path")
+ val path: String,
+ @JsonProperty("autorename")
+ val autoRename: Boolean = false,
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/DeleteNodeSharedMemberRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/DeleteNodeSharedMemberRequest.kt
new file mode 100644
index 00000000..6b895728
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/DeleteNodeSharedMemberRequest.kt
@@ -0,0 +1,43 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.annotation.JsonSerialize
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.MemberSelector
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.MemberSelectorDeserializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.MemberSelectorSerializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toMemberSelector
+
+sealed interface DeleteNodeSharedMemberRequest {
+ val member: MemberSelector
+}
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class DeleteFileSharedMemberRequest(
+ @JsonProperty("file")
+ val fileId: String,
+ private val memberId: String
+) : DeleteNodeSharedMemberRequest {
+ @Keep
+ @JsonProperty("member")
+ @JsonSerialize(using = MemberSelectorSerializer::class)
+ @JsonDeserialize(using = MemberSelectorDeserializer::class)
+ override val member: MemberSelector = memberId.toMemberSelector()
+}
+
+data class DeleteFolderSharedMemberRequest(
+ @JsonProperty("leave_a_copy")
+ val leaveACopy: Boolean = false,
+ @JsonProperty("shared_folder_id")
+ val sharedFolderId: String,
+ private val memberId: String
+) : DeleteNodeSharedMemberRequest {
+ @Keep
+ @JsonProperty("member")
+ @JsonSerialize(using = MemberSelectorSerializer::class)
+ @JsonDeserialize(using = MemberSelectorDeserializer::class)
+ override val member: MemberSelector = memberId.toMemberSelector()
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ExportFileRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ExportFileRequest.kt
new file mode 100644
index 00000000..bc82895d
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ExportFileRequest.kt
@@ -0,0 +1,14 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ExportFileRequest(
+ @JsonProperty("path")
+ val path: String,
+ @JsonProperty("export_format")
+ val exportFormat: String? = null
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/FinishUploadSessionRequestBody.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/FinishUploadSessionRequestBody.kt
new file mode 100644
index 00000000..816599e9
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/FinishUploadSessionRequestBody.kt
@@ -0,0 +1,12 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+internal data class FinishUploadSessionRequestBody(
+ @JsonProperty("commit")
+ val createFileRequestBody: CreateFileRequestBody,
+ @JsonProperty("cursor")
+ val uploadSessionCursor: UploadSessionCursor
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/GetFileRevisionsRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/GetFileRevisionsRequest.kt
new file mode 100644
index 00000000..65de65bd
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/GetFileRevisionsRequest.kt
@@ -0,0 +1,38 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.databind.JsonSerializer
+import com.fasterxml.jackson.databind.SerializerProvider
+import com.fasterxml.jackson.databind.annotation.JsonSerialize
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonSerialize(using = GetFileRevisionsSerializer::class)
+data class GetFileRevisionsRequest(
+ @JsonProperty("path")
+ val path: String,
+ @JsonProperty("limit")
+ val limit: Int? = 100,
+ val mode: String? = "id",
+)
+
+internal class GetFileRevisionsSerializer : JsonSerializer() {
+ override fun serialize(
+ value: GetFileRevisionsRequest,
+ gen: JsonGenerator,
+ serializers: SerializerProvider
+ ) {
+ gen.writeStartObject()
+ gen.writeStringField("path", value.path)
+ value.limit?.let { gen.writeNumberField("limit", it) }
+ value.mode?.let {
+ gen.writeObjectFieldStart("mode")
+ gen.writeStringField(".tag", it)
+ gen.writeEndObject()
+ }
+ gen.writeEndObject()
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ListFolderRequestBody.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ListFolderRequestBody.kt
new file mode 100644
index 00000000..7350331f
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ListFolderRequestBody.kt
@@ -0,0 +1,20 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+data class ListFolderRequestBody(
+ @JsonProperty("path")
+ val path: String,
+ @JsonProperty("include_deleted")
+ val includeDeletedFiles: Boolean? = false,
+ @JsonProperty("include_has_explicit_shared_members")
+ val includeHasExplicitSharedMembers: Boolean? = false,
+ @JsonProperty("include_mounted_folders")
+ val includeMountedFolders: Boolean? = true,
+ @JsonProperty("include_non_downloadable_files")
+ val includeNonDownloadableFiles: Boolean? = true,
+ @JsonProperty("recursive")
+ val recursive: Boolean? = false
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ListNodeSharedMembersRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ListNodeSharedMembersRequest.kt
new file mode 100644
index 00000000..8393e930
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ListNodeSharedMembersRequest.kt
@@ -0,0 +1,25 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ListFileSharedMembersRequest(
+ @JsonProperty("file")
+ val id: String,
+ @JsonProperty("limit")
+ val limit: Int? = 300,
+ @JsonProperty("include_inherited")
+ val includeInherited: Boolean? = true
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ListFolderSharedMembersRequest(
+ @JsonProperty("shared_folder_id")
+ val sharedFolderId: String,
+ @JsonProperty("limit")
+ val limit: Int? = 1000
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/NodeMetadataRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/NodeMetadataRequest.kt
new file mode 100644
index 00000000..f8a861ac
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/NodeMetadataRequest.kt
@@ -0,0 +1,16 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+data class NodeMetadataRequest(
+ @JsonProperty("path")
+ val path: String? = null,
+ @JsonProperty("include_deleted")
+ val includeDeletedFiles: Boolean? = false,
+ @JsonProperty("include_has_explicit_shared_members")
+ val includeHasExplicitSharedMembers: Boolean? = false,
+ @JsonProperty("include_media_info")
+ val includeMediaInfo: Boolean? = false
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/NodeSharingMetadataRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/NodeSharingMetadataRequest.kt
new file mode 100644
index 00000000..b0a5a634
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/NodeSharingMetadataRequest.kt
@@ -0,0 +1,16 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+internal data class GetFileSharingMetadataRequestBody(
+ @JsonProperty("file")
+ val file: String
+)
+
+@Keep
+internal data class GetFolderSharingMetadataRequestBody(
+ @JsonProperty("shared_folder_id")
+ val sharedFolderId: String
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/PathRequestBody.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/PathRequestBody.kt
new file mode 100644
index 00000000..d4b05f7e
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/PathRequestBody.kt
@@ -0,0 +1,10 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+internal data class PathRequestBody(
+ @JsonProperty("path")
+ val path: String
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/PathRequestWithAutoRename.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/PathRequestWithAutoRename.kt
new file mode 100644
index 00000000..940a4b73
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/PathRequestWithAutoRename.kt
@@ -0,0 +1,9 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+
+@Keep
+internal data class PathRequestWithAutoRename(
+ val path: String,
+ val autorename: Boolean
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/SearchFileRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/SearchFileRequest.kt
new file mode 100644
index 00000000..c3c89a45
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/SearchFileRequest.kt
@@ -0,0 +1,31 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class SearchFileRequest(
+ @JsonProperty("query")
+ val query: String,
+ @JsonProperty("options")
+ val options: SearchOptions = SearchOptions(),
+) {
+ @Keep
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ data class SearchOptions(
+ @JsonProperty("file_status")
+ val fileStatus: String = "active",
+ @JsonProperty("filename_only")
+ val filenameOnly: Boolean = false,
+ @JsonProperty("max_results")
+ val maxResults: Int = 100
+ )
+
+ @Keep
+ data class SearchByCursor(
+ @JsonProperty("cursor")
+ val cursor: String
+ )
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ShareFolderRequestBody.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ShareFolderRequestBody.kt
new file mode 100644
index 00000000..6d2b4e22
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/ShareFolderRequestBody.kt
@@ -0,0 +1,15 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+internal data class ShareFolderRequestBody(
+ @JsonProperty("path")
+ val path: String,
+ // Force async behaviour so that we always receive an async_job_id and can poll.
+ @JsonProperty("force_async")
+ val forceAsync: Boolean = true
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UpdateNodeSharedMemberRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UpdateNodeSharedMemberRequest.kt
new file mode 100644
index 00000000..8f4515f9
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UpdateNodeSharedMemberRequest.kt
@@ -0,0 +1,46 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.annotation.JsonSerialize
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.MemberSelector
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.MemberSelectorDeserializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.MemberSelectorSerializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.OmhPermissionRoleDeserializer
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.OmhPermissionRoleSerializer
+
+@Keep
+sealed interface UpdateNodeSharedMemberRequest {
+ val accessLevel: OmhPermissionRole
+ val member: MemberSelector
+}
+
+@Keep
+data class UpdateFileSharedMemberRequest(
+ @JsonProperty("access_level")
+ @JsonSerialize(using = OmhPermissionRoleSerializer::class)
+ @JsonDeserialize(using = OmhPermissionRoleDeserializer::class)
+ override val accessLevel: OmhPermissionRole,
+ @JsonProperty("member")
+ @JsonSerialize(using = MemberSelectorSerializer::class)
+ @JsonDeserialize(using = MemberSelectorDeserializer::class)
+ override val member: MemberSelector,
+ @JsonProperty("file")
+ val fileId: String
+) : UpdateNodeSharedMemberRequest
+
+@Keep
+data class UpdateFolderSharedMemberRequest(
+ @JsonProperty("access_level")
+ @JsonSerialize(using = OmhPermissionRoleSerializer::class)
+ @JsonDeserialize(using = OmhPermissionRoleDeserializer::class)
+ override val accessLevel: OmhPermissionRole,
+ @JsonProperty("member")
+ @JsonSerialize(using = MemberSelectorSerializer::class)
+ @JsonDeserialize(using = MemberSelectorDeserializer::class)
+ override val member: MemberSelector,
+ @JsonProperty("shared_folder_id")
+ val sharedFolderId: String
+) : UpdateNodeSharedMemberRequest
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UploadSessionCursor.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UploadSessionCursor.kt
new file mode 100644
index 00000000..9f772b83
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UploadSessionCursor.kt
@@ -0,0 +1,12 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+data class UploadSessionCursor(
+ @JsonProperty("offset")
+ val offset: Long,
+ @JsonProperty("session_id")
+ val sessionId: String
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UploadSessionRequest.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UploadSessionRequest.kt
new file mode 100644
index 00000000..3d9f2007
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UploadSessionRequest.kt
@@ -0,0 +1,10 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+internal data class UploadSessionRequest(
+ @JsonProperty("close")
+ val close: Boolean? = false
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/mapper/Mappers.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/mapper/Mappers.kt
new file mode 100644
index 00000000..d964203e
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/mapper/Mappers.kt
@@ -0,0 +1,140 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper
+
+import android.webkit.MimeTypeMap
+import com.openmobilehub.android.storage.core.model.OmhCreatePermission
+import com.openmobilehub.android.storage.core.model.OmhFileVersion
+import com.openmobilehub.android.storage.core.model.OmhIdentity
+import com.openmobilehub.android.storage.core.model.OmhPermission
+import com.openmobilehub.android.storage.core.model.OmhPermissionRecipient
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.core.model.OmhStorageEntity
+import com.openmobilehub.android.storage.core.utils.fromRFC3339StringToDate
+import com.openmobilehub.android.storage.plugin.dropbox.restful.Constants.emailRegex
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FileMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FolderMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ListFileRevisionsResponse
+import org.json.JSONArray
+import java.util.Date
+
+fun FileMetadata.toOmhFile(parentId: String): OmhStorageEntity.OmhFile {
+ return OmhStorageEntity.OmhFile(
+ mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
+ MimeTypeMap.getFileExtensionFromUrl(this.name)
+ ) ?: "application/octet-stream",
+ id = this.id,
+ name = this.name,
+ modifiedTime = this.serverModified?.fromRFC3339StringToDate() ?: Date(0L),
+ createdTime = this.clientModified?.fromRFC3339StringToDate() ?: Date(0L),
+ size = this.size.toInt(),
+ extension = MimeTypeMap.getFileExtensionFromUrl(this.name),
+ parentId = parentId
+ )
+}
+
+fun FolderMetadata.toOmhFolder(parentId: String): OmhStorageEntity.OmhFolder {
+ return OmhStorageEntity.OmhFolder(
+ id = this.id,
+ name = this.name,
+ parentId = parentId,
+ createdTime = null, // Folders typically do not have a creation time in Dropbox
+ modifiedTime = null
+ )
+}
+
+fun ListFileRevisionsResponse.toOmhFileRevisions(): List {
+ return this.entries.map { entry ->
+ OmhFileVersion(
+ fileId = entry.id,
+ versionId = entry.rev!!,
+ lastModified = entry.serverModified?.fromRFC3339StringToDate() ?: Date(0L)
+ )
+ }
+}
+
+fun JSONArray.groupsToOmhPermissionList(): List {
+ val permissions = mutableListOf()
+ for (i in 0 until this.length()) {
+ val group = this.getJSONObject(i)
+ group.let { identityPermission ->
+ val role = group.getString("role").toOmhPermissionRole()
+ if (role != null) {
+ permissions.add(
+ OmhPermission.IdentityPermission(
+ id = identityPermission.get("id").toString(),
+ role = role,
+ isInherited = identityPermission.getBoolean("is_inherited"),
+ identity = identityPermission.getJSONObject("group").let {
+ OmhIdentity.Group(
+ id = it.getString("id"),
+ displayName = it.getString("name"),
+ emailAddress = null,
+ expirationTime = null,
+ deleted = null
+ )
+ }
+ )
+ )
+ }
+ }
+ }
+ return permissions
+}
+
+fun JSONArray.usersToOmhPermissionList(): List {
+ val permissions = mutableListOf()
+ for (i in 0 until this.length()) {
+ val user = this.getJSONObject(i)
+ user.let { identityPermission ->
+ val role = identityPermission
+ .getJSONObject("access_type").getString(".tag").toOmhPermissionRole()
+ if (role != null) {
+ val user = identityPermission.getJSONObject("user")
+ permissions.add(
+ OmhPermission.IdentityPermission(
+ id = user.getString("account_id"),
+ role = role,
+ isInherited = identityPermission.getBoolean("is_inherited"),
+ identity = OmhIdentity.User(
+ id = user.getString("account_id"),
+ displayName = user.getString("display_name"),
+ emailAddress = user.optString("email", null),
+ expirationTime = null,
+ deleted = null,
+ photoLink = null,
+ pendingOwner = null
+ )
+ )
+ )
+ }
+ }
+ }
+ return permissions
+}
+
+fun String.toMemberSelector(): MemberSelector = if (emailRegex.matches(this)) {
+ MemberSelector(tag = "email", email = this)
+} else {
+ MemberSelector(tag = "dropbox_id", userId = this)
+}
+
+internal fun OmhCreatePermission.CreateIdentityPermission.toMemberSelector(): MemberSelector =
+ when (val recipient = recipient) {
+ OmhPermissionRecipient.Anyone -> throw UnsupportedOperationException("Unsupported recipient")
+ is OmhPermissionRecipient.Domain -> throw UnsupportedOperationException("Unsupported recipient")
+ is OmhPermissionRecipient.Group ->
+ throw UnsupportedOperationException("Use WithObjectId and provide group ID")
+
+ is OmhPermissionRecipient.User -> recipient.emailAddress.toMemberSelector()
+ is OmhPermissionRecipient.WithAlias -> throw UnsupportedOperationException("Unsupported recipient")
+ is OmhPermissionRecipient.WithObjectId -> recipient.id.toMemberSelector()
+ }
+
+private fun String.toOmhPermissionRole(): OmhPermissionRole? {
+ return when (this) {
+ "viewer" -> OmhPermissionRole.COMMENTER
+ "editor" -> OmhPermissionRole.WRITER
+ "owner" -> OmhPermissionRole.OWNER
+ "viewer_no_comment" -> OmhPermissionRole.READER
+ else -> null
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/mapper/MemberSelectorSerializers.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/mapper/MemberSelectorSerializers.kt
new file mode 100644
index 00000000..ae0e3ba8
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/mapper/MemberSelectorSerializers.kt
@@ -0,0 +1,187 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.JsonDeserializer
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.JsonSerializer
+import com.fasterxml.jackson.databind.SerializerProvider
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.annotation.JsonSerialize
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class MemberSelector(
+ @JsonProperty(".tag")
+ val tag: String,
+ @JsonProperty("email")
+ val email: String? = null,
+ @JsonProperty("dropbox_id")
+ val userId: String? = null
+)
+
+/**
+ * Represents a member with their access level for Dropbox sharing APIs.
+ * This follows the Dropbox API structure where each member has their own access_level.
+ */
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class AddFolderMember(
+ @JsonProperty("member")
+ val member: MemberSelector,
+ @JsonProperty("access_level")
+ @JsonSerialize(using = OmhPermissionRoleSerializer::class)
+ @JsonDeserialize(using = OmhPermissionRoleDeserializer::class)
+ val accessLevel: OmhPermissionRole
+)
+
+internal class MemberSelectorSerializer : JsonSerializer() {
+ override fun serialize(value: MemberSelector, gen: JsonGenerator, serializers: SerializerProvider) {
+ gen.writeStartObject()
+ gen.writeStringField(".tag", value.tag)
+ value.email?.let { gen.writeStringField("email", it) }
+ value.userId?.let { gen.writeStringField("dropbox_id", it) }
+ gen.writeEndObject()
+ }
+}
+
+internal class MemberSelectorListSerializer : JsonSerializer>() {
+ override fun serialize(value: List, gen: JsonGenerator, serializers: SerializerProvider) {
+ gen.writeStartArray()
+ value.forEach { memberSelector ->
+ gen.writeStartObject()
+ gen.writeStringField(".tag", memberSelector.tag)
+ memberSelector.email?.let { gen.writeStringField("email", it) }
+ memberSelector.userId?.let { gen.writeStringField("dropbox_id", it) }
+ gen.writeEndObject()
+ }
+ gen.writeEndArray()
+ }
+}
+
+internal class AddFolderMemberSerializer : JsonSerializer() {
+ override fun serialize(value: AddFolderMember, gen: JsonGenerator, serializers: SerializerProvider) {
+ gen.writeStartObject()
+
+ // Serialize member
+ gen.writeFieldName("member")
+ gen.writeStartObject()
+ gen.writeStringField(".tag", value.member.tag)
+ value.member.email?.let { gen.writeStringField("email", it) }
+ value.member.userId?.let { gen.writeStringField("dropbox_id", it) }
+ gen.writeEndObject()
+
+ // Serialize access level
+ gen.writeFieldName("access_level")
+ serializers.findValueSerializer(OmhPermissionRole::class.java).serialize(value.accessLevel, gen, serializers)
+
+ gen.writeEndObject()
+ }
+}
+
+internal class AddFolderMemberListSerializer : JsonSerializer>() {
+ override fun serialize(value: List, gen: JsonGenerator, serializers: SerializerProvider) {
+ gen.writeStartArray()
+ value.forEach { addMember ->
+ gen.writeStartObject()
+
+ // Serialize member
+ gen.writeFieldName("member")
+ gen.writeStartObject()
+ gen.writeStringField(".tag", addMember.member.tag)
+ addMember.member.email?.let { gen.writeStringField("email", it) }
+ addMember.member.userId?.let { gen.writeStringField("dropbox_id", it) }
+ gen.writeEndObject()
+
+ // Serialize access level
+ gen.writeFieldName("access_level")
+ gen.writeStartObject()
+ val accessLevelTag = when (addMember.accessLevel) {
+ OmhPermissionRole.OWNER -> "owner"
+ OmhPermissionRole.WRITER -> "editor"
+ OmhPermissionRole.READER -> "viewer"
+ OmhPermissionRole.COMMENTER -> "viewer" // Dropbox doesn't have commenter, map to viewer
+ }
+ gen.writeStringField(".tag", accessLevelTag)
+ gen.writeEndObject()
+
+ gen.writeEndObject()
+ }
+ gen.writeEndArray()
+ }
+}
+
+internal class MemberSelectorDeserializer : JsonDeserializer() {
+ override fun deserialize(p: JsonParser, ctxt: DeserializationContext): MemberSelector {
+ val node = p.codec.readTree(p)
+ return MemberSelector(
+ tag = node.get(".tag").asText(),
+ email = node.get("email")?.asText(),
+ userId = node.get("dropbox_id")?.asText()
+ )
+ }
+}
+
+internal class MemberSelectorListDeserializer : JsonDeserializer>() {
+ override fun deserialize(p: JsonParser, ctxt: DeserializationContext): List {
+ val node = p.codec.readTree(p)
+ return node.map { element ->
+ MemberSelector(
+ tag = element.get(".tag").asText(),
+ email = element.get("email")?.asText(),
+ userId = element.get("dropbox_id")?.asText()
+ )
+ }
+ }
+}
+
+internal class AddFolderMemberDeserializer : JsonDeserializer() {
+ override fun deserialize(p: JsonParser, ctxt: DeserializationContext): AddFolderMember {
+ val node = p.codec.readTree(p)
+
+ // Deserialize member
+ val memberNode = node.get("member")
+ val member = MemberSelector(
+ tag = memberNode.get(".tag").asText(),
+ email = memberNode.get("email")?.asText(),
+ userId = memberNode.get("dropbox_id")?.asText()
+ )
+
+ // Deserialize access level
+ val accessLevelNode = node.get("access_level")
+ val accessLevel = ctxt.readTreeAsValue(accessLevelNode, OmhPermissionRole::class.java)
+
+ return AddFolderMember(member = member, accessLevel = accessLevel)
+ }
+}
+
+internal class AddFolderMemberListDeserializer : JsonDeserializer>() {
+ override fun deserialize(p: JsonParser, ctxt: DeserializationContext): List {
+ val node = p.codec.readTree(p)
+ return node.map { element ->
+ // Deserialize member
+ val memberNode = element.get("member")
+ val member = MemberSelector(
+ tag = memberNode.get(".tag").asText(),
+ email = memberNode.get("email")?.asText(),
+ userId = memberNode.get("dropbox_id")?.asText()
+ )
+
+ // Deserialize access level
+ val accessLevelNode = element.get("access_level")
+ val accessLevel = when (accessLevelNode.get(".tag").asText()) {
+ "owner" -> OmhPermissionRole.OWNER
+ "editor" -> OmhPermissionRole.WRITER
+ "viewer" -> OmhPermissionRole.READER
+ else -> throw IllegalArgumentException("Unknown access level: ${accessLevelNode.get(".tag").asText()}")
+ }
+
+ AddFolderMember(member = member, accessLevel = accessLevel)
+ }
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/mapper/OmhPermissionRoleSerializers.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/mapper/OmhPermissionRoleSerializers.kt
new file mode 100644
index 00000000..19523352
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/mapper/OmhPermissionRoleSerializers.kt
@@ -0,0 +1,42 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper
+
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.JsonDeserializer
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.JsonSerializer
+import com.fasterxml.jackson.databind.SerializerProvider
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+
+fun OmhPermissionRole.toAccessLevel(): String {
+ return when (this) {
+ OmhPermissionRole.OWNER -> "owner"
+ OmhPermissionRole.WRITER -> "editor"
+ OmhPermissionRole.READER -> "viewer_no_comment"
+ OmhPermissionRole.COMMENTER -> "viewer"
+ }
+}
+
+internal class OmhPermissionRoleSerializer : JsonSerializer() {
+ override fun serialize(value: OmhPermissionRole, gen: JsonGenerator, serializers: SerializerProvider) {
+ val accessLevel = value.toAccessLevel()
+ gen.writeStartObject()
+ gen.writeStringField(".tag", accessLevel)
+ gen.writeEndObject()
+ }
+}
+
+internal class OmhPermissionRoleDeserializer : JsonDeserializer() {
+ override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OmhPermissionRole? {
+ val node = p.codec.readTree(p)
+ val tag = node.get(".tag").asText()
+ return when (tag) {
+ "owner" -> OmhPermissionRole.OWNER
+ "editor" -> OmhPermissionRole.WRITER
+ "viewer" -> OmhPermissionRole.COMMENTER
+ "viewer_no_comment" -> OmhPermissionRole.READER
+ else -> throw IllegalArgumentException("Unknown permission role: $tag")
+ }
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/CreateFolderResponse.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/CreateFolderResponse.kt
new file mode 100644
index 00000000..dd8a3c68
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/CreateFolderResponse.kt
@@ -0,0 +1,12 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class CreateFolderResponse(
+ @JsonProperty("metadata")
+ val metadata: FolderMetadata
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/CurrentUserAccountResponse.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/CurrentUserAccountResponse.kt
new file mode 100644
index 00000000..9c44bf0d
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/CurrentUserAccountResponse.kt
@@ -0,0 +1,45 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class CurrentUserAccountResponse(
+ @JsonProperty("root_info")
+ val rootInfo: RootInfo
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class RootInfo(
+ @JsonProperty("root_namespace_id")
+ val rootNamespaceId: String
+)
+
+// {
+// "account_id": "dbid:",
+// "name": {
+// "given_name": "Imaginary",
+// "surname": "User",
+// "familiar_name": "Imaginary User",
+// "display_name": "Imaginary User",
+// "abbreviated_name": "IU"
+// },
+// "email": "test@test.com",
+// "email_verified": true,
+// "disabled": false,
+// "country": "US",
+// "locale": "en",
+// "referral_link": "https://www.dropbox.com/referrals/UNKNOWN",
+// "is_paired": false,
+// "account_type": {
+// ".tag": "basic"
+// },
+// "root_info": {
+// ".tag": "user",
+// "root_namespace_id": "",
+// "home_namespace_id": ""
+// }
+// }
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ExportInfo.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ExportInfo.kt
new file mode 100644
index 00000000..4795e77f
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ExportInfo.kt
@@ -0,0 +1,14 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ExportInfo(
+ @JsonProperty("export_as")
+ val exportAs: String? = null,
+ @JsonProperty("export_options")
+ val exportOptions: List? = null,
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/GetSpaceUsageResponse.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/GetSpaceUsageResponse.kt
new file mode 100644
index 00000000..4adf911a
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/GetSpaceUsageResponse.kt
@@ -0,0 +1,21 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class GetSpaceUsageResponse(
+ @JsonProperty("used")
+ val used: Long,
+ @JsonProperty("allocation")
+ val allocation: SpaceAllocation
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class SpaceAllocation(
+ @JsonProperty("allocated")
+ val allocated: Long,
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/GetTemporaryLinkResponse.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/GetTemporaryLinkResponse.kt
new file mode 100644
index 00000000..341ca69e
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/GetTemporaryLinkResponse.kt
@@ -0,0 +1,14 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class GetTemporaryLinkResponse(
+ @JsonProperty("link")
+ val link: String,
+ @JsonProperty("metadata")
+ val metadata: FileMetadata
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ListFileRevisionsResponse.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ListFileRevisionsResponse.kt
new file mode 100644
index 00000000..c5799db2
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ListFileRevisionsResponse.kt
@@ -0,0 +1,16 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ListFileRevisionsResponse(
+ @JsonProperty("entries")
+ val entries: List,
+ @JsonProperty("has_more")
+ val hasMore: Boolean = false,
+ @JsonProperty("is_deleted")
+ val isDeleted: Boolean = false,
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ListFolderResponse.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ListFolderResponse.kt
new file mode 100644
index 00000000..96a78d4f
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ListFolderResponse.kt
@@ -0,0 +1,54 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.JsonDeserializer
+import com.fasterxml.jackson.databind.JsonNode
+
+@Keep
+data class ListFolderResponse(
+ val cursor: String,
+ val hasMore: Boolean,
+ val entries: List
+)
+
+class ListFolderResponseDeserializer : JsonDeserializer() {
+ override fun deserialize(p: JsonParser, ctxt: DeserializationContext): ListFolderResponse {
+ val node = p.codec.readTree(p)
+ val hasMore = node.get("has_more").asBoolean(false)
+ val cursor = node.get("cursor").asText(null)
+ val entries: List = node.get("entries").asIterable().map { item ->
+ val tag = item.get(".tag").asText()
+ when (tag) {
+ "file" -> {
+ FileMetadata(
+ item.get(".tag").asText(),
+ item.get("content_hash").asText(),
+ item.get("has_explicit_shared_members")?.asBoolean(false) ?: false,
+ item.get("id").asText(),
+ item.get("is_downloadable").asBoolean(),
+ item.get("name").asText(),
+ item.get("path_display").asText(),
+ item.get("size").asLong(),
+ item.get("client_modified").asText(null),
+ item.get("server_modified").asText(null),
+ null,
+ item.get("rev").asText(null)
+ )
+ }
+ "folder" -> {
+ FolderMetadata(
+ item.get(".tag").asText(),
+ item.get("id").asText(),
+ item.get("name").asText(),
+ item.get("path_display").asText(),
+ null
+ )
+ }
+ else -> throw IllegalArgumentException("Unsupported tag: $tag")
+ }
+ }
+ return ListFolderResponse(cursor, hasMore, entries)
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/NodeMetadata.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/NodeMetadata.kt
new file mode 100644
index 00000000..b668c182
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/NodeMetadata.kt
@@ -0,0 +1,126 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+sealed interface NodeMetadata {
+ val id: String
+ val name: String
+ val path: String
+
+ companion object {
+ const val ATTR_TAG = ".tag"
+ const val TAG_FILE = "file"
+ const val TAG_FOLDER = "folder"
+ }
+}
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class FolderMetadata(
+ @JsonProperty(".tag")
+ val tag: String? = "folder",
+ @JsonProperty("id")
+ override val id: String,
+ @JsonProperty("name")
+ override val name: String,
+ @JsonProperty("path_display")
+ override val path: String,
+ @JsonProperty("sharing_info")
+ val sharingInfo: FolderSharingInfo? = null,
+ @JsonProperty("shared_folder_id")
+ val sharedFolderId: String? = null
+) : NodeMetadata
+// {
+// "metadata": {
+// "id": "id:",
+// "name": "math",
+// "path_display": "/Homework/math",
+// "path_lower": "/homework/math",
+// "property_groups": [
+// {
+// "fields": [
+// {
+// "name": "Security Policy",
+// "value": "Confidential"
+// }
+// ],
+// "template_id": "ptid:"
+// }
+// ],
+// "sharing_info": {
+// "no_access": false,
+// "parent_shared_folder_id": "",
+// "read_only": false,
+// "traverse_only": false
+// }
+// }
+// }
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class FileMetadata(
+ @JsonProperty(".tag")
+ val tag: String? = "file",
+ @JsonProperty("content_hash")
+ val contentHash: String? = null,
+ @JsonProperty("has_explicit_shared_members")
+ val hasExplicitSharedMembers: Boolean,
+ @JsonProperty("id")
+ override val id: String,
+ @JsonProperty("is_downloadable")
+ val downloadAble: Boolean,
+ @JsonProperty("name")
+ override val name: String,
+ @JsonProperty("path_display")
+ override val path: String,
+ @JsonProperty("size")
+ val size: Long,
+ @JsonProperty("client_modified")
+ val clientModified: String? = null,
+ @JsonProperty("server_modified")
+ val serverModified: String? = null,
+ @JsonProperty("sharing_info")
+ val sharingInfo: FileSharingInfo? = null,
+ @JsonProperty("rev")
+ val rev: String? = null,
+ @JsonProperty("export_info")
+ val exportInfo: ExportInfo? = null,
+) : NodeMetadata
+
+// "metadata": {
+// ".tag": "file",
+// "client_modified": "2015-05-12T15:50:38Z",
+// "content_hash": "",
+// "file_lock_info": {
+// "created": "2015-05-12T15:50:38Z",
+// "is_lockholder": true,
+// "lockholder_name": "Imaginary User"
+// },
+// "has_explicit_shared_members": false,
+// "id": "id:",
+// "is_downloadable": true,
+// "name": "Prime_Numbers.txt",
+// "path_display": "/Homework/math/Prime_Numbers.txt",
+// "path_lower": "/homework/math/prime_numbers.txt",
+// "property_groups": [
+// {
+// "fields": [
+// {
+// "name": "Security Policy",
+// "value": "Confidential"
+// }
+// ],
+// "template_id": "ptid:"
+// }
+// ],
+// "rev": "a1c10ce0dd78",
+// "server_modified": "2015-05-12T15:50:38Z",
+// "sharing_info": {
+// "modified_by": "dbid:",
+// "parent_shared_folder_id": "84528192421",
+// "read_only": true
+// },
+// "size": 7212
+// }
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/NodeSharingInfo.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/NodeSharingInfo.kt
new file mode 100644
index 00000000..03c3e534
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/NodeSharingInfo.kt
@@ -0,0 +1,32 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+sealed interface NodeSharingInfo {
+ val readOnly: Boolean?
+ val parentId: String?
+}
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class FileSharingInfo(
+ @JsonProperty("read_only")
+ override val readOnly: Boolean? = false,
+ @JsonProperty("parent_shared_folder_id")
+ override val parentId: String? = null,
+ @JsonProperty("modified_by")
+ val modifiedByUser: String? = null,
+) : NodeSharingInfo
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class FolderSharingInfo(
+ @JsonProperty("read_only")
+ override val readOnly: Boolean? = false,
+ @JsonProperty("parent_shared_folder_id")
+ override val parentId: String? = null,
+ @JsonProperty("shared_folder_id")
+ val sharedFolderId: String? = null
+) : NodeSharingInfo
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/SearchResultResponse.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/SearchResultResponse.kt
new file mode 100644
index 00000000..a521281b
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/SearchResultResponse.kt
@@ -0,0 +1,20 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.openmobilehub.android.storage.core.model.OmhStorageEntity
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toOmhFile
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toOmhFolder
+
+@Keep
+data class SearchResultResponse(
+ val matches: List
+) {
+ fun toListOfOmhStorageEntity(): List {
+ return matches.map {
+ when (it) {
+ is FileMetadata -> it.toOmhFile("")
+ is FolderMetadata -> it.toOmhFolder("")
+ }
+ }
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ShareFolderResponse.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ShareFolderResponse.kt
new file mode 100644
index 00000000..778f5678
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ShareFolderResponse.kt
@@ -0,0 +1,18 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ShareFolderResponse(
+ @JsonProperty("folder_id")
+ val id: String? = null,
+ @JsonProperty("shared_folder_id")
+ val sharedFolderId: String,
+ @JsonProperty("name")
+ val name: String,
+ @JsonProperty("preview_url")
+ val previewUrl: String,
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ShareJobResponse.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ShareJobResponse.kt
new file mode 100644
index 00000000..dbc5b138
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/ShareJobResponse.kt
@@ -0,0 +1,12 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ShareJobResponse(
+ @JsonProperty("async_job_id")
+ val jobId: String
+)
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/SharedNodeMetadata.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/SharedNodeMetadata.kt
new file mode 100644
index 00000000..5c2b40f8
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/SharedNodeMetadata.kt
@@ -0,0 +1,40 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+sealed interface SharedNodeMetadata {
+ val id: String
+ val name: String
+ val previewUrl: String
+ val path: String?
+}
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class SharedFolderMetadata(
+ @JsonProperty("id")
+ override val id: String,
+ @JsonProperty("name")
+ override val name: String,
+ @JsonProperty("preview_url")
+ override val previewUrl: String,
+ @JsonProperty("path_display")
+ override val path: String? = null,
+ @JsonProperty("shared_folder_id")
+ val sharedFolderId: String
+) : SharedNodeMetadata
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class SharedFileMetadata(
+ @JsonProperty("id")
+ override val id: String,
+ @JsonProperty("name")
+ override val name: String,
+ @JsonProperty("preview_url")
+ override val previewUrl: String,
+ @JsonProperty("path_display")
+ override val path: String? = null,
+) : SharedNodeMetadata
diff --git a/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/UploadSessionResponse.kt b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/UploadSessionResponse.kt
new file mode 100644
index 00000000..4358fe86
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/main/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/UploadSessionResponse.kt
@@ -0,0 +1,10 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+data class UploadSessionResponse(
+ @JsonProperty("session_id")
+ val sessionId: String
+)
diff --git a/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/DropboxRestfulOmhStorageClientTest.kt b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/DropboxRestfulOmhStorageClientTest.kt
new file mode 100644
index 00000000..04fabed4
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/DropboxRestfulOmhStorageClientTest.kt
@@ -0,0 +1,14 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful
+
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class DropboxRestfulOmhStorageClientTest {
+
+ @Test
+ fun `test rootFolder`() {
+ val client = DropboxRestfulOmhStorageClient(mockk(), mockk())
+ assertEquals("", client.rootFolder)
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/repository/DropboxRestfulFileRepositoryTest.kt b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/repository/DropboxRestfulFileRepositoryTest.kt
new file mode 100644
index 00000000..356b8078
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/repository/DropboxRestfulFileRepositoryTest.kt
@@ -0,0 +1,2592 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.repository
+
+import android.webkit.MimeTypeMap
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import com.openmobilehub.android.storage.core.model.OmhCreatePermission
+import com.openmobilehub.android.storage.core.model.OmhPermissionRecipient
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.core.model.OmhStorageEntity
+import com.openmobilehub.android.storage.core.model.OmhStorageException
+import com.openmobilehub.android.storage.core.restful.common.data.mapper.LocalFileToMimeType
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.DropboxApiService
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.DropboxContentApiService
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.retrofit.DropboxRetrofitImpl
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.AppendUploadSessionRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.CheckShareJobStatusRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ContinueRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.CreateFolderRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.DeleteFileSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.DeleteFolderSharedMemberRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ExportFileRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.FinishUploadSessionRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.GetFileRevisionsRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ListFileSharedMembersRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ListFolderRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ListFolderSharedMembersRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.NodeMetadataRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.PathRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.SearchFileRequest
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.ShareFolderRequestBody
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.UploadSessionCursor
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toOmhFile
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.CreateFolderResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FileMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FolderMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FolderSharingInfo
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ListFileRevisionsResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ListFolderResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ShareJobResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.SharedFileMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.SharedFolderMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.UploadSessionResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestFileMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestFileMetadata.testFileUploadInChunksResult
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestFolderMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestGetSpaceUsageResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestListFolderResponse.testFile1
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestListFolderResponse.testFile2
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestListFolderResponse.testFileListResponseWithNextCursor
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestListFolderResponse.testFileListResponseWithNextCursor2ndResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestListFolderResponse.testFileListResponseWithoutNextCursor
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestPermissionResponse.TEST_PERMISSION_GROUP_ID
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestPermissionResponse.TEST_PERMISSION_GROUP_NAME
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestPermissionResponse.TEST_PERMISSION_USER_EMAIL
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestPermissionResponse.TEST_PERMISSION_USER_ID
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestPermissionResponse.TEST_PERMISSION_USER_NAME
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestPermissionResponse.testExpectedPermissions
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestPermissionResponse.testFileSharedMembersResponseJson
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestPermissionResponse.testFolderSharedMembersResponseJson
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestPermissionResponse.testOmhGroupPermission
+import com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles.TestPermissionResponse.testOmhUserPermission
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockkStatic
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import okhttp3.Headers
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.ResponseBody
+import okhttp3.ResponseBody.Companion.toResponseBody
+import okio.Buffer
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+import retrofit2.Response
+import java.io.File
+import java.io.FileInputStream
+import kotlin.reflect.full.callSuspend
+import kotlin.reflect.full.memberFunctions
+import kotlin.reflect.jvm.isAccessible
+
+@Suppress(
+ "LargeClass",
+ "UnusedPrivateMember",
+ "ForbiddenComment",
+ "MaxLineLength",
+ "MaximumLineLength",
+ "LongMethod"
+)
+@OptIn(ExperimentalCoroutinesApi::class)
+class DropboxRestfulFileRepositoryTest {
+
+ companion object {
+ private const val TEST_MIME_TYPE = "application/x-test-mimetype"
+
+ /**
+ * Creates a ResponseBody that can be consumed multiple times for testing.
+ * This is needed because ResponseBody.string() can only be called once.
+ */
+ private fun createReusableResponseBody(content: String, mediaType: String = "application/json"): ResponseBody {
+ return object : ResponseBody() {
+ override fun contentType() = mediaType.toMediaTypeOrNull()
+ override fun contentLength() = content.length.toLong()
+ override fun source() = Buffer().writeUtf8(content)
+ }
+ }
+ }
+
+ private val objectMapper: ObjectMapper = ObjectMapper().registerModule(KotlinModule())
+
+ @MockK(relaxed = true)
+ private lateinit var retrofitImpl: DropboxRetrofitImpl
+
+ @MockK(relaxed = true)
+ private lateinit var dropboxApiService: DropboxApiService
+
+ @MockK(relaxed = true)
+ private lateinit var dropboxContentApiService: DropboxContentApiService
+
+ @MockK(relaxed = true)
+ private lateinit var mimeTypeMap: MimeTypeMap
+
+ @MockK(relaxed = true)
+ private lateinit var responseBody: ResponseBody
+
+ @MockK(relaxed = true)
+ private lateinit var file: File
+
+ @MockK(relaxed = true)
+ private lateinit var fileInputStream: FileInputStream
+
+ @MockK
+ private lateinit var localFileToMimeType: LocalFileToMimeType
+
+ private lateinit var fileRepositoryImpl: DropboxRestfulFileRepository
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+
+ mockkStatic("com.openmobilehub.android.storage.core.utils.FileExtensionsKt")
+ every { localFileToMimeType.invoke(any()) } returns TEST_MIME_TYPE
+
+ fileRepositoryImpl = DropboxRestfulFileRepository(
+ dropboxApiService,
+ dropboxContentApiService,
+ smallFileLimitInMB = 1 // Lower the bar to force upload session and upload in chunks
+ )
+
+ every { retrofitImpl.dropboxApiService } returns dropboxApiService
+ every { retrofitImpl.dropboxContentApiService } returns dropboxContentApiService
+ mockkStatic(MimeTypeMap::class)
+ every { MimeTypeMap.getSingleton() } returns mimeTypeMap
+ every { mimeTypeMap.getMimeTypeFromExtension(any()) } returns TEST_MIME_TYPE
+ }
+
+ @After
+ fun tearDown() {
+ clearAllMocks()
+ }
+
+ @Test
+ fun `given a blank parentId, when getFilesList is success, then a list of OmhStorageEntities is returned`() =
+ runTest {
+ coEvery { dropboxApiService.getFilesList(any()) } returns Response.success(
+ testFileListResponseWithoutNextCursor
+ )
+
+ val request = ListFolderRequestBody(
+ path = "",
+ includeDeletedFiles = false,
+ includeHasExplicitSharedMembers = false,
+ includeMountedFolders = true,
+ includeNonDownloadableFiles = true,
+ recursive = false
+ )
+ val result = fileRepositoryImpl.getFilesList("")
+
+ assertEquals(listOf(testFile1.toOmhFile("")), result)
+ coVerify { dropboxApiService.getFilesList(eq(request)) }
+ }
+
+ @Test
+ fun `test getFilesList with hasMore in response should continue list internally`() =
+ runTest {
+ coEvery {
+ dropboxApiService.getFilesList(any())
+ } returns Response.success(testFileListResponseWithNextCursor)
+ coEvery {
+ dropboxApiService.continueGetFilesList(any())
+ } returns Response.success(testFileListResponseWithNextCursor2ndResponse)
+
+ val request = ListFolderRequestBody(
+ path = "",
+ includeDeletedFiles = false,
+ includeHasExplicitSharedMembers = false,
+ includeMountedFolders = true,
+ includeNonDownloadableFiles = true,
+ recursive = false
+ )
+ val result = fileRepositoryImpl.getFilesList("")
+
+ assertEquals(
+ listOf(
+ testFile1.toOmhFile(""),
+ testFile2.toOmhFile("")
+ ),
+ result
+ )
+ coVerify { dropboxApiService.getFilesList(eq(request)) }
+ coVerify {
+ dropboxApiService.continueGetFilesList(
+ eq(ContinueRequest("test cursor 2"))
+ )
+ }
+ }
+
+ @Test
+ fun `test getFilesList with empty response`() =
+ runTest {
+ coEvery { dropboxApiService.getFilesList(any()) } returns Response.success(
+ ListFolderResponse(
+ cursor = "test cursor",
+ hasMore = false,
+ entries = emptyList()
+ )
+ )
+
+ val result = fileRepositoryImpl.getFilesList("")
+
+ assertEquals(emptyList(), result)
+ }
+
+ @Test
+ fun `test downloadFile`() =
+ runTest {
+ val fileMetadata = objectMapper.writerFor(FileMetadata::class.java)
+ .writeValueAsString(testFile1)
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ fileMetadata.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery {
+ dropboxContentApiService.downloadFile(any())
+ } returns Response.success(
+ "12345678".toResponseBody("text/plain".toMediaTypeOrNull()),
+ Headers.headersOf("dropbox-api-result", fileMetadata)
+ )
+
+ val result = fileRepositoryImpl.downloadFile("id:testFile1")
+ assertNotNull(result)
+ coVerify {
+ dropboxContentApiService.downloadFile(
+ eq(objectMapper.writeValueAsString(PathRequestBody("id:testFile1")))
+ )
+ }
+ }
+
+ @Test
+ fun `given downloadFile fails, when called, then throws ApiException`() =
+ runTest {
+ coEvery {
+ dropboxContentApiService.downloadFile(any())
+ } returns Response.error(
+ 404,
+ "File not found".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.downloadFile("id:nonExistentFile")
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {}
+
+ coVerify {
+ dropboxContentApiService.downloadFile(
+ eq(objectMapper.writeValueAsString(PathRequestBody("id:nonExistentFile")))
+ )
+ }
+ }
+
+ @Test
+ fun `test upload big file should cause upload session and upload in chunks`() =
+ runTest {
+ // Lower the throttle to trigger upload in chunks as much as possible
+ val field = DropboxRestfulFileRepository::class.java.getDeclaredField("smallFileLimit")
+ field.isAccessible = true
+ field.set(fileRepositoryImpl, 10)
+
+ val content = "abcdefghijklmnopqrstuvwxyz1234567890"
+ val file = File.createTempFile("upload", ".txt")
+ file.deleteOnExit()
+ file.writeText(content)
+
+ coEvery {
+ dropboxContentApiService.createUploadFileSession(any())
+ } returns Response.success(UploadSessionResponse("pid_upload_session:session id"))
+
+ coEvery {
+ dropboxContentApiService.continueUploadFile(
+ objectMapper.writer().writeValueAsString(
+ AppendUploadSessionRequest(
+ close = false,
+ cursor =
+ UploadSessionCursor(
+ offset = 0,
+ sessionId = "pid_upload_session:session id",
+ ),
+ ),
+ ),
+ any(),
+ )
+ } returns Response.success(200, Unit)
+
+ coEvery {
+ dropboxContentApiService.continueUploadFile(
+ objectMapper.writer().writeValueAsString(
+ AppendUploadSessionRequest(
+ close = false,
+ cursor =
+ UploadSessionCursor(
+ offset = 10,
+ sessionId = "pid_upload_session:session id",
+ ),
+ ),
+ ),
+ any(),
+ )
+ } returns Response.success(200, Unit)
+
+ coEvery {
+ dropboxContentApiService.continueUploadFile(
+ objectMapper.writer().writeValueAsString(
+ AppendUploadSessionRequest(
+ close = false,
+ cursor =
+ UploadSessionCursor(
+ offset = 20,
+ sessionId = "pid_upload_session:session id",
+ ),
+ ),
+ ),
+ any(),
+ )
+ } returns Response.success(200, Unit)
+
+ coEvery {
+ dropboxContentApiService.continueUploadFile(
+ objectMapper.writer().writeValueAsString(
+ AppendUploadSessionRequest(
+ close = false,
+ cursor =
+ UploadSessionCursor(
+ offset = 30,
+ sessionId = "pid_upload_session:session id",
+ ),
+ ),
+ ),
+ any(),
+ )
+ } returns Response.success(200, Unit)
+
+ coEvery {
+ dropboxContentApiService.finishUploadFile(any(), any())
+ } returns Response.success(
+ testFileUploadInChunksResult.copy(
+ name = file.name,
+ size = file.length(),
+ id = "id:created file id"
+ )
+ )
+
+ val result = fileRepositoryImpl.uploadFile(file, "")
+
+ file.delete()
+
+ assertNotNull(result)
+ assertEquals(file.name, result?.name)
+ assertEquals(content.length, result?.size)
+ assertEquals("id:created file id", result?.id)
+
+ coVerify(exactly = 1) {
+ dropboxContentApiService.continueUploadFile(
+ withArg {
+ val req = objectMapper.readerFor(AppendUploadSessionRequest::class.java)
+ .readValue(it)
+ assertEquals("pid_upload_session:session id", req.cursor.sessionId)
+ assertEquals(0, req.cursor.offset)
+ assertFalse(req.close == true)
+ },
+ withArg { filePart ->
+ assertEquals(10, filePart.contentLength())
+ }
+ )
+ }
+ coVerify(exactly = 1) {
+ dropboxContentApiService.continueUploadFile(
+ withArg {
+ val req = objectMapper.readerFor(AppendUploadSessionRequest::class.java)
+ .readValue(it)
+ assertEquals("pid_upload_session:session id", req.cursor.sessionId)
+ assertEquals(10, req.cursor.offset)
+ assertFalse(req.close == true)
+ },
+ withArg { filePart ->
+ assertEquals(10, filePart.contentLength())
+ }
+ )
+ }
+ coVerify(exactly = 1) {
+ dropboxContentApiService.continueUploadFile(
+ withArg {
+ val req = objectMapper.readerFor(AppendUploadSessionRequest::class.java)
+ .readValue(it)
+ assertEquals("pid_upload_session:session id", req.cursor.sessionId)
+ assertEquals(20, req.cursor.offset)
+ assertFalse(req.close == true)
+ },
+ withArg { filePart ->
+ assertEquals(10, filePart.contentLength())
+ }
+ )
+ }
+ coVerify(exactly = 1) {
+ dropboxContentApiService.continueUploadFile(
+ withArg {
+ val req = objectMapper.readerFor(AppendUploadSessionRequest::class.java)
+ .readValue(it)
+ assertEquals("pid_upload_session:session id", req.cursor.sessionId)
+ assertEquals(30, req.cursor.offset)
+ assertFalse(req.close == true)
+ },
+ withArg { filePart ->
+ assertEquals(6, filePart.contentLength())
+ }
+ )
+ }
+ coVerify(exactly = 1) {
+ dropboxContentApiService.finishUploadFile(
+ withArg {
+ val orig = objectMapper.readerFor(FinishUploadSessionRequestBody::class.java)
+ .readValue(it)
+ assertEquals("/${file.name}", orig.createFileRequestBody.path)
+ assertEquals("add", orig.createFileRequestBody.mode)
+ },
+ withArg { filePart ->
+ assertEquals(0, filePart.contentLength())
+ }
+ )
+ }
+ }
+
+ @Test
+ fun `test deleteFile`() =
+ runTest {
+ coEvery { dropboxApiService.deleteFile(any()) } returns Response.success(testFile1)
+
+ val result = fileRepositoryImpl.deleteFile("id:testFile1")
+ assertTrue(result)
+ coVerify {
+ dropboxApiService.deleteFile(eq(PathRequestBody("id:testFile1")))
+ }
+ }
+
+ @Test
+ fun `test get user quota`() =
+ runTest {
+ coEvery { dropboxApiService.getSpaceUsage() } returns
+ Response.success(TestGetSpaceUsageResponse.getSpaceUsageResponseWithQuotaImposed)
+
+ val result = fileRepositoryImpl.getSpaceUsage()
+ assertEquals(123456789L, result.used)
+ assertEquals(987654321L, result.allocation.allocated)
+ }
+
+ // Additional test stubs for comprehensive coverage
+
+ @Test
+ fun `given valid folder name and parentId, when createFolder is success, then returns OmhFolder`() =
+ runTest {
+ val parentFolderResponse = objectMapper.writerFor(FolderMetadata::class.java).writeValueAsString(
+ TestFolderMetadata.testParentFolder
+ ).toResponseBody(
+ "application/json".toMediaTypeOrNull()
+ )
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(parentFolderResponse)
+ coEvery {
+ dropboxApiService.createFolder(
+ CreateFolderRequest(
+ path = "/test parent folder/test folder"
+ )
+ )
+ } returns Response.success(
+ CreateFolderResponse(
+ TestFolderMetadata.testFolder.copy(
+ name = "test folder",
+ path = "${TestFolderMetadata.testParentFolder.path}/test folder"
+ )
+ )
+ )
+
+ val result = fileRepositoryImpl.createFolder("test folder", "id:test parent folder id")
+ assertNotNull(result)
+ assertEquals("test folder", result?.name)
+
+ coVerify(exactly = 1) {
+ dropboxApiService.getNodeMetaData(
+ withArg { request ->
+ assertEquals(request.path, "id:test parent folder id")
+ }
+ )
+ }
+ coVerify(exactly = 1) {
+ dropboxApiService.createFolder(
+ CreateFolderRequest(
+ path = "/test parent folder/test folder"
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `given valid folder name and null parentId, when createFolder is success, then returns OmhFolder in root`() =
+ runTest {
+ coEvery {
+ dropboxApiService.createFolder(
+ CreateFolderRequest(
+ path = "/test folder"
+ )
+ )
+ } returns Response.success(
+ CreateFolderResponse(
+ TestFolderMetadata.testFolder.copy(
+ name = "test folder",
+ path = "/test folder"
+ )
+ )
+ )
+
+ val result = fileRepositoryImpl.createFolder("test folder", "")
+ assertNotNull(result)
+ assertEquals("test folder", result?.name)
+
+ coVerify(exactly = 0) { dropboxApiService.getNodeMetaData(any()) }
+ coVerify(exactly = 1) {
+ dropboxApiService.createFolder(
+ CreateFolderRequest(
+ path = "/test folder"
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `given createFolder fails, when called, then returns null`() =
+ runTest {
+ coEvery {
+ dropboxApiService.createFolder(any())
+ } returns Response.error(
+ 500,
+ "Server error".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.createFolder("test folder", "")
+
+ assertEquals(null, result)
+
+ coVerify(exactly = 1) {
+ dropboxApiService.createFolder(
+ CreateFolderRequest(path = "/test folder")
+ )
+ }
+ }
+
+ @Test
+ fun `given valid file name and parentId, when createFile is success, then returns OmhFile`() =
+ runTest {
+ val parentFolderResponse = objectMapper.writerFor(FolderMetadata::class.java).writeValueAsString(
+ TestFolderMetadata.testParentFolder
+ ).toResponseBody("application/json".toMediaTypeOrNull())
+
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(parentFolderResponse)
+
+ coEvery {
+ dropboxContentApiService.createFile(any(), any())
+ } returns Response.success(TestFileMetadata.testCreatedFileWithParent)
+
+ val result = fileRepositoryImpl.createFile("test file.txt", "id:test parent folder id")
+
+ assertNotNull(result)
+ assertEquals("test file.txt", result?.name)
+ assertEquals("newly created file with parent id", result?.id)
+
+ coVerify(exactly = 1) {
+ dropboxApiService.getNodeMetaData(
+ withArg { request ->
+ assertEquals("id:test parent folder id", request.path)
+ }
+ )
+ }
+ coVerify(exactly = 1) {
+ dropboxContentApiService.createFile(any(), any())
+ }
+ }
+
+ @Test
+ fun `given valid file name and null parentId, when createFile is success, then returns OmhFile in root`() =
+ runTest {
+ coEvery {
+ dropboxContentApiService.createFile(any(), any())
+ } returns Response.success(TestFileMetadata.testCreatedFile)
+
+ val result = fileRepositoryImpl.createFile("test file.txt", "")
+
+ assertNotNull(result)
+ assertEquals("test file.txt", result?.name)
+ assertEquals("newly created file id", result?.id)
+
+ coVerify(exactly = 0) { dropboxApiService.getNodeMetaData(any()) }
+ coVerify(exactly = 1) {
+ dropboxContentApiService.createFile(any(), any())
+ }
+ }
+
+ @Test
+ fun `given createFile fails, when called, then returns null`() =
+ runTest {
+ coEvery {
+ dropboxContentApiService.createFile(any(), any())
+ } returns Response.error(
+ 500,
+ "Server error".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.createFile("test file.txt", "")
+
+ assertEquals(null, result)
+
+ coVerify(exactly = 1) {
+ dropboxContentApiService.createFile(any(), any())
+ }
+ }
+
+ @Test
+ fun `given valid fileId, when permanentlyDeleteFile is success, then returns true`() =
+ runTest {
+ coEvery {
+ dropboxApiService.permanentlyDeleteFile(any())
+ } returns Response.success(Unit)
+
+ val result = fileRepositoryImpl.permanentlyDeleteFile("id:testFile1")
+ assertTrue(result)
+ coVerify {
+ dropboxApiService.permanentlyDeleteFile(eq(PathRequestBody("id:testFile1")))
+ }
+ }
+
+ @Test
+ fun `given permanentlyDeleteFile fails, when called, then throws ApiException`() =
+ runTest {
+ coEvery { dropboxApiService.permanentlyDeleteFile(any()) } returns Response.error(
+ 500,
+ "Server error".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.permanentlyDeleteFile("id:testFile1")
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {}
+
+ coVerify {
+ dropboxApiService.permanentlyDeleteFile(eq(PathRequestBody("id:testFile1")))
+ }
+ }
+
+ @Test
+ fun `given valid fileId, when getFileRevisions is success, then returns list of OmhFileVersion`() =
+ runTest {
+ // Create a real ListFileRevisionsResponse with mock FileMetadata entries
+ val mockFileMetadata = TestFileMetadata.testFile.copy(
+ size = 1L,
+ id = "file123",
+ rev = "rev123"
+ )
+
+ val fileRevisionsResponse = ListFileRevisionsResponse(
+ entries = listOf(mockFileMetadata),
+ hasMore = false,
+ isDeleted = false
+ )
+
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ objectMapper.writerFor(FileMetadata::class.java)
+ .writeValueAsString(
+ TestFileMetadata.testFile
+ ).toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ coEvery {
+ dropboxApiService.getFileRevisionList(any())
+ } returns Response.success(fileRevisionsResponse)
+
+ val result = fileRepositoryImpl.getFileRevisions("id:testFile1")
+
+ assertEquals(1, result.size)
+ assertEquals("file123", result[0].fileId)
+ assertEquals("rev123", result[0].versionId)
+ coVerify {
+ dropboxApiService.getFileRevisionList(
+ eq(GetFileRevisionsRequest(path = "/testfile.txt", mode = "path"))
+ )
+ }
+ }
+
+ @Test
+ fun `given getFileRevisions fails, when called, then throws ApiException`() =
+ runTest {
+ coEvery {
+ dropboxApiService.getFileRevisionList(any())
+ } returns Response.error(
+ 500,
+ "Server error".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ objectMapper.writerFor(FileMetadata::class.java).writeValueAsString(
+ TestFileMetadata.testFile
+ ).toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.getFileRevisions("id:testFile1")
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {}
+
+ coVerify {
+ dropboxApiService.getFileRevisionList(
+ eq(
+ GetFileRevisionsRequest(
+ path = "/testfile.txt",
+ mode = "path"
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `given valid fileId and versionId, when downloadFileVersion is success, then returns ByteArrayOutputStream`() =
+ runTest {
+ coEvery {
+ dropboxContentApiService.downloadFile(any())
+ } returns Response.success(
+ "version file content".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.downloadFileVersion("id:testFile1", "version123")
+
+ assertNotNull(result)
+ coVerify {
+ dropboxContentApiService.downloadFile(
+ eq(objectMapper.writeValueAsString(PathRequestBody("rev:version123")))
+ )
+ }
+ }
+
+ @Test
+ fun `given downloadFileVersion fails, when called, then throws ApiException`() =
+ runTest {
+ coEvery {
+ dropboxContentApiService.downloadFile(any())
+ } returns Response.error(
+ 404,
+ "Version not found".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.downloadFileVersion("id:testFile1", "invalidVersion")
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {}
+
+ coVerify {
+ dropboxContentApiService.downloadFile(
+ eq(objectMapper.writeValueAsString(PathRequestBody("rev:invalidVersion")))
+ )
+ }
+ }
+
+ @Test
+ fun `given valid fileId, when getFileMetadata is success, then returns OmhStorageMetadata for file`() =
+ runTest {
+ val testFileMetadataJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedFile)
+
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.getFileMetadata("id:testFile1")
+
+ assertNotNull(result)
+ assertTrue(result?.entity is OmhStorageEntity.OmhFile)
+ val file = result?.entity as OmhStorageEntity.OmhFile
+ assertEquals("newly created file id", file.id)
+ assertEquals("test file.txt", file.name)
+ assertNotNull(result.originalMetadata)
+ assertEquals("file", (result.originalMetadata as org.json.JSONObject).getString(".tag"))
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(NodeMetadataRequest("id:testFile1"))
+ )
+ }
+ }
+
+ @Test
+ fun `given valid folderId, when getFileMetadata is success, then returns OmhStorageMetadata for folder`() =
+ runTest {
+ val testFolderMetadataJson = objectMapper.writeValueAsString(TestFolderMetadata.testFolder)
+
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.getFileMetadata("id:testFolder1")
+
+ assertNotNull(result)
+ assertTrue(result?.entity is OmhStorageEntity.OmhFolder)
+ val folder = result?.entity as OmhStorageEntity.OmhFolder
+ assertEquals("id:newly created folder id", folder.id)
+ assertEquals("newly created folder name", folder.name)
+ assertNotNull(result.originalMetadata)
+ assertEquals("folder", (result.originalMetadata as org.json.JSONObject).getString(".tag"))
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(NodeMetadataRequest("id:testFolder1"))
+ )
+ }
+ }
+
+ @Test
+ fun `given valid nodeId and path, when getNodeMetadata is success, then returns NodeMetadata`() =
+ runTest {
+ val testFileMetadataJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedFile)
+
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.getNodeMetadata(id = "id:testFileId", path = null)
+
+ assertNotNull(result)
+ assertTrue(result is FileMetadata)
+ val fileMetadata = result as FileMetadata
+ assertEquals("newly created file id", fileMetadata.id)
+ assertEquals("test file.txt", fileMetadata.name)
+ assertEquals("/test file.txt", fileMetadata.path)
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(NodeMetadataRequest("id:testFileId"))
+ )
+ }
+ }
+
+ @Test
+ fun `given valid path only, when getNodeMetadata is success, then returns NodeMetadata`() =
+ runTest {
+ val testFolderMetadataJson = objectMapper.writeValueAsString(TestFolderMetadata.testFolder)
+
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.getNodeMetadata(id = null, path = "/test/folder/path")
+
+ assertNotNull(result)
+ assertTrue(result is FolderMetadata)
+ val folderMetadata = result as FolderMetadata
+ assertEquals("id:newly created folder id", folderMetadata.id)
+ assertEquals("newly created folder name", folderMetadata.name)
+ assertEquals("/new folder", folderMetadata.path)
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(NodeMetadataRequest("/test/folder/path"))
+ )
+ }
+ }
+
+ @Test
+ fun `given getNodeMetadata returns null, when called, then returns null`() =
+ runTest {
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(null)
+
+ val result = fileRepositoryImpl.getNodeMetadata(id = "id:testFileId", path = null)
+
+ assertEquals(null, result)
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(NodeMetadataRequest("id:testFileId"))
+ )
+ }
+ }
+
+ @Test
+ fun `given valid query, when search is success, then returns SearchResultResponse`() =
+ runTest {
+ val searchResponseJson = JSONObject().apply {
+ put("has_more", false)
+ put("cursor", "")
+ put(
+ "matches",
+ org.json.JSONArray().apply {
+ // File match
+ put(
+ JSONObject().apply {
+ put("match_type", JSONObject().apply { put(".tag", "filename") })
+ put(
+ "metadata",
+ JSONObject().apply {
+ put(".tag", "metadata")
+ put(
+ "metadata",
+ JSONObject().apply {
+ put(".tag", "file")
+ put("id", "search_file_1")
+ put("name", "search_result.txt")
+ put("path_display", "/search_result.txt")
+ put("path_lower", "/search_result.txt")
+ put("size", 1024)
+ put("server_modified", "2023-10-01T12:00:00Z")
+ put("client_modified", "2023-10-01T12:00:00Z")
+ put("rev", "search_rev")
+ put("is_downloadable", true)
+ }
+ )
+ }
+ )
+ }
+ )
+ // Folder match
+ put(
+ JSONObject().apply {
+ put("match_type", JSONObject().apply { put(".tag", "filename") })
+ put(
+ "metadata",
+ JSONObject().apply {
+ put(".tag", "metadata")
+ put(
+ "metadata",
+ JSONObject().apply {
+ put(".tag", "folder")
+ put("id", "search_folder_1")
+ put("name", "search_folder")
+ put("path_display", "/search_folder")
+ put("path_lower", "/search_folder")
+ }
+ )
+ }
+ )
+ }
+ )
+ }
+ )
+ }
+
+ coEvery {
+ dropboxApiService.search(any())
+ } returns Response.success(
+ searchResponseJson.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.search("test query")
+
+ assertNotNull(result)
+ assertEquals(2, result.matches.size)
+ assertTrue(result.matches[0] is FileMetadata)
+ assertTrue(result.matches[1] is FolderMetadata)
+
+ val fileMetadata = result.matches[0] as FileMetadata
+ assertEquals("search_file_1", fileMetadata.id)
+ assertEquals("search_result.txt", fileMetadata.name)
+
+ val folderMetadata = result.matches[1] as FolderMetadata
+ assertEquals("search_folder_1", folderMetadata.id)
+ assertEquals("search_folder", folderMetadata.name)
+
+ coVerify {
+ dropboxApiService.search(
+ eq(SearchFileRequest("test query"))
+ )
+ }
+ }
+
+ @Test
+ fun `given search with pagination, when search is called, handles multiple pages correctly`() =
+ runTest {
+ // First page response with has_more = true
+ val firstPageResponse = JSONObject().apply {
+ put("has_more", true)
+ put("cursor", "next_page_cursor")
+ put(
+ "matches",
+ org.json.JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put("match_type", JSONObject().apply { put(".tag", "filename") })
+ put(
+ "metadata",
+ JSONObject().apply {
+ put(".tag", "metadata")
+ put(
+ "metadata",
+ JSONObject().apply {
+ put(".tag", "file")
+ put("id", "page1_file")
+ put("name", "page1_file.txt")
+ put("path_display", "/page1_file.txt")
+ put("path_lower", "/page1_file.txt")
+ put("size", 512)
+ put("server_modified", "2023-10-01T12:00:00Z")
+ put("client_modified", "2023-10-01T12:00:00Z")
+ put("rev", "page1_rev")
+ put("is_downloadable", true)
+ }
+ )
+ }
+ )
+ }
+ )
+ }
+ )
+ }
+
+ // Second page response with has_more = false
+ val secondPageResponse = JSONObject().apply {
+ put("has_more", false)
+ put("cursor", "")
+ put(
+ "matches",
+ org.json.JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put("match_type", JSONObject().apply { put(".tag", "filename") })
+ put(
+ "metadata",
+ JSONObject().apply {
+ put(".tag", "metadata")
+ put(
+ "metadata",
+ JSONObject().apply {
+ put(".tag", "file")
+ put("id", "page2_file")
+ put("name", "page2_file.txt")
+ put("path_display", "/page2_file.txt")
+ put("path_lower", "/page2_file.txt")
+ put("size", 256)
+ put("server_modified", "2023-10-01T12:00:00Z")
+ put("client_modified", "2023-10-01T12:00:00Z")
+ put("rev", "page2_rev")
+ put("is_downloadable", true)
+ }
+ )
+ }
+ )
+ }
+ )
+ }
+ )
+ }
+
+ coEvery {
+ dropboxApiService.search(any())
+ } returns Response.success(
+ firstPageResponse.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ coEvery {
+ dropboxApiService.continueSearch(any())
+ } returns Response.success(
+ secondPageResponse.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.search("test pagination")
+
+ assertNotNull(result)
+ assertEquals(2, result.matches.size)
+
+ // Verify first page file
+ val firstFile = result.matches[0] as FileMetadata
+ assertEquals("page1_file", firstFile.id)
+ assertEquals("page1_file.txt", firstFile.name)
+
+ // Verify second page file
+ val secondFile = result.matches[1] as FileMetadata
+ assertEquals("page2_file", secondFile.id)
+ assertEquals("page2_file.txt", secondFile.name)
+
+ coVerify {
+ dropboxApiService.search(
+ eq(SearchFileRequest("test pagination"))
+ )
+ }
+ coVerify {
+ dropboxApiService.continueSearch(
+ eq(SearchFileRequest.SearchByCursor("next_page_cursor"))
+ )
+ }
+ }
+
+ @Test
+ fun `given search fails, when called, then throws ApiException`() =
+ runTest {
+ coEvery {
+ dropboxApiService.search(any())
+ } returns Response.error(
+ 500,
+ "Search service unavailable".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.search("test query")
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {}
+
+ coVerify {
+ dropboxApiService.search(
+ eq(
+ com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body.SearchFileRequest(
+ "test query"
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `given invalid search response, when search is called, then throws ApiException`() =
+ runTest {
+ // Response without "matches" field
+ val invalidResponseJson = JSONObject().apply {
+ put("has_more", false)
+ put("cursor", "")
+ // Missing "matches" field
+ }
+
+ coEvery {
+ dropboxApiService.search(any())
+ } returns Response.success(
+ invalidResponseJson.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.search("test query")
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {
+ assertEquals(400, expected.statusCode)
+ assertEquals("Invalid search response", expected.message)
+ }
+
+ coVerify {
+ dropboxApiService.search(
+ eq(SearchFileRequest("test query"))
+ )
+ }
+ }
+
+ @Test
+ fun `given valid fileId, list of OmhPermission for file`() =
+ runTest {
+ // Mock getNodeMetadata to return FileMetadata
+ val testFileMetadataJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedFile)
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val listFileSharedMembersResponse = testFileSharedMembersResponseJson
+ .toString()
+
+ // Mock file shared members API call
+ coEvery {
+ dropboxApiService.listFileSharedMembers(any())
+ } returns Response.success(
+ listFileSharedMembersResponse
+ .toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ coEvery {
+ dropboxApiService.continueListFileSharedMembers(any())
+ } returns Response.success("{}".toResponseBody())
+
+ val result = fileRepositoryImpl.getNodePermission("id:testFile1")
+
+ assertEquals(testExpectedPermissions, result)
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(NodeMetadataRequest("id:testFile1"))
+ )
+ }
+ coVerify {
+ dropboxApiService.listFileSharedMembers(
+ eq(ListFileSharedMembersRequest("id:testFile1"))
+ )
+ }
+ }
+
+ @Test
+ fun `given valid folderId, returns list of OmhPermission for folder`() =
+ runTest {
+ // Mock getNodeMetadata to return FolderMetadata
+ val testFolderMetadataJson =
+ objectMapper.writeValueAsString(TestFolderMetadata.testFolder)
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val listFolderSharedMemberResponse: ResponseBody =
+ testFolderSharedMembersResponseJson.toString().toResponseBody(
+ "application/json".toMediaTypeOrNull()
+ )
+
+ // Mock folder shared members API call
+ coEvery {
+ dropboxApiService.listFolderSharedMembers(any())
+ } returns Response.success(
+ listFolderSharedMemberResponse
+ )
+
+ coEvery {
+ dropboxApiService.continueListFolderSharedMembers(any())
+ } returns Response.success("{}".toResponseBody())
+
+ val result = fileRepositoryImpl.getNodePermission("id:testFolder1")
+
+ assertEquals(testExpectedPermissions, result)
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(NodeMetadataRequest("id:testFolder1"))
+ )
+ }
+ coVerify {
+ dropboxApiService.listFolderSharedMembers(
+ eq(ListFolderSharedMembersRequest("shared folder id"))
+ )
+ }
+ }
+
+ @Test
+ fun `given node not found, when getNodePermission is called, then throws ApiException`() =
+ runTest {
+ // Mock getNodeMetadata to return null (node not found)
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(null)
+
+ try {
+ fileRepositoryImpl.getNodePermission("id:nonExistentNode")
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {
+ assertEquals(404, expected.statusCode)
+ assertEquals("Node not found", expected.message)
+ }
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(NodeMetadataRequest("id:nonExistentNode"))
+ )
+ }
+ // Should not call shared members APIs when node is not found
+ coVerify(exactly = 0) {
+ dropboxApiService.listFileSharedMembers(any())
+ }
+ coVerify(exactly = 0) {
+ dropboxApiService.listFolderSharedMembers(any())
+ }
+ }
+
+ @Test
+ fun `if node not found, when createNodePermission is called, then throws ApiException with`() =
+ runTest {
+ // Arrange: node metadata returns null
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(null)
+
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.READER,
+ recipient = OmhPermissionRecipient.User(TEST_PERMISSION_USER_EMAIL)
+ )
+
+ // Act + Assert
+ try {
+ fileRepositoryImpl.createNodePermission("id:nonExistentNode", create)
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {
+ assertEquals(404, expected.statusCode)
+ assertEquals("Node not found", expected.message)
+ }
+
+ // Verify
+ coVerify {
+ dropboxApiService.getNodeMetaData(eq(NodeMetadataRequest("id:nonExistentNode")))
+ }
+ coVerify(exactly = 0) { dropboxApiService.addFileSharedMember(any()) }
+ coVerify(exactly = 0) { dropboxApiService.addFolderSharedMember(any()) }
+ }
+
+ @Test
+ fun `given valid fileId and permission, when createNodePermission is success for file`() =
+ runTest {
+ val testFileMetadataJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedFile)
+ // Will be called twice: once by createNodePermission and once by getNodePermission
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returnsMany listOf(
+ Response.success(testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())),
+ Response.success(testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull()))
+ )
+
+ // add file member succeeds
+ coEvery { dropboxApiService.addFileSharedMember(any()) } returns Response.success("{}".toResponseBody())
+
+ // list members returns the user/group set including our created user
+ coEvery { dropboxApiService.listFileSharedMembers(any()) } returns Response.success(
+ testFileSharedMembersResponseJson.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.continueListFileSharedMembers(any()) } returns Response.success(
+ "{}".toResponseBody()
+ )
+
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.READER,
+ recipient = OmhPermissionRecipient.User(TEST_PERMISSION_USER_EMAIL)
+ )
+
+ val result = fileRepositoryImpl.createNodePermission("id:testFile1", create)
+
+ assertEquals(testOmhUserPermission, result)
+ coVerify {
+ dropboxApiService.getNodeMetaData(eq(NodeMetadataRequest("id:testFile1")))
+ }
+ coVerify {
+ dropboxApiService.addFileSharedMember(
+ withArg { req ->
+ assertEquals("id:testFile1", req.fileId)
+ assertEquals(1, req.members.size)
+ assertEquals("email", req.members.first().tag)
+ assertEquals(TEST_PERMISSION_USER_EMAIL, req.members.first().email)
+ assertEquals(OmhPermissionRole.READER, req.accessLevel)
+ }
+ )
+ }
+ coVerify {
+ dropboxApiService.listFileSharedMembers(eq(ListFileSharedMembersRequest("id:testFile1")))
+ }
+ }
+
+ @Test
+ fun `given valid folderId and permission, when createNodePermission is success for folder`() =
+ runTest {
+ // Use folder with existing sharedFolderId to avoid sharing flow
+ val testFolderWithSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = "existing_shared_folder_id")
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithSharing)
+ // Will be called twice
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returnsMany listOf(
+ Response.success(testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())),
+ Response.success(testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull()))
+ )
+
+ // add folder member succeeds
+ coEvery { dropboxApiService.addFolderSharedMember(any()) } returns Response.success("{}".toResponseBody())
+
+ // list members returns the user/group set including our created group
+ // Mock permission listing for final verification - should include the newly created group permission
+ val folderSharedMembersWithNewGroup = JSONObject().apply {
+ put("users", JSONArray())
+ put(
+ "groups",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("role", "editor")
+ put(
+ "group",
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("name", TEST_PERMISSION_GROUP_NAME)
+ }
+ )
+ put("is_inherited", false)
+ }
+ )
+ }
+ )
+ put("cursor", "")
+ put("has_more", false)
+ }
+ coEvery { dropboxApiService.listFolderSharedMembers(any()) } returns Response.success(
+ folderSharedMembersWithNewGroup.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.continueListFolderSharedMembers(any()) } returns Response.success(
+ "{}".toResponseBody()
+ )
+
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.WRITER,
+ recipient = OmhPermissionRecipient.WithObjectId(TEST_PERMISSION_GROUP_ID)
+ )
+
+ val result = fileRepositoryImpl.createNodePermission("id:testFolder1", create)
+
+ assertEquals(testOmhGroupPermission, result)
+ coVerify {
+ dropboxApiService.getNodeMetaData(eq(NodeMetadataRequest("id:testFolder1")))
+ }
+ coVerify {
+ dropboxApiService.addFolderSharedMember(
+ withArg { req ->
+ assertEquals("existing_shared_folder_id", req.sharedFolderId)
+ assertEquals(1, req.members.size)
+ assertEquals("dropbox_id", req.members.first().member.tag)
+ assertEquals(TEST_PERMISSION_GROUP_ID, req.members.first().member.userId)
+ assertEquals(OmhPermissionRole.WRITER, req.members.first().accessLevel)
+ }
+ )
+ }
+ coVerify {
+ dropboxApiService.listFolderSharedMembers(
+ eq(ListFolderSharedMembersRequest("existing_shared_folder_id"))
+ )
+ }
+ }
+
+ @Test
+ fun `given createNodePermission fails for file, when called, then throws ApiException`() =
+ runTest {
+ val testFileMetadataJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedFile)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.addFileSharedMember(any()) } returns Response.error(
+ 500,
+ "Server error".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.createNodePermission(
+ "id:testFile1",
+ OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.READER,
+ recipient = OmhPermissionRecipient.User(TEST_PERMISSION_USER_EMAIL)
+ )
+ )
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {}
+
+ coVerify { dropboxApiService.addFileSharedMember(any()) }
+ }
+
+ @Test
+ fun `given createNodePermission fails for folder, when called, then throws ApiException`() =
+ runTest {
+ // Use folder with existing sharedFolderId to avoid sharing flow
+ val testFolderWithSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = "existing_shared_folder_id")
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithSharing)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.addFolderSharedMember(any()) } returns Response.error(
+ 500,
+ "Server error".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.createNodePermission(
+ "id:testFolder1",
+ OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.WRITER,
+ recipient = OmhPermissionRecipient.WithObjectId(TEST_PERMISSION_GROUP_ID)
+ )
+ )
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {}
+
+ coVerify { dropboxApiService.addFolderSharedMember(any()) }
+ }
+
+ @Test
+ fun `given valid fileId and permissionId, updateNodePermission success for file`() =
+ runTest {
+ val testFileMetadataJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedFile)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returnsMany listOf(
+ Response.success(testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())),
+ Response.success(testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull()))
+ )
+ coEvery { dropboxApiService.updateFileSharedMember(any()) } returns Response.success("{}".toResponseBody())
+
+ // Create updated response with WRITER role instead of READER
+ val updatedFileSharedMembersResponse = JSONObject().apply {
+ put(
+ "users",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put(
+ "access_type",
+ JSONObject().apply { put(".tag", "editor") } // WRITER role
+ )
+ put("is_inherited", false)
+ put(
+ "user",
+ JSONObject().apply {
+ put("account_id", TEST_PERMISSION_USER_ID)
+ put("display_name", TEST_PERMISSION_USER_NAME)
+ put("email", TEST_PERMISSION_USER_EMAIL)
+ }
+ )
+ }
+ )
+ }
+ )
+ put("groups", JSONArray())
+ }
+
+ coEvery { dropboxApiService.listFileSharedMembers(any()) } returns Response.success(
+ updatedFileSharedMembersResponse.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.continueListFileSharedMembers(any()) } returns Response.success(
+ "{}".toResponseBody()
+ )
+
+ val result = fileRepositoryImpl.updateNodePermission(
+ "id:testFile1",
+ TEST_PERMISSION_USER_ID,
+ OmhPermissionRole.WRITER
+ )
+
+ assertEquals(testOmhUserPermission.copy(role = OmhPermissionRole.WRITER), result)
+ coVerify {
+ dropboxApiService.updateFileSharedMember(
+ withArg { req ->
+ assertEquals("id:testFile1", req.fileId)
+ assertEquals(OmhPermissionRole.WRITER, req.accessLevel)
+ assertEquals("dropbox_id", req.member.tag)
+ assertEquals(TEST_PERMISSION_USER_ID, req.member.userId)
+ }
+ )
+ }
+ }
+
+ @Test
+ fun `given valid folderId and permissionId, updateNodePermission success for folder`() =
+ runTest {
+ val testFolderWithSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = "existing_shared_folder_id")
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithSharing)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returnsMany listOf(
+ Response.success(testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())),
+ Response.success(testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull()))
+ )
+ coEvery { dropboxApiService.updateFolderSharedMember(any()) } returns Response.success(
+ "{}".toResponseBody()
+ )
+
+ // Create updated response with READER role instead of WRITER for group
+ val updatedFolderSharedMembersResponse = JSONObject().apply {
+ put("users", JSONArray())
+ put(
+ "groups",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("role", "viewer_no_comment") // READER role
+ put("is_inherited", false)
+ put(
+ "group",
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("name", TEST_PERMISSION_GROUP_NAME)
+ }
+ )
+ }
+ )
+ }
+ )
+ }
+
+ coEvery { dropboxApiService.listFolderSharedMembers(any()) } returns Response.success(
+ updatedFolderSharedMembersResponse.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.continueListFolderSharedMembers(any()) } returns Response.success(
+ "{}".toResponseBody()
+ )
+
+ val result = fileRepositoryImpl.updateNodePermission(
+ "id:testFolder1",
+ TEST_PERMISSION_GROUP_ID,
+ OmhPermissionRole.READER
+ )
+
+ assertEquals(testOmhGroupPermission.copy(role = OmhPermissionRole.READER), result)
+ coVerify {
+ dropboxApiService.updateFolderSharedMember(
+ withArg { req ->
+ assertEquals("existing_shared_folder_id", req.sharedFolderId)
+ assertEquals(OmhPermissionRole.READER, req.accessLevel)
+ assertEquals("dropbox_id", req.member.tag)
+ assertEquals(TEST_PERMISSION_GROUP_ID, req.member.userId)
+ }
+ )
+ }
+ }
+
+ @Test
+ fun `given updateNodePermission fails for file, when called, then throws ApiException`() =
+ runTest {
+ val testFileMetadataJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedFile)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.updateFileSharedMember(any()) } returns Response.error(
+ 500,
+ "Server error".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.updateNodePermission(
+ "id:testFile1",
+ TEST_PERMISSION_USER_ID,
+ OmhPermissionRole.WRITER
+ )
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {}
+
+ coVerify { dropboxApiService.updateFileSharedMember(any()) }
+ }
+
+ @Test
+ fun `given updateNodePermission fails for folder, when called, then throws ApiException`() =
+ runTest {
+ val testFolderWithSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = "existing_shared_folder_id")
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithSharing)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.updateFolderSharedMember(any()) } returns Response.error(
+ 500,
+ "Server error".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.updateNodePermission(
+ "id:testFolder1",
+ TEST_PERMISSION_GROUP_ID,
+ OmhPermissionRole.READER
+ )
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {}
+
+ coVerify { dropboxApiService.updateFolderSharedMember(any()) }
+ }
+
+ @Test
+ fun `given node not found, when updateNodePermission is called, then throws ApiException with 404`() =
+ runTest {
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(null)
+
+ try {
+ fileRepositoryImpl.updateNodePermission("id:nonExistentNode", "anyPermId", OmhPermissionRole.WRITER)
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {
+ assertEquals(404, expected.statusCode)
+ assertEquals("Node not found", expected.message)
+ }
+
+ coVerify { dropboxApiService.getNodeMetaData(eq(NodeMetadataRequest("id:nonExistentNode"))) }
+ coVerify(exactly = 0) { dropboxApiService.updateFileSharedMember(any()) }
+ coVerify(exactly = 0) { dropboxApiService.updateFolderSharedMember(any()) }
+ }
+
+ @Test
+ fun `given valid fileId and permissionId, return true when deleteNodePermission success`() =
+ runTest {
+ val testFileMetadataJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedFile)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.deleteFileSharedMembers(any()) } returns Response.success(Unit)
+
+ val result = fileRepositoryImpl.deleteNodePermission("id:testFile1", "user123")
+
+ assertTrue(result)
+ coVerify { dropboxApiService.getNodeMetaData(eq(NodeMetadataRequest("id:testFile1"))) }
+ coVerify {
+ dropboxApiService.deleteFileSharedMembers(
+ eq(DeleteFileSharedMemberRequest(fileId = "id:testFile1", memberId = "user123"))
+ )
+ }
+ }
+
+ @Test
+ fun `given valid folderId and permissionId, return true when deleteNodePermission success`() =
+ runTest {
+ val testFolderMetadataJson = objectMapper.writeValueAsString(TestFolderMetadata.testFolder)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.deleteFolderSharedMembers(any()) } returns Response.success(Unit)
+
+ val result = fileRepositoryImpl.deleteNodePermission("id:testFolder1", "group456")
+
+ assertTrue(result)
+ coVerify { dropboxApiService.getNodeMetaData(eq(NodeMetadataRequest("id:testFolder1"))) }
+ coVerify {
+ dropboxApiService.deleteFolderSharedMembers(
+ eq(
+ DeleteFolderSharedMemberRequest(
+ sharedFolderId = "shared folder id",
+ memberId = "group456"
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `given node not found, when deleteNodePermission is called, then throws ApiException`() =
+ runTest {
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(null)
+
+ try {
+ fileRepositoryImpl.deleteNodePermission("id:nonExistentNode", "anyPermId")
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {
+ assertEquals(404, expected.statusCode)
+ assertEquals("Node not found", expected.message)
+ }
+
+ coVerify { dropboxApiService.getNodeMetaData(eq(NodeMetadataRequest("id:nonExistentNode"))) }
+ coVerify(exactly = 0) { dropboxApiService.deleteFileSharedMembers(any()) }
+ coVerify(exactly = 0) { dropboxApiService.deleteFolderSharedMembers(any()) }
+ }
+
+ @Test
+ fun `given valid nodeId, when getNodeMetadataRaw is success, then returns JSON string`() =
+ runTest {
+ val testFileMetadataJson = objectMapper.writeValueAsString(
+ TestFileMetadata.testCreatedFile
+ )
+
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ // Use reflection to access the private getNodeMetadataRaw method
+ val method = DropboxRestfulFileRepository::class.memberFunctions.find {
+ it.name == "getNodeMetadataRaw"
+ }.also { it?.isAccessible = true }
+
+ val result = method?.callSuspend(fileRepositoryImpl, "id:testFileId", null) as String?
+
+ assertNotNull(result)
+ assertEquals(testFileMetadataJson, result)
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(
+ NodeMetadataRequest(
+ "id:testFileId"
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `given valid path, when getNodeMetadataRaw is success, then returns JSON string`() =
+ runTest {
+ val testFolderMetadataJson = objectMapper.writeValueAsString(
+ TestFolderMetadata.testFolder
+ )
+
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ // Use reflection to access the private getNodeMetadataRaw method
+ val method = DropboxRestfulFileRepository::class.memberFunctions.find {
+ it.name == "getNodeMetadataRaw"
+ }.also { it?.isAccessible = true }
+
+ val result = method?.callSuspend(fileRepositoryImpl, null, "/test/folder/path") as String?
+
+ assertNotNull(result)
+ assertEquals(testFolderMetadataJson, result)
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(
+ NodeMetadataRequest(
+ "/test/folder/path"
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `given getNodeMetadataRaw returns null response body, when called, then returns null`() =
+ runTest {
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(null)
+
+ // Use reflection to access the private getNodeMetadataRaw method
+ val method = DropboxRestfulFileRepository::class.memberFunctions.find {
+ it.name == "getNodeMetadataRaw"
+ }.also { it?.isAccessible = true }
+
+ val result = method?.callSuspend(fileRepositoryImpl, "id:testFileId", null) as String?
+
+ assertEquals(null, result)
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(
+ NodeMetadataRequest(
+ "id:testFileId"
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `given file JSON, when jsonToNodeMetadata is called, then returns FileMetadata`() =
+ runTest {
+ // Create a valid file JSON
+ val fileJson = org.json.JSONObject().apply {
+ put(".tag", "file")
+ put("id", "test_file_id")
+ put("name", "test_file.txt")
+ put("path_lower", "/test_file.txt")
+ put("path_display", "/test_file.txt")
+ put("size", 1024)
+ put("server_modified", "2023-10-01T12:00:00Z")
+ put("client_modified", "2023-10-01T12:00:00Z")
+ put("rev", "test_rev")
+ put("is_downloadable", true)
+ }
+
+ // Use reflection to access the private jsonToNodeMetadata method
+ val method = DropboxRestfulFileRepository::class.java.getDeclaredMethod(
+ "jsonToNodeMetadata",
+ JSONObject::class.java
+ )
+ method.isAccessible = true
+
+ val result = method.invoke(fileRepositoryImpl, fileJson)
+
+ assertTrue(
+ result is FileMetadata
+ )
+ val fileMetadata = result as FileMetadata
+ assertEquals("test_file_id", fileMetadata.id)
+ assertEquals("test_file.txt", fileMetadata.name)
+ assertEquals("/test_file.txt", fileMetadata.path)
+ }
+
+ @Test
+ fun `given folder JSON, when jsonToNodeMetadata is called, then returns FolderMetadata`() =
+ runTest {
+ // Create a valid folder JSON
+ val folderJson = JSONObject().apply {
+ put(".tag", "folder")
+ put("id", "test_folder_id")
+ put("name", "test_folder")
+ put("path_lower", "/test_folder")
+ put("path_display", "/test_folder")
+ }
+
+ // Use reflection to access the private jsonToNodeMetadata method
+ val method = DropboxRestfulFileRepository::class.java.getDeclaredMethod(
+ "jsonToNodeMetadata",
+ JSONObject::class.java
+ )
+ method.isAccessible = true
+
+ val result = method.invoke(fileRepositoryImpl, folderJson)
+
+ assertTrue(
+ result is FolderMetadata
+ )
+ val folderMetadata = result as FolderMetadata
+ assertEquals("test_folder_id", folderMetadata.id)
+ assertEquals("test_folder", folderMetadata.name)
+ assertEquals("/test_folder", folderMetadata.path)
+ }
+
+ @Test
+ fun `given valid fileId and exportedMimeType, when exportFile is success, then returns ByteArrayOutputStream`() =
+ runTest {
+ val testFileMetadataJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedPaper)
+
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ coEvery {
+ dropboxContentApiService.exportFile(any())
+ } returns Response.success(
+ "exported file content".toResponseBody("text/html".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.exportFile("id:testFile1", "html")
+
+ assertNotNull(result)
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(
+ NodeMetadataRequest(
+ "id:testFile1"
+ )
+ )
+ )
+ }
+ coVerify {
+ dropboxContentApiService.exportFile(
+ match {
+ val obj = objectMapper.readValue(it, ExportFileRequest::class.java)
+ obj.path == "id:testFile1" && obj.exportFormat == "html"
+ }
+ )
+ }
+ }
+
+ @Test
+ fun `given exportFile API call fails, when exportFile is called, then throws ApiException`() =
+ runTest {
+ val testFileMetadataJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedFile)
+
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(
+ testFileMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ coEvery {
+ dropboxContentApiService.exportFile(any())
+ } returns Response.error(
+ 500,
+ "Export failed".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ try {
+ fileRepositoryImpl.exportFile("id:testFile1", "application/pdf")
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {}
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(any())
+ }
+ coVerify {
+ dropboxContentApiService.exportFile(any())
+ }
+ }
+
+ @Test
+ fun `given file not found, when exportFile is called, then throws ApiException with 404`() =
+ runTest {
+ coEvery {
+ dropboxApiService.getNodeMetaData(any())
+ } returns Response.success(null)
+
+ try {
+ fileRepositoryImpl.exportFile("id:nonExistentFile", "application/pdf")
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {
+ assertEquals(404, expected.statusCode)
+ assertEquals("File not found", expected.message)
+ }
+
+ coVerify {
+ dropboxApiService.getNodeMetaData(
+ eq(
+ NodeMetadataRequest(
+ "id:nonExistentFile"
+ )
+ )
+ )
+ }
+ coVerify(exactly = 0) {
+ dropboxContentApiService.exportFile(any())
+ }
+ }
+
+ @Test
+ fun `given valid fileId, when getTemporaryLink is called then returns preview url`() = runTest {
+ val fileJson = objectMapper.writeValueAsString(TestFileMetadata.testCreatedFile)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ fileJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ val sharedFile = SharedFileMetadata(
+ id = "newly created file id",
+ name = "test file.txt",
+ previewUrl = "https://preview/file",
+ path = "/test file.txt"
+ )
+ coEvery { dropboxApiService.getFileSharingMetadata(any()) } returns Response.success(sharedFile)
+
+ val result = fileRepositoryImpl.getTemporaryLink("id:testFile1")
+
+ assertEquals("https://preview/file", result)
+ coVerify { dropboxApiService.getFileSharingMetadata(match { it.file == "newly created file id" }) }
+ }
+
+ @Test
+ fun `given folderId with sharing info when getTemporaryLink is called then returns preview url`() = runTest {
+ val folderWithSharing = TestFolderMetadata.testFolder.copy(
+ sharingInfo = FolderSharingInfo(sharedFolderId = "sfid123")
+ )
+ val folderJson = objectMapper.writeValueAsString(folderWithSharing)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ folderJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ val sharedFolder = SharedFolderMetadata(
+ id = folderWithSharing.id,
+ name = folderWithSharing.name,
+ previewUrl = "https://preview/folder",
+ path = folderWithSharing.path,
+ sharedFolderId = "sfid123"
+ )
+ coEvery { dropboxApiService.getFolderSharingMetadata(any()) } returns Response.success(sharedFolder)
+
+ val result = fileRepositoryImpl.getTemporaryLink("id:newly created folder id")
+
+ assertEquals("https://preview/folder", result)
+ coVerify { dropboxApiService.getFolderSharingMetadata(match { it.sharedFolderId == "sfid123" }) }
+ }
+
+ @Test
+ fun `given folderId without sharing info when getTemporaryLink is called then returns null`() = runTest {
+ val folderJson = objectMapper.writeValueAsString(TestFolderMetadata.testFolder)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ folderJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val result = fileRepositoryImpl.getTemporaryLink("id:newly created folder id")
+
+ assertEquals(null, result)
+ coVerify(exactly = 0) { dropboxApiService.getFolderSharingMetadata(any()) }
+ }
+
+ @Test
+ fun `given node not found when getTemporaryLink is called then returns null`() = runTest {
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(null)
+
+ val result = fileRepositoryImpl.getTemporaryLink("id:missing")
+
+ assertEquals(null, result)
+ coVerify { dropboxApiService.getNodeMetaData(eq(NodeMetadataRequest("id:missing"))) }
+ }
+
+ // Tests for share job polling behavior
+
+ @Test
+ fun `folder without sharedFolderId, createNodePermission polls job status successfully after retry`() =
+ runTest {
+ // Create test folder without sharedFolderId (needs to be shared first)
+ val testFolderWithoutSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = null)
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithoutSharing)
+
+ // After sharing job completes, the folder should have a sharedFolderId
+ val testFolderWithSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = "sf_123")
+ val testFolderWithSharingJson = objectMapper.writeValueAsString(testFolderWithSharing)
+
+ // Mock getNodeMetadata calls (first without sharing, then with sharing after job completes)
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returnsMany listOf(
+ Response.success(testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())),
+ Response.success(testFolderWithSharingJson.toResponseBody("application/json".toMediaTypeOrNull()))
+ )
+
+ // Mock shareFolder to return a job
+ val shareJobResponse = ShareJobResponse(jobId = "test_job_123")
+ coEvery { dropboxApiService.shareFolder(any()) } returns Response.success(shareJobResponse)
+
+ // Mock job status polling - first two calls return in_progress, third returns complete
+ val inProgressResponseContent = JSONObject().apply {
+ put(".tag", "in_progress")
+ }.toString()
+
+ val completedResponseContent = JSONObject().apply {
+ put(".tag", "complete")
+ put("folder_id", "sf_123")
+ put("shared_folder_id", "sf_123")
+ put("name", "test folder")
+ put("preview_url", "https://preview.url")
+ }.toString()
+
+ coEvery { dropboxApiService.checkShareJobStatus(any()) } returnsMany listOf(
+ Response.success(createReusableResponseBody(inProgressResponseContent)),
+ Response.success(createReusableResponseBody(inProgressResponseContent)),
+ Response.success(createReusableResponseBody(completedResponseContent))
+ )
+
+ // Mock addFolderSharedMember success
+ coEvery { dropboxApiService.addFolderSharedMember(any()) } returns Response.success("{}".toResponseBody())
+
+ // Mock permission listing for final verification - should include the newly created group permission
+ val folderSharedMembersWithNewGroup = JSONObject().apply {
+ put("users", JSONArray())
+ put(
+ "groups",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("role", "editor")
+ put(
+ "group",
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("name", TEST_PERMISSION_GROUP_NAME)
+ }
+ )
+ put("is_inherited", false)
+ }
+ )
+ }
+ )
+ put("cursor", "")
+ put("has_more", false)
+ }
+ coEvery { dropboxApiService.listFolderSharedMembers(any()) } returns Response.success(
+ folderSharedMembersWithNewGroup.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.continueListFolderSharedMembers(any()) } returns Response.success(
+ "{}".toResponseBody()
+ )
+
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.WRITER,
+ recipient = OmhPermissionRecipient.WithObjectId(TEST_PERMISSION_GROUP_ID)
+ )
+
+ val result = fileRepositoryImpl.createNodePermission("id:testFolder1", create)
+
+ assertEquals(testOmhGroupPermission, result)
+
+ // Verify the sequence of calls
+ coVerify { dropboxApiService.shareFolder(eq(ShareFolderRequestBody("id:testFolder1", true))) }
+ coVerify(exactly = 3) {
+ dropboxApiService.checkShareJobStatus(
+ eq(CheckShareJobStatusRequest("test_job_123"))
+ )
+ }
+ coVerify {
+ dropboxApiService.addFolderSharedMember(
+ withArg { req ->
+ assertEquals("sf_123", req.sharedFolderId) // Should use the returned sharedFolderId
+ assertEquals(1, req.members.size)
+ assertEquals("dropbox_id", req.members.first().member.tag)
+ assertEquals(TEST_PERMISSION_GROUP_ID, req.members.first().member.userId)
+ assertEquals(OmhPermissionRole.WRITER, req.members.first().accessLevel)
+ }
+ )
+ }
+ }
+
+ @Test
+ fun `given folder sharing job completes immediately, when createNodePermission called, then no polling required`() =
+ runTest {
+ val testFolderWithoutSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = null)
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithoutSharing)
+
+ // After sharing job completes, the folder should have a sharedFolderId
+ val testFolderWithSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = "sf_456")
+ val testFolderWithSharingJson = objectMapper.writeValueAsString(testFolderWithSharing)
+
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returnsMany listOf(
+ Response.success(testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())),
+ Response.success(testFolderWithSharingJson.toResponseBody("application/json".toMediaTypeOrNull()))
+ )
+
+ val shareJobResponse = ShareJobResponse(jobId = "immediate_job_123")
+ coEvery { dropboxApiService.shareFolder(any()) } returns Response.success(shareJobResponse)
+
+ // Job completes immediately on first check
+ val completedResponseContent = JSONObject().apply {
+ put(".tag", "complete")
+ put("folder_id", "sf_456")
+ put("shared_folder_id", "sf_456")
+ put("name", "test folder")
+ put("preview_url", "https://preview.url")
+ }.toString()
+
+ coEvery { dropboxApiService.checkShareJobStatus(any()) } returns Response.success(
+ createReusableResponseBody(completedResponseContent)
+ )
+
+ coEvery { dropboxApiService.addFolderSharedMember(any()) } returns Response.success("{}".toResponseBody())
+
+ // Mock permission listing for final verification - should include the newly created group permission
+ val folderSharedMembersWithNewGroup = JSONObject().apply {
+ put("users", JSONArray())
+ put(
+ "groups",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("role", "editor")
+ put(
+ "group",
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("name", TEST_PERMISSION_GROUP_NAME)
+ }
+ )
+ put("is_inherited", false)
+ }
+ )
+ }
+ )
+ put("cursor", "")
+ put("has_more", false)
+ }
+ coEvery { dropboxApiService.listFolderSharedMembers(any()) } returns Response.success(
+ folderSharedMembersWithNewGroup.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.continueListFolderSharedMembers(any()) } returns Response.success(
+ "{}".toResponseBody()
+ )
+
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.WRITER,
+ recipient = OmhPermissionRecipient.WithObjectId(TEST_PERMISSION_GROUP_ID)
+ )
+
+ val result = fileRepositoryImpl.createNodePermission("id:testFolder1", create)
+
+ assertEquals(testOmhGroupPermission, result)
+
+ // Verify only one job status check was needed
+ coVerify(exactly = 1) {
+ dropboxApiService.checkShareJobStatus(
+ eq(CheckShareJobStatusRequest("immediate_job_123"))
+ )
+ }
+ }
+
+ @Test
+ fun `given folder sharing job fails, when createNodePermission called, then throws ApiException`() =
+ runTest {
+ val testFolderWithoutSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = null)
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithoutSharing)
+
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val shareJobResponse = ShareJobResponse(jobId = "failed_job_123")
+ coEvery { dropboxApiService.shareFolder(any()) } returns Response.success(shareJobResponse)
+
+ // Job fails after being in progress
+ val inProgressResponseContent = JSONObject().apply {
+ put(".tag", "in_progress")
+ }.toString()
+
+ val failedResponseContent = JSONObject().apply {
+ put(".tag", "failed")
+ put("error", "Sharing failed due to permissions")
+ }.toString()
+
+ coEvery { dropboxApiService.checkShareJobStatus(any()) } returnsMany listOf(
+ Response.success(createReusableResponseBody(inProgressResponseContent)),
+ Response.success(createReusableResponseBody(failedResponseContent))
+ )
+
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.WRITER,
+ recipient = OmhPermissionRecipient.WithObjectId(TEST_PERMISSION_GROUP_ID)
+ )
+
+ try {
+ fileRepositoryImpl.createNodePermission("id:testFolder1", create)
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {
+ assertEquals(500, expected.statusCode)
+ assertTrue(expected.message!!.contains("Share job failed"))
+ assertTrue(expected.message!!.contains("Sharing failed due to permissions"))
+ }
+
+ coVerify { dropboxApiService.shareFolder(eq(ShareFolderRequestBody("id:testFolder1", true))) }
+ coVerify(exactly = 2) {
+ dropboxApiService.checkShareJobStatus(
+ eq(CheckShareJobStatusRequest("failed_job_123"))
+ )
+ }
+ // Should not attempt to add folder member if sharing failed
+ coVerify(exactly = 0) { dropboxApiService.addFolderSharedMember(any()) }
+ }
+
+ @Test
+ fun `given folder sharing job times out, when createNodePermission called, then throws ApiException`() =
+ runTest {
+ val testFolderWithoutSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = null)
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithoutSharing)
+
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val shareJobResponse = ShareJobResponse(jobId = "timeout_job_123")
+ coEvery { dropboxApiService.shareFolder(any()) } returns Response.success(shareJobResponse)
+
+ // Job always returns in_progress (simulating timeout)
+ val inProgressResponseContent = JSONObject().apply {
+ put(".tag", "in_progress")
+ }.toString()
+
+ coEvery { dropboxApiService.checkShareJobStatus(any()) } returns Response.success(
+ createReusableResponseBody(inProgressResponseContent)
+ )
+
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.WRITER,
+ recipient = OmhPermissionRecipient.WithObjectId(TEST_PERMISSION_GROUP_ID)
+ )
+
+ try {
+ fileRepositoryImpl.createNodePermission("id:testFolder1", create)
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {
+ assertEquals(408, expected.statusCode)
+ assertTrue(expected.message!!.contains("Share job timed out"))
+ }
+
+ coVerify { dropboxApiService.shareFolder(eq(ShareFolderRequestBody("id:testFolder1", true))) }
+ // Should make maximum number of attempts
+ coVerify(exactly = 30) {
+ dropboxApiService.checkShareJobStatus(
+ eq(CheckShareJobStatusRequest("timeout_job_123"))
+ )
+ }
+ coVerify(exactly = 0) { dropboxApiService.addFolderSharedMember(any()) }
+ }
+
+ @Test
+ fun `given folder sharing job status API fails, when createNodePermission called, then throws ApiException`() =
+ runTest {
+ val testFolderWithoutSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = null)
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithoutSharing)
+
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returns Response.success(
+ testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())
+ )
+
+ val shareJobResponse = ShareJobResponse(jobId = "api_fail_job_123")
+ coEvery { dropboxApiService.shareFolder(any()) } returns Response.success(shareJobResponse)
+
+ // Job status API call fails
+ coEvery { dropboxApiService.checkShareJobStatus(any()) } returns Response.error(
+ 500,
+ "Job status API unavailable".toResponseBody("text/plain".toMediaTypeOrNull())
+ )
+
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.WRITER,
+ recipient = OmhPermissionRecipient.WithObjectId(TEST_PERMISSION_GROUP_ID)
+ )
+
+ try {
+ fileRepositoryImpl.createNodePermission("id:testFolder1", create)
+ fail("Expected ApiException to be thrown")
+ } catch (expected: OmhStorageException.ApiException) {
+ assertEquals(500, expected.statusCode)
+ }
+
+ coVerify { dropboxApiService.shareFolder(eq(ShareFolderRequestBody("id:testFolder1", true))) }
+ coVerify(exactly = 1) {
+ dropboxApiService.checkShareJobStatus(
+ eq(CheckShareJobStatusRequest("api_fail_job_123"))
+ )
+ }
+ coVerify(exactly = 0) { dropboxApiService.addFolderSharedMember(any()) }
+ }
+
+ @Test
+ fun `given folder with existing sharedFolderId, when createNodePermission called, then no sharing job needed`() =
+ runTest {
+ // Create test folder WITH existing sharedFolderId
+ val testFolderWithSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = "existing_sf_123")
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithSharing)
+
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returnsMany listOf(
+ Response.success(testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())),
+ Response.success(testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull()))
+ )
+
+ coEvery { dropboxApiService.addFolderSharedMember(any()) } returns Response.success("{}".toResponseBody())
+ // Mock permission listing for final verification - should include the newly created group permission
+ val folderSharedMembersWithNewGroup = JSONObject().apply {
+ put("users", JSONArray())
+ put(
+ "groups",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("role", "editor")
+ put(
+ "group",
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("name", TEST_PERMISSION_GROUP_NAME)
+ }
+ )
+ put("is_inherited", false)
+ }
+ )
+ }
+ )
+ put("cursor", "")
+ put("has_more", false)
+ }
+ coEvery { dropboxApiService.listFolderSharedMembers(any()) } returns Response.success(
+ folderSharedMembersWithNewGroup.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.continueListFolderSharedMembers(any()) } returns Response.success(
+ "{}".toResponseBody()
+ )
+
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.WRITER,
+ recipient = OmhPermissionRecipient.WithObjectId(TEST_PERMISSION_GROUP_ID)
+ )
+
+ val result = fileRepositoryImpl.createNodePermission("id:testFolder1", create)
+
+ assertEquals(testOmhGroupPermission, result)
+
+ // Verify no sharing job was initiated since folder already has sharedFolderId
+ coVerify(exactly = 0) { dropboxApiService.shareFolder(any()) }
+ coVerify(exactly = 0) { dropboxApiService.checkShareJobStatus(any()) }
+ coVerify {
+ dropboxApiService.addFolderSharedMember(
+ withArg { req ->
+ assertEquals("existing_sf_123", req.sharedFolderId) // Should use existing sharedFolderId
+ assertEquals(1, req.members.size)
+ assertEquals("dropbox_id", req.members.first().member.tag)
+ assertEquals(TEST_PERMISSION_GROUP_ID, req.members.first().member.userId)
+ assertEquals(OmhPermissionRole.WRITER, req.members.first().accessLevel)
+ }
+ )
+ }
+ }
+
+ @Test
+ fun `given folder sharing job returns unknown status, when createNodePermission called, then continues polling`() =
+ runTest {
+ val testFolderWithoutSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = null)
+ val testFolderMetadataJson = objectMapper.writeValueAsString(testFolderWithoutSharing)
+
+ // After sharing job completes, the folder should have a sharedFolderId
+ val testFolderWithSharing = TestFolderMetadata.testFolder.copy(sharedFolderId = "sf_789")
+ val testFolderWithSharingJson = objectMapper.writeValueAsString(testFolderWithSharing)
+
+ coEvery { dropboxApiService.getNodeMetaData(any()) } returnsMany listOf(
+ Response.success(testFolderMetadataJson.toResponseBody("application/json".toMediaTypeOrNull())),
+ Response.success(testFolderWithSharingJson.toResponseBody("application/json".toMediaTypeOrNull()))
+ )
+
+ val shareJobResponse = ShareJobResponse(jobId = "unknown_status_job_123")
+ coEvery { dropboxApiService.shareFolder(any()) } returns Response.success(shareJobResponse)
+
+ // First call returns unknown status, second call returns complete
+ val unknownStatusResponseContent = JSONObject().apply {
+ put(".tag", "unknown_status")
+ }.toString()
+
+ val completedResponseContent = JSONObject().apply {
+ put(".tag", "complete")
+ put("folder_id", "sf_789")
+ put("shared_folder_id", "sf_789")
+ put("name", "test folder")
+ put("preview_url", "https://preview.url")
+ }.toString()
+
+ coEvery { dropboxApiService.checkShareJobStatus(any()) } returnsMany listOf(
+ Response.success(createReusableResponseBody(unknownStatusResponseContent)),
+ Response.success(createReusableResponseBody(completedResponseContent))
+ )
+
+ coEvery { dropboxApiService.addFolderSharedMember(any()) } returns Response.success("{}".toResponseBody())
+ // Mock permission listing for final verification - should include the newly created group permission
+ val folderSharedMembersWithNewGroup = JSONObject().apply {
+ put("users", JSONArray())
+ put(
+ "groups",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("role", "editor")
+ put(
+ "group",
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("name", TEST_PERMISSION_GROUP_NAME)
+ }
+ )
+ put("is_inherited", false)
+ }
+ )
+ }
+ )
+ put("cursor", "")
+ put("has_more", false)
+ }
+ coEvery { dropboxApiService.listFolderSharedMembers(any()) } returns Response.success(
+ folderSharedMembersWithNewGroup.toString().toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ coEvery { dropboxApiService.continueListFolderSharedMembers(any()) } returns Response.success(
+ "{}".toResponseBody()
+ )
+
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ role = OmhPermissionRole.WRITER,
+ recipient = OmhPermissionRecipient.WithObjectId(TEST_PERMISSION_GROUP_ID)
+ )
+
+ val result = fileRepositoryImpl.createNodePermission("id:testFolder1", create)
+
+ assertEquals(testOmhGroupPermission, result)
+
+ // Verify unknown status was handled and polling continued
+ coVerify(exactly = 2) {
+ dropboxApiService.checkShareJobStatus(
+ eq(CheckShareJobStatusRequest("unknown_status_job_123"))
+ )
+ }
+ coVerify {
+ dropboxApiService.addFolderSharedMember(
+ withArg { req ->
+ assertEquals("sf_789", req.sharedFolderId)
+ assertEquals(1, req.members.size)
+ assertEquals("dropbox_id", req.members.first().member.tag)
+ assertEquals(TEST_PERMISSION_GROUP_ID, req.members.first().member.userId)
+ assertEquals(OmhPermissionRole.WRITER, req.members.first().accessLevel)
+ }
+ )
+ }
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/AddNodeSharedMemberRequestTest.kt b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/AddNodeSharedMemberRequestTest.kt
new file mode 100644
index 00000000..346207da
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/AddNodeSharedMemberRequestTest.kt
@@ -0,0 +1,157 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.AddFolderMember
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toMemberSelector
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import kotlin.test.Test
+
+class AddNodeSharedMemberRequestTest {
+
+ private val objectMapper: ObjectMapper = ObjectMapper().registerModule(KotlinModule())
+
+ @Test
+ fun `test write AddFileSharedMemberRequest to JSON using email`() {
+ val s =
+ objectMapper.writerFor(AddFileSharedMemberRequest::class.java).writeValueAsString(
+ AddFileSharedMemberRequest(
+ members = listOf("test@test.com".toMemberSelector()),
+ accessLevel = OmhPermissionRole.OWNER,
+ fileId = "id:This is a test",
+ quiet = false
+ )
+ )
+ val result = JSONObject(s)
+ result.getJSONArray("members").let { members ->
+ assertTrue(members.length() == 1)
+ members.getJSONObject(0).let { memberObj ->
+ assertEquals("email", memberObj.getString(".tag"))
+ assertEquals("test@test.com", memberObj.getString("email"))
+ }
+ val accessLevel = result.getJSONObject("access_level")
+ assertEquals("owner", accessLevel.getString(".tag"))
+ assertFalse(result.getBoolean("quiet"))
+ assertFalse(result.getBoolean("add_message_as_comment"))
+ assertEquals("id:This is a test", result.getString("file"))
+ }
+ }
+
+ @Test
+ fun `test write AddFileSharedMemberRequest to JSON using member ID`() {
+ val s =
+ objectMapper.writerFor(AddFileSharedMemberRequest::class.java).writeValueAsString(
+ AddFileSharedMemberRequest(
+ members = listOf("this is a member ID".toMemberSelector()),
+ accessLevel = OmhPermissionRole.OWNER,
+ fileId = "id:This is a test",
+ quiet = false
+ )
+ )
+ val result = JSONObject(s)
+ result.getJSONArray("members").let { members ->
+ assertTrue(members.length() == 1)
+ members.getJSONObject(0).let { memberObj ->
+ assertEquals("dropbox_id", memberObj.getString(".tag"))
+ assertEquals("this is a member ID", memberObj.getString("dropbox_id"))
+ }
+ val accessLevel = result.getJSONObject("access_level")
+ assertEquals("owner", accessLevel.getString(".tag"))
+ assertFalse(result.getBoolean("quiet"))
+ assertFalse(result.getBoolean("add_message_as_comment"))
+ assertEquals("id:This is a test", result.getString("file"))
+ }
+ }
+
+ @Test
+ fun `test write AddFolderSharedMemberRequest to JSON using email`() {
+ val s =
+ objectMapper.writerFor(AddFolderSharedMemberRequest::class.java).writeValueAsString(
+ AddFolderSharedMemberRequest(
+ members = listOf(
+ AddFolderMember(
+ member = "test@test.com".toMemberSelector(),
+ accessLevel = OmhPermissionRole.OWNER
+ )
+ ),
+ sharedFolderId = "id:This is a folder id",
+ quiet = false
+ )
+ )
+ val result = JSONObject(s)
+ result.getJSONArray("members").let { members ->
+ assertTrue(members.length() == 1)
+ members.getJSONObject(0).let { memberObj ->
+ val member = memberObj.getJSONObject("member")
+ assertEquals("email", member.getString(".tag"))
+ assertEquals("test@test.com", member.getString("email"))
+ val accessLevel = memberObj.getJSONObject("access_level")
+ assertEquals("owner", accessLevel.getString(".tag"))
+ }
+ assertFalse(result.getBoolean("quiet"))
+ assertEquals("id:This is a folder id", result.getString("shared_folder_id"))
+ }
+ }
+
+ @Test
+ fun `test write AddFolderSharedMemberRequest to JSON using member ID`() {
+ val s =
+ objectMapper.writerFor(AddFolderSharedMemberRequest::class.java).writeValueAsString(
+ AddFolderSharedMemberRequest(
+ members = listOf(
+ AddFolderMember(
+ member = "dropbox member ID".toMemberSelector(),
+ accessLevel = OmhPermissionRole.OWNER
+ )
+ ),
+ sharedFolderId = "id:This is a folder id",
+ quiet = false
+ )
+ )
+ val result = JSONObject(s)
+ result.getJSONArray("members").let { members ->
+ assertTrue(members.length() == 1)
+ members.getJSONObject(0).let { memberObj ->
+ val member = memberObj.getJSONObject("member")
+ assertEquals("dropbox_id", member.getString(".tag"))
+ assertEquals("dropbox member ID", member.getString("dropbox_id"))
+ val accessLevel = memberObj.getJSONObject("access_level")
+ assertEquals("owner", accessLevel.getString(".tag"))
+ }
+ assertFalse(result.getBoolean("quiet"))
+ assertEquals("id:This is a folder id", result.getString("shared_folder_id"))
+ }
+ }
+
+ @Test
+ fun `test unmarshall JSON to AddFileSharedMemberRequest`() {
+ val source = """
+ {
+ "file": "/test.txt",
+ "members": [
+ {
+ ".tag": "dropbox_id",
+ "dropbox_id": "123456"
+ }
+ ],
+ "access_level": {
+ ".tag": "owner"
+ },
+ "custom_message": "This is a test",
+ "quiet": false,
+ "add_message_as_comment": false
+ }
+ """.trimIndent()
+ val result: AddFileSharedMemberRequest =
+ objectMapper
+ .readerFor(AddFileSharedMemberRequest::class.java)
+ .readValue(source)
+ assertEquals("123456", result.members[0].userId)
+ assertEquals("This is a test", result.customMessage)
+ assertEquals(OmhPermissionRole.OWNER, result.accessLevel)
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UpdateNodeSharedMemberRequestTest.kt b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UpdateNodeSharedMemberRequestTest.kt
new file mode 100644
index 00000000..d5eef5f3
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/body/UpdateNodeSharedMemberRequestTest.kt
@@ -0,0 +1,49 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.body
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.mapper.toMemberSelector
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class UpdateNodeSharedMemberRequestTest {
+ private val objectMapper: ObjectMapper = ObjectMapper().registerModule(KotlinModule())
+
+ @Test
+ fun `test UpdateFileSharedMemberRequest to JSON`() {
+ val source = UpdateFileSharedMemberRequest(
+ accessLevel = OmhPermissionRole.OWNER,
+ fileId = "id:test file id 1",
+ member = "testuser@user.email".toMemberSelector()
+ )
+
+ val result = JSONObject(
+ objectMapper.writerFor(UpdateFileSharedMemberRequest::class.java)
+ .writeValueAsString(source)
+ )
+ assertEquals("email", result.getJSONObject("member").getString(".tag"))
+ assertEquals("testuser@user.email", result.getJSONObject("member").getString("email"))
+ assertEquals("owner", result.getJSONObject("access_level").getString(".tag"))
+ }
+
+ @Test
+ fun `test unmarshall JSON to UpdateFileSharedMemberRequest`() {
+ val source = """
+ {
+ "file": "id:test file id",
+ "member": {".tag":"email","email":"test@test.com"},
+ "access_level": {".tag":"viewer_no_comment"}
+ }
+ """.trimIndent()
+
+ val result: UpdateFileSharedMemberRequest =
+ objectMapper
+ .readerFor(UpdateFileSharedMemberRequest::class.java)
+ .readValue(source)
+
+ assertEquals("test@test.com", result.member.email)
+ assertEquals(OmhPermissionRole.READER, result.accessLevel)
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/GetSpaceUsageResponseTest.kt b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/GetSpaceUsageResponseTest.kt
new file mode 100644
index 00000000..5445909a
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/data/source/response/GetSpaceUsageResponseTest.kt
@@ -0,0 +1,31 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class GetSpaceUsageResponseTest {
+
+ private val objectMapper: ObjectMapper = ObjectMapper().registerModule(KotlinModule())
+
+ @Test
+ fun `test GetSpaceResponse`() {
+ val source = """
+ {
+ "used": 123456789,
+ "allocation": {
+ ".tag": "individual",
+ "allocated": 987654321
+ }
+ }
+ """.trimIndent()
+
+ val result: GetSpaceUsageResponse = objectMapper
+ .readerFor(GetSpaceUsageResponse::class.java)
+ .readValue(source)
+
+ assertEquals(123456789, result.used)
+ assertEquals(987654321, result.allocation.allocated)
+ }
+}
diff --git a/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestFileMetadata.kt b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestFileMetadata.kt
new file mode 100644
index 00000000..f90d25e2
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestFileMetadata.kt
@@ -0,0 +1,83 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles
+
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ExportInfo
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FileMetadata
+
+object TestFileMetadata {
+
+ val testFile: FileMetadata = FileMetadata(
+ "file",
+ "123456789012345678901234567890ab",
+ false,
+ "id:testFile1",
+ true,
+ "testfile.txt",
+ "/testfile.txt",
+ 36,
+ "2023-10-01T12:00:00Z",
+ "2023-10-01T12:00:00Z",
+ null,
+ "test file rev"
+ )
+
+ val testFileUploadInChunksResult: FileMetadata = FileMetadata(
+ "file",
+ "123456789012345678901234567890ab",
+ false,
+ "test file id for multi upload",
+ true,
+ "test multipart upload.txt",
+ "/test multipart upload.txt",
+ 36,
+ "2023-10-01T12:00:00Z",
+ "2023-10-01T12:00:00Z",
+ null,
+ "test file rev"
+ )
+
+ val testCreatedFile: FileMetadata = FileMetadata(
+ "file",
+ "newly created file id",
+ false,
+ "newly created file id",
+ true,
+ "test file.txt",
+ "/test file.txt",
+ 0,
+ "2023-10-01T12:00:00Z",
+ "2023-10-01T12:00:00Z",
+ null,
+ "test file rev"
+ )
+
+ val testCreatedFileWithParent: FileMetadata = FileMetadata(
+ "file",
+ "newly created file with parent id",
+ false,
+ "newly created file with parent id",
+ true,
+ "test file.txt",
+ "/test parent folder/test file.txt",
+ 0,
+ "2023-10-01T12:00:00Z",
+ "2023-10-01T12:00:00Z",
+ null,
+ "test file rev"
+ )
+
+ val testCreatedPaper: FileMetadata = FileMetadata(
+ "file",
+ "newly created file id",
+ false,
+ "newly created file id",
+ true,
+ "test dropbox.paper",
+ "/test dropbox.paper",
+ 0,
+ "2023-10-01T12:00:00Z",
+ "2023-10-01T12:00:00Z",
+ null,
+ "test file rev",
+ ExportInfo("html", listOf("markdown", "html"))
+ )
+}
diff --git a/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestFolderMetadata.kt b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestFolderMetadata.kt
new file mode 100644
index 00000000..18d42504
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestFolderMetadata.kt
@@ -0,0 +1,21 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles
+
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FolderMetadata
+
+object TestFolderMetadata {
+
+ val testParentFolder: FolderMetadata = FolderMetadata(
+ "folder",
+ "id:test folder id",
+ "test parent folder",
+ "/test parent folder"
+ )
+
+ val testFolder: FolderMetadata = FolderMetadata(
+ "folder",
+ "id:newly created folder id",
+ "newly created folder name",
+ "/new folder",
+ sharedFolderId = "shared folder id",
+ )
+}
diff --git a/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestGetSpaceUsageResponse.kt b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestGetSpaceUsageResponse.kt
new file mode 100644
index 00000000..48a79c98
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestGetSpaceUsageResponse.kt
@@ -0,0 +1,12 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles
+
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.GetSpaceUsageResponse
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.SpaceAllocation
+
+internal object TestGetSpaceUsageResponse {
+
+ internal val getSpaceUsageResponseWithQuotaImposed = GetSpaceUsageResponse(
+ used = 123456789L,
+ allocation = SpaceAllocation(allocated = 987654321L)
+ )
+}
diff --git a/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestListFolderResponse.kt b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestListFolderResponse.kt
new file mode 100644
index 00000000..e83a63c9
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestListFolderResponse.kt
@@ -0,0 +1,51 @@
+package com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles
+
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.FileMetadata
+import com.openmobilehub.android.storage.plugin.dropbox.restful.data.source.response.ListFolderResponse
+
+object TestListFolderResponse {
+
+ val testFile1 = FileMetadata(
+ tag = "file",
+ name = "testFile1.txt",
+ path = "/testFile1.txt",
+ id = "id:testFile1",
+ clientModified = "2023-10-01T12:00:00Z",
+ serverModified = "2023-10-01T12:00:00Z",
+ size = 1024,
+ downloadAble = true,
+ rev = "1234567890abcdef",
+ hasExplicitSharedMembers = false
+ )
+
+ val testFile2 = FileMetadata(
+ tag = "file",
+ name = "testFile2.txt",
+ path = "/testFile2.txt",
+ id = "id:testFile2",
+ clientModified = "2024-01-01T12:00:00Z",
+ serverModified = "2024-01-01T12:00:00Z",
+ size = 1025,
+ downloadAble = true,
+ rev = "1234567890abcdef",
+ hasExplicitSharedMembers = false
+ )
+
+ val testFileListResponseWithoutNextCursor = ListFolderResponse(
+ cursor = "test cursor 1",
+ hasMore = false,
+ entries = listOf(testFile1)
+ )
+
+ val testFileListResponseWithNextCursor = ListFolderResponse(
+ cursor = "test cursor 2",
+ hasMore = true,
+ entries = listOf(testFile1)
+ )
+
+ val testFileListResponseWithNextCursor2ndResponse = ListFolderResponse(
+ cursor = "test cursor 3",
+ hasMore = false,
+ entries = listOf(testFile2)
+ )
+}
diff --git a/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestPermissionResponse.kt b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestPermissionResponse.kt
new file mode 100644
index 00000000..977e4aaf
--- /dev/null
+++ b/packages/plugin-dropbox-restful/src/test/java/com/openmobilehub/android/storage/plugin/dropbox/restful/testdoubles/TestPermissionResponse.kt
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2023 Open Mobile Hub
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.openmobilehub.android.storage.plugin.dropbox.restful.testdoubles
+
+import com.openmobilehub.android.storage.core.model.OmhIdentity
+import com.openmobilehub.android.storage.core.model.OmhPermission
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import org.json.JSONArray
+import org.json.JSONObject
+
+object TestPermissionResponse {
+
+ const val TEST_PERMISSION_USER_ID = "user123"
+ const val TEST_PERMISSION_USER_NAME = "John Doe"
+ const val TEST_PERMISSION_USER_EMAIL = "john.doe@example.com"
+
+ const val TEST_PERMISSION_GROUP_ID = "group456"
+ const val TEST_PERMISSION_GROUP_NAME = "Test Group"
+
+ // Test file shared members response JSON
+ val testFileSharedMembersResponseJson = JSONObject().apply {
+ put(
+ "users",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ // Dropbox user permission objects expose access_type with nested .tag for the role
+ put(
+ "access_type",
+ JSONObject().apply { put(".tag", "viewer_no_comment") }
+ )
+ put("is_inherited", false)
+ put(
+ "user",
+ JSONObject().apply {
+ put("account_id", TEST_PERMISSION_USER_ID)
+ put("display_name", TEST_PERMISSION_USER_NAME)
+ put("email", TEST_PERMISSION_USER_EMAIL)
+ }
+ )
+ }
+ )
+ }
+ )
+ put(
+ "groups",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("role", "editor")
+ put("is_inherited", false)
+ put(
+ "group",
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("name", TEST_PERMISSION_GROUP_NAME)
+ }
+ )
+ }
+ )
+ }
+ )
+ }
+
+ // Test folder shared members response JSON
+ val testFolderSharedMembersResponseJson = JSONObject().apply {
+ put(
+ "users",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put(
+ "access_type",
+ JSONObject().apply { put(".tag", "viewer_no_comment") }
+ )
+ put("is_inherited", false)
+ put(
+ "user",
+ JSONObject().apply {
+ put("account_id", TEST_PERMISSION_USER_ID)
+ put("display_name", TEST_PERMISSION_USER_NAME)
+ put("email", TEST_PERMISSION_USER_EMAIL)
+ }
+ )
+ }
+ )
+ }
+ )
+ put(
+ "groups",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("role", "editor")
+ put("is_inherited", false)
+ put(
+ "group",
+ JSONObject().apply {
+ put("id", TEST_PERMISSION_GROUP_ID)
+ put("name", TEST_PERMISSION_GROUP_NAME)
+ }
+ )
+ }
+ )
+ }
+ )
+ }
+
+ // Expected OmhPermission objects for testing
+ val testOmhUserPermission = OmhPermission.IdentityPermission(
+ id = TEST_PERMISSION_USER_ID,
+ role = OmhPermissionRole.READER,
+ isInherited = false,
+ identity = OmhIdentity.User(
+ id = TEST_PERMISSION_USER_ID,
+ displayName = TEST_PERMISSION_USER_NAME,
+ emailAddress = TEST_PERMISSION_USER_EMAIL,
+ expirationTime = null,
+ deleted = null,
+ photoLink = null,
+ pendingOwner = null
+ )
+ )
+
+ val testOmhGroupPermission = OmhPermission.IdentityPermission(
+ id = TEST_PERMISSION_GROUP_ID,
+ role = OmhPermissionRole.WRITER,
+ isInherited = false,
+ identity = OmhIdentity.Group(
+ id = TEST_PERMISSION_GROUP_ID,
+ displayName = TEST_PERMISSION_GROUP_NAME,
+ emailAddress = null,
+ expirationTime = null,
+ deleted = null
+ )
+ )
+
+ val testExpectedPermissions = listOf(testOmhGroupPermission, testOmhUserPermission)
+}
diff --git a/packages/plugin-googledrive-non-gms/build.gradle.kts b/packages/plugin-googledrive-non-gms/build.gradle.kts
index e4654a48..9b26865c 100644
--- a/packages/plugin-googledrive-non-gms/build.gradle.kts
+++ b/packages/plugin-googledrive-non-gms/build.gradle.kts
@@ -23,6 +23,7 @@ val useLocalProjects = project.rootProject.extra["useLocalProjects"] as Boolean
dependencies {
if (useLocalProjects) {
api(project(":packages:core"))
+ implementation(project(":packages:core-restful-common"))
} else {
api("com.openmobilehub.android.storage:core:2.1.0-alpha")
}
diff --git a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/GoogleDriveNonGmsOmhStorageClient.kt b/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/GoogleDriveNonGmsOmhStorageClient.kt
index 4a748d8d..9183fbcf 100644
--- a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/GoogleDriveNonGmsOmhStorageClient.kt
+++ b/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/GoogleDriveNonGmsOmhStorageClient.kt
@@ -26,7 +26,7 @@ import com.openmobilehub.android.storage.core.model.OmhPermissionRole
import com.openmobilehub.android.storage.core.model.OmhStorageEntity
import com.openmobilehub.android.storage.core.model.OmhStorageException
import com.openmobilehub.android.storage.core.model.OmhStorageMetadata
-import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.mapper.LocalFileToMimeType
+import com.openmobilehub.android.storage.core.restful.common.data.mapper.LocalFileToMimeType
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.repository.NonGmsFileRepository
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.retrofit.GoogleStorageApiServiceProvider
import java.io.ByteArrayOutputStream
diff --git a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/repository/NonGmsFileRepository.kt b/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/repository/NonGmsFileRepository.kt
index b83cc567..330849de 100644
--- a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/repository/NonGmsFileRepository.kt
+++ b/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/repository/NonGmsFileRepository.kt
@@ -24,10 +24,13 @@ import com.openmobilehub.android.storage.core.model.OmhPermissionRole
import com.openmobilehub.android.storage.core.model.OmhStorageEntity
import com.openmobilehub.android.storage.core.model.OmhStorageException
import com.openmobilehub.android.storage.core.model.OmhStorageMetadata
+import com.openmobilehub.android.storage.core.restful.common.data.mapper.LocalFileToMimeType
+import com.openmobilehub.android.storage.core.restful.common.utils.isNotSuccessful
+import com.openmobilehub.android.storage.core.restful.common.utils.toApiException
+import com.openmobilehub.android.storage.core.restful.common.utils.toByteArrayOutputStream
import com.openmobilehub.android.storage.core.utils.splitPathToParts
import com.openmobilehub.android.storage.core.utils.toInputStream
import com.openmobilehub.android.storage.plugin.googledrive.nongms.GoogleDriveNonGmsConstants
-import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.mapper.LocalFileToMimeType
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.mapper.toCreateRequestBody
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.mapper.toFileList
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.mapper.toOmhFileVersions
@@ -38,9 +41,6 @@ import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.mapper.t
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.GoogleStorageApiService
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.body.CreateFileRequestBody
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.retrofit.GoogleStorageApiServiceProvider
-import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.utils.isNotSuccessful
-import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.utils.toApiException
-import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.utils.toByteArrayOutputStream
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.utils.toOmhStorageEntityMetadata
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
diff --git a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/service/retrofit/GoogleStorageApiServiceProvider.kt b/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/service/retrofit/GoogleStorageApiServiceProvider.kt
index a817e783..907a2627 100644
--- a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/service/retrofit/GoogleStorageApiServiceProvider.kt
+++ b/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/service/retrofit/GoogleStorageApiServiceProvider.kt
@@ -17,9 +17,10 @@
package com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.retrofit
import com.openmobilehub.android.auth.core.OmhAuthClient
+import com.openmobilehub.android.storage.core.restful.common.data.repository.StorageAuthenticator
+import com.openmobilehub.android.storage.core.restful.common.utils.accessToken
import com.openmobilehub.android.storage.plugin.googledrive.nongms.BuildConfig
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.GoogleStorageApiService
-import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.utils.accessToken
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
@@ -43,7 +44,10 @@ internal class GoogleStorageApiServiceProvider(private val omhAuthClient: OmhAut
}
private fun createOkHttpClient(): OkHttpClient {
- val authenticator = StorageAuthenticator(omhAuthClient)
+ val authenticator =
+ StorageAuthenticator(
+ omhAuthClient
+ )
return OkHttpClient.Builder()
.addInterceptor { chain ->
val request = setupRequestInterceptor(chain)
diff --git a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/utils/Extensions.kt b/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/utils/Extensions.kt
index 6f6e2094..8178eb6c 100644
--- a/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/utils/Extensions.kt
+++ b/packages/plugin-googledrive-non-gms/src/main/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/utils/Extensions.kt
@@ -16,31 +16,12 @@
package com.openmobilehub.android.storage.plugin.googledrive.nongms.data.utils
-import com.openmobilehub.android.auth.core.OmhAuthClient
import com.openmobilehub.android.storage.core.model.OmhStorageEntity
-import com.openmobilehub.android.storage.core.model.OmhStorageException
import com.openmobilehub.android.storage.core.model.OmhStorageMetadata
import com.openmobilehub.android.storage.core.utils.fromRFC3339StringToDate
import com.openmobilehub.android.storage.plugin.googledrive.nongms.GoogleDriveNonGmsConstants
import okhttp3.ResponseBody
import org.json.JSONObject
-import retrofit2.HttpException
-import retrofit2.Response
-import java.io.ByteArrayOutputStream
-
-fun ResponseBody?.toByteArrayOutputStream(): ByteArrayOutputStream {
- val outputStream = ByteArrayOutputStream()
-
- if (this == null) {
- return outputStream
- }
-
- byteStream().use { inputStream ->
- inputStream.copyTo(outputStream)
- }
-
- return outputStream
-}
fun ResponseBody?.toOmhStorageEntityMetadata(): OmhStorageMetadata {
val responseBody = this?.string().orEmpty()
@@ -86,12 +67,3 @@ fun ResponseBody?.toOmhStorageEntityMetadata(): OmhStorageMetadata {
return OmhStorageMetadata(omhStorageEntity, responseBody)
}
-
-fun Response.toApiException(): OmhStorageException.ApiException =
- OmhStorageException.ApiException(code(), errorBody()?.string(), HttpException(this))
-
-val Response.isNotSuccessful: Boolean
- get() = !isSuccessful
-
-val OmhAuthClient.accessToken: String?
- get() = getCredentials().accessToken
diff --git a/packages/plugin-googledrive-non-gms/src/test/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/repository/NonGmsFileRepositoryTest.kt b/packages/plugin-googledrive-non-gms/src/test/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/repository/NonGmsFileRepositoryTest.kt
index 7b1d3d55..ccbca538 100644
--- a/packages/plugin-googledrive-non-gms/src/test/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/repository/NonGmsFileRepositoryTest.kt
+++ b/packages/plugin-googledrive-non-gms/src/test/java/com/openmobilehub/android/storage/plugin/googledrive/nongms/data/repository/NonGmsFileRepositoryTest.kt
@@ -22,14 +22,14 @@ import android.webkit.MimeTypeMap
import com.openmobilehub.android.storage.core.model.OmhPermissionRole
import com.openmobilehub.android.storage.core.model.OmhStorageException
import com.openmobilehub.android.storage.core.model.OmhStorageMetadata
+import com.openmobilehub.android.storage.core.restful.common.data.mapper.LocalFileToMimeType
+import com.openmobilehub.android.storage.core.restful.common.utils.toByteArrayOutputStream
import com.openmobilehub.android.storage.core.utils.toInputStream
-import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.mapper.LocalFileToMimeType
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.repository.NonGmsFileRepository.Companion.STORAGE_QUOTA
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.GoogleStorageApiService
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.response.FileListRemoteResponse
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.response.FileRemoteResponse
import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.service.retrofit.GoogleStorageApiServiceProvider
-import com.openmobilehub.android.storage.plugin.googledrive.nongms.data.utils.toByteArrayOutputStream
import com.openmobilehub.android.storage.plugin.googledrive.nongms.testdoubles.TEST_EMAIL_MESSAGE
import com.openmobilehub.android.storage.plugin.googledrive.nongms.testdoubles.TEST_FILE_ID
import com.openmobilehub.android.storage.plugin.googledrive.nongms.testdoubles.TEST_FILE_MIME_TYPE
diff --git a/packages/plugin-onedrive-restful/.gitignore b/packages/plugin-onedrive-restful/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/packages/plugin-onedrive-restful/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/packages/plugin-onedrive-restful/README.md b/packages/plugin-onedrive-restful/README.md
new file mode 100644
index 00000000..e9fac18d
--- /dev/null
+++ b/packages/plugin-onedrive-restful/README.md
@@ -0,0 +1,54 @@
+Module plugin-onedrive-restful
+
+
+
+ 
+
+
Android OMH Storage - OneDrive (RESTful)
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+OneDrive Implementation of OMH Storage API using Microsoft's Graph API.
+
+Different from plugin-onedrive, this plugin does not depend on Microsoft Graph SDK for Java/Android, which enables usage scenarios that is imposed by Microsoft Graph SDK -
+
+Microsoft Graph SDK requires additional setup to allow SDK access the API credentials from within the app; this plugin allows API credentials be provisioned outside the app.
+
+## Usage
+
+### Set up your Microsoft Azure application
+
+Setup the Microsoft Azure application from Azure portal, the same as mentioned in original OneDrive plugin.
+
+Next, you need to setup a Redirect URI. You can use Custom URI as most Android apps would do; however for better user experience you may consider Google's recommended [verified app links](https://developer.android.com/training/app-links/verify-android-applinks) method.
+
+Then on the other hand, you don't need to provide the `ms_auth_config.json` as you may do with the original plugin-onedrive. Instead, you need to provide the `MICROSOFT_CLIENT_ID` to your app:
+
+```
+MICROSOFT_CLIENT_ID=your-azure-app-client-id
+```
+
+Apart from this, you should be able to use the plugin as-is, similar to plugin-onedrive.
+
+#### Caveats
+
+Almost all known issues in plugin-onedrive also applies to this plugin, please refer to [plugin-onedrive's documentation](https://openmobilehub.github.io/android-omh-storage/docs/plugin-onedrive) for details.
+
+### Escape Hatch
+
+This plugin does not provides an escape hatch to access the native Microsoft Graph SDK, as it uses REST API instead. If needed, you can use credentials from OmhAuthClient to authorise your own REST API client.
+
+## License
+
+- See [LICENSE](https://github.com/openmobilehub/android-omh-storage/blob/main/LICENSE)
diff --git a/packages/plugin-onedrive-restful/build.gradle.kts b/packages/plugin-onedrive-restful/build.gradle.kts
new file mode 100644
index 00000000..6482533a
--- /dev/null
+++ b/packages/plugin-onedrive-restful/build.gradle.kts
@@ -0,0 +1,55 @@
+plugins {
+ `android-base-lib`
+}
+
+android {
+ namespace = "com.openmobilehub.android.storage.plugin.onedrive.restful"
+
+ defaultConfig {
+ buildConfigField(
+ type = "String",
+ name = "MSGRAPH_API_URL",
+ value = getRequiredValueFromEnvOrProperties("msGraphApiUrl"),
+ )
+ }
+
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+}
+
+val useLocalProjects = project.rootProject.extra["useLocalProjects"] as Boolean
+
+dependencies {
+ if (useLocalProjects) {
+ api(project(":packages:core"))
+ implementation(project(":packages:core-restful-common"))
+ } else {
+ api("com.openmobilehub.android.storage:core:2.1.0-alpha")
+ implementation("com.openmobilehub.android.storage:core-restful-common:2.1.0-alpha")
+ }
+
+ // Omh Auth
+ api(Libs.omhGoogleNonGmsAuthLibrary)
+
+ // slf4j
+ implementation(Libs.slf4jApi)
+
+ // Retrofit setup
+ implementation(Libs.retrofit)
+ implementation(Libs.retrofitJacksonConverter)
+ implementation(Libs.okHttp)
+ implementation(Libs.okHttpLoggingInterceptor)
+ implementation(Libs.jacksonKotlin)
+
+ implementation(Libs.coroutinesCore)
+ implementation(Libs.coroutinesAndroid)
+
+ // Test dependencies
+ testImplementation(kotlin("test"))
+ testImplementation(Libs.junit)
+ testImplementation(Libs.mockk)
+ testImplementation(Libs.coroutineTesting)
+ testImplementation(Libs.json)
+ testImplementation(Libs.slf4jAndroid)
+}
\ No newline at end of file
diff --git a/packages/plugin-onedrive-restful/consumer-rules.pro b/packages/plugin-onedrive-restful/consumer-rules.pro
new file mode 100644
index 00000000..6984b6ce
--- /dev/null
+++ b/packages/plugin-onedrive-restful/consumer-rules.pro
@@ -0,0 +1,100 @@
+### Jackson rules
+-keepattributes *Annotation*,EnclosingMethod,Signature
+-keepnames class com.fasterxml.jackson.** { *; }
+-dontwarn com.fasterxml.jackson.databind.**
+-keep class org.codehaus.** { *; }
+-keepclassmembers public final enum org.codehaus.jackson.annotate.JsonAutoDetect$Visibility {
+ public static final org.codehaus.jackson.annotate.JsonAutoDetect$Visibility *; }
+-keep public class your.class.** {
+ public void set*(***);
+ public *** get*();
+}
+
+### Retrofit 2
+# Platform calls Class.forName on types which do not exist on Android to determine platform.
+-dontnote retrofit2.Platform
+# Platform used when running on RoboVM on iOS. Will not be used at runtime.
+-dontnote retrofit2.Platform$IOS$MainThreadExecutor
+# Platform used when running on Java 8 VMs. Will not be used at runtime.
+-dontwarn retrofit2.Platform$Java8
+# Retain generic type information for use by reflection by converters and adapters.
+-keepattributes Signature
+# Retain declared checked exceptions for use by a Proxy instance.
+-keepattributes Exceptions
+
+-dontwarn retrofit2.adapter.rxjava.CompletableHelper$** # https://github.com/square/retrofit/issues/2034
+#To use Single instead of Observable in Retrofit interface
+-keepnames class rx.Single
+#Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and
+# EnclosingMethod is required to use InnerClasses.
+-keepattributes Signature, InnerClasses, EnclosingMethod
+# Retain service method parameters when optimizing.
+-keepclassmembers,allowshrinking,allowobfuscation interface * {
+ @retrofit2.http.* ;
+}
+# Retrofit does reflection on method and parameter annotations.
+-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
+# Ignore annotation used for build tooling.
+-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
+# Ignore JSR 305 annotations for embedding nullability information.
+-dontwarn javax.annotation.**
+# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath.
+-dontwarn kotlin.Unit
+# Top-level functions that can only be used by Kotlin.
+-dontwarn retrofit2.KotlinExtensions
+-dontwarn retrofit2.KotlinExtensions$*
+# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
+# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
+-if interface * { @retrofit2.http.* ; }
+-keep,allowobfuscation interface <1>
+
+### OkHttp3
+-dontwarn okhttp3.**
+-dontwarn okio.**
+-dontwarn javax.annotation.**
+# A resource is loaded with a relative path so the package of this class must be preserved.
+-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
+
+### Kotlin Coroutine
+# https://github.com/Kotlin/kotlinx.coroutines/blob/master/README.md
+# ServiceLoader support
+-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
+-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
+-keepnames class kotlinx.coroutines.android.AndroidExceptionPreHandler {}
+-keepnames class kotlinx.coroutines.android.AndroidDispatcherFactory {}
+# Most of volatile fields are updated with AFU and should not be mangled
+-keepclassmembernames class kotlinx.** {
+ volatile ;
+}
+# Same story for the standard library's SafeContinuation that also uses AtomicReferenceFieldUpdater
+-keepclassmembernames class kotlin.coroutines.SafeContinuation {
+ volatile ;
+}
+# https://github.com/Kotlin/kotlinx.atomicfu/issues/57
+-dontwarn kotlinx.atomicfu.**
+
+-dontwarn kotlinx.coroutines.flow.**
+
+### Kotlin
+#https://stackoverflow.com/questions/33547643/how-to-use-kotlin-with-proguard
+#https://medium.com/@AthorNZ/kotlin-metadata-jackson-and-proguard-f64f51e5ed32
+-keepclassmembers class **$WhenMappings {
+ ;
+}
+-keep class kotlin.Metadata { *; }
+-keepclassmembers class kotlin.Metadata {
+ public ;
+}
+
+# Jackson
+# Source: https://github.com/FasterXML/jackson-docs/wiki/JacksonOnAndroid
+-keep class com.fasterxml.jackson.databind.ObjectMapper {
+ public ;
+ protected ;
+}
+-keep class com.fasterxml.jackson.databind.ObjectWriter {
+ public ** writeValueAsString(**);
+}
+-keepnames class com.fasterxml.jackson.** { *; }
+-dontwarn com.fasterxml.jackson.databind.**
+-keep @com.fasterxml.jackson.annotation.JsonIgnoreProperties class * { *; }
diff --git a/packages/plugin-onedrive-restful/docs/README.md b/packages/plugin-onedrive-restful/docs/README.md
new file mode 100644
index 00000000..d4897cfe
--- /dev/null
+++ b/packages/plugin-onedrive-restful/docs/README.md
@@ -0,0 +1,7 @@
+---
+title: OneDrive
+layout: default
+nav_order: 6
+---
+
+{% include_relative _README_ORIGINAL.md %}
diff --git a/packages/plugin-onedrive-restful/gradle.properties b/packages/plugin-onedrive-restful/gradle.properties
new file mode 100644
index 00000000..caf31c7a
--- /dev/null
+++ b/packages/plugin-onedrive-restful/gradle.properties
@@ -0,0 +1,4 @@
+artifactId=plugin-onedrive-restful
+version=2.1.0-alpha
+description=Microsoft OneDrive implementation of the OMH Storage API
+msGraphApiUrl="https://graph.microsoft.com/"
\ No newline at end of file
diff --git a/packages/plugin-onedrive-restful/src/main/AndroidManifest.xml b/packages/plugin-onedrive-restful/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..568741e5
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/OneDriveRestfulOmhStorageClientFactory.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/OneDriveRestfulOmhStorageClientFactory.kt
new file mode 100644
index 00000000..addaef2f
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/OneDriveRestfulOmhStorageClientFactory.kt
@@ -0,0 +1,18 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful
+
+import com.openmobilehub.android.auth.core.OmhAuthClient
+import com.openmobilehub.android.storage.core.OmhStorageClient
+import com.openmobilehub.android.storage.core.OmhStorageFactory
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.repository.OneDriveRestfulFileRepository
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.retrofit.OneDriveRetrofitImpl
+
+class OneDriveRestfulOmhStorageClientFactory : OmhStorageFactory {
+ override fun getStorageClient(authClient: OmhAuthClient): OmhStorageClient {
+ val retrofit = OneDriveRetrofitImpl(authClient)
+
+ return OneDriveRestfulOmhStorageClientImpl(
+ authClient,
+ OneDriveRestfulFileRepository(retrofit.apiService, retrofit.httpClient)
+ )
+ }
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/OneDriveRestfulOmhStorageClientImpl.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/OneDriveRestfulOmhStorageClientImpl.kt
new file mode 100644
index 00000000..5c267749
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/OneDriveRestfulOmhStorageClientImpl.kt
@@ -0,0 +1,149 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful
+
+import com.openmobilehub.android.auth.core.OmhAuthClient
+import com.openmobilehub.android.storage.core.OmhStorageClient
+import com.openmobilehub.android.storage.core.model.OmhCreatePermission
+import com.openmobilehub.android.storage.core.model.OmhFileVersion
+import com.openmobilehub.android.storage.core.model.OmhPermission
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.core.model.OmhStorageEntity
+import com.openmobilehub.android.storage.core.model.OmhStorageMetadata
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.OneDriveApiService
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.repository.OneDriveRestfulFileRepository
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.mapper.toOmhStorageEntity
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.mapper.toOmhStorageMetadata
+import java.io.ByteArrayOutputStream
+import java.io.File
+
+@Suppress("TooManyFunctions")
+internal class OneDriveRestfulOmhStorageClientImpl(
+ authClient: OmhAuthClient,
+ private val repository: OneDriveRestfulFileRepository
+) : OmhStorageClient(authClient) {
+
+ override val rootFolder: String = OneDriveApiService.ROOT
+
+ override suspend fun listFiles(parentId: String): List {
+ return repository.getFilesList(parentId)
+ }
+
+ override suspend fun search(query: String): List {
+ return repository.search(query)
+ }
+
+ override suspend fun createFileWithMimeType(
+ name: String,
+ mimeType: String,
+ parentId: String
+ ): OmhStorageEntity? {
+ throw UnsupportedOperationException(
+ "OneDrive does not support creating files with mime type. Use createFileWithExtension instead."
+ )
+ }
+
+ override suspend fun createFileWithExtension(
+ name: String,
+ extension: String,
+ parentId: String
+ ): OmhStorageEntity? {
+ val filename = "$name.$extension"
+ return repository.createFile(filename, parentId)
+ }
+
+ override suspend fun createFolder(name: String, parentId: String): OmhStorageEntity? {
+ return repository.createFolder(name, parentId)
+ }
+
+ override suspend fun deleteFile(id: String) {
+ repository.deleteFile(id)
+ }
+
+ override suspend fun permanentlyDeleteFile(id: String) {
+ throw UnsupportedOperationException()
+ }
+
+ override suspend fun uploadFile(localFileToUpload: File, parentId: String?): OmhStorageEntity? {
+ return repository.uploadFile(localFileToUpload, parentId)
+ }
+
+ override suspend fun downloadFile(fileId: String): ByteArrayOutputStream {
+ return repository.downloadFile(fileId)
+ }
+
+ override suspend fun exportFile(fileId: String, exportedMimeType: String): ByteArrayOutputStream {
+ throw UnsupportedOperationException("Exporting files is not supported in OneDrive.")
+ }
+
+ override suspend fun updateFile(localFileToUpload: File, fileId: String): OmhStorageEntity? {
+ return repository.updateFile(localFileToUpload, fileId)
+ }
+
+ override suspend fun getFileVersions(fileId: String): List {
+ return repository.getItemVersions(fileId)
+ }
+
+ override suspend fun downloadFileVersion(
+ fileId: String,
+ versionId: String
+ ): ByteArrayOutputStream {
+ return repository.downloadVersion(fileId, versionId)
+ }
+
+ override suspend fun getFileMetadata(fileId: String): OmhStorageMetadata? {
+ return repository.getNodeMetaDataById(fileId)?.toOmhStorageMetadata()
+ }
+
+ override suspend fun getFilePermissions(fileId: String): List {
+ return repository.getNodePermission(fileId)
+ }
+
+ override suspend fun deletePermission(fileId: String, permissionId: String) {
+ repository.deleteNodePermission(fileId, permissionId)
+ }
+
+ override suspend fun updatePermission(
+ fileId: String,
+ permissionId: String,
+ role: OmhPermissionRole
+ ): OmhPermission? {
+ return repository.updateNodePermission(
+ id = fileId,
+ permissionId = permissionId,
+ role = role
+ )
+ }
+
+ override suspend fun createPermission(
+ fileId: String,
+ permission: OmhCreatePermission,
+ sendNotificationEmail: Boolean,
+ emailMessage: String?
+ ): OmhPermission? {
+ return repository.createNodePermission(
+ id = fileId,
+ permission = permission,
+ sendNotificationEmail = sendNotificationEmail,
+ emailMessage = emailMessage
+ )
+ }
+
+ override suspend fun getWebUrl(fileId: String): String? {
+ return repository.getNodeMetaDataById(fileId)?.webUrl
+ }
+
+ override suspend fun resolvePath(path: String): OmhStorageEntity? {
+ return repository.getNodeMetaData(path)?.toOmhStorageEntity()
+ }
+
+ override suspend fun getStorageUsage(): Long {
+ return repository.getDriveInfo().quota.used
+ }
+
+ override suspend fun getStorageQuota(): Long {
+ return repository.getDriveInfo().quota.total
+ }
+
+ override fun getProviderSdk(): Any {
+ throw UnsupportedOperationException("Not implemented for Onedrive Restful client")
+ }
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/OneDriveApiService.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/OneDriveApiService.kt
new file mode 100644
index 00000000..a83c80e0
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/OneDriveApiService.kt
@@ -0,0 +1,166 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data
+
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body.CreateFolderRequestBody
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body.CreatePermissionRequestBody
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body.UpdatePermissionRequestBody
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.Drive
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.DriveItem
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.FileVersionsListResponse
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.ListFolderResponse
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.PermissionResponse
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.PermissionsListResponse
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.UploadSessionResponse
+import okhttp3.RequestBody
+import okhttp3.ResponseBody
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+@Suppress("TooManyFunctions")
+interface OneDriveApiService {
+ companion object {
+ const val DRIVE = "drive"
+ const val ROOT = "root"
+ const val APPLICATION_OCTET_STREAM = "application/octet-stream"
+
+ private const val CONTENT_TYPE = "Content-Type"
+
+ private const val V1 = "v1.0"
+
+ private const val ME = "me"
+ private const val ITEMS = "items"
+ private const val FOLDER_ID = "folderId"
+ private const val ITEM_ID = "itemId"
+ private const val CHILDREN = "children"
+ private const val CONTENT = "content"
+ private const val QUERY_SKIPTOKEN = "\$skiptoken"
+ private const val PATH = "path"
+ private const val CREATE_UPLOAD_SESSION = "createUploadSession"
+ private const val Q = "q"
+
+ // Permissions
+ private const val PERMISSIONS = "permissions"
+ private const val INVITE = "invite"
+ private const val PERMISSION_ID = "permissionId"
+ private const val VERSIONS = "versions"
+ private const val VERSION_ID = "versionId"
+ }
+
+ @GET("$V1/$ME/$DRIVE")
+ suspend fun getDrive(): Response
+
+ // validate PATH required.
+ @GET("$V1/$DRIVE/$ROOT:{$PATH}")
+ suspend fun getItemByPath(
+ @Path(value = PATH, encoded = true) path: String,
+ ): Response
+
+ @GET("$V1/$DRIVE/$ITEMS/{$ITEM_ID}")
+ suspend fun getItemById(
+ @Path(ITEM_ID, encoded = true) id: String,
+ ): Response
+
+ @GET("$V1/$DRIVE/$ROOT/$CHILDREN")
+ suspend fun getRootFileList(
+ @Query(QUERY_SKIPTOKEN) skipToken: String? = null,
+ ): Response
+
+ @GET("$V1/$DRIVE/$ITEMS/{$FOLDER_ID}/$CHILDREN")
+ suspend fun getFolderFileList(
+ @Path(FOLDER_ID) folderId: String,
+ @Query(QUERY_SKIPTOKEN) skipToken: String?,
+ ): Response
+
+ @POST("$V1/$DRIVE/$ROOT/$CHILDREN")
+ suspend fun createFolderFromRoot(
+ @Body createFolderRequest: CreateFolderRequestBody,
+ ): Response
+
+ @POST("$V1/$DRIVE/$ITEMS/{$FOLDER_ID}/$CHILDREN")
+ suspend fun createFolder(
+ @Path(FOLDER_ID) folderId: String,
+ @Body createFolderRequest: CreateFolderRequestBody,
+ ): Response
+
+ @DELETE("$V1/$DRIVE/$ITEMS/{$ITEM_ID}")
+ suspend fun deleteFile(
+ @Path(ITEM_ID) itemId: String,
+ ): Response
+
+ @PUT("$V1/$DRIVE/$ROOT:{$PATH}:/$CONTENT")
+ suspend fun uploadFile(
+ @Path(value = PATH, encoded = true) path: String,
+ @Body filePart: RequestBody,
+ ): Response
+
+ // Update file content by item id (small upload)
+ @PUT("$V1/$DRIVE/$ITEMS/{$ITEM_ID}/$CONTENT")
+ suspend fun updateFileContent(
+ @Path(ITEM_ID) itemId: String,
+ @Body filePart: RequestBody,
+ ): Response
+
+ @POST("$V1/$DRIVE/$ROOT:{$PATH}:/$CREATE_UPLOAD_SESSION")
+ suspend fun createUploadSession(
+ @Path(value = PATH, encoded = true) path: String,
+ @Header(CONTENT_TYPE) contentType: String? = "application/octet-stream",
+ ): Response
+
+ // Create upload session for existing item (update large file)
+ @POST("$V1/$DRIVE/$ITEMS/{$ITEM_ID}/$CREATE_UPLOAD_SESSION")
+ suspend fun createUploadSessionForItem(
+ @Path(ITEM_ID) itemId: String,
+ @Header(CONTENT_TYPE) contentType: String? = "application/octet-stream",
+ ): Response
+
+ // Search within root
+ @GET("$V1/$DRIVE/$ROOT/search(q='{$Q}')")
+ suspend fun searchInRoot(
+ @Path(Q, encoded = true) query: String,
+ @Query(QUERY_SKIPTOKEN) skipToken: String? = null,
+ ): Response
+
+ // Versions
+ @GET("$V1/$DRIVE/$ITEMS/{$ITEM_ID}/$VERSIONS")
+ suspend fun getItemVersions(
+ @Path(ITEM_ID) itemId: String,
+ ): Response
+
+ @GET("$V1/$DRIVE/$ITEMS/{$ITEM_ID}/$VERSIONS/{$VERSION_ID}/$CONTENT")
+ suspend fun downloadVersionContent(
+ @Path(ITEM_ID) itemId: String,
+ @Path(VERSION_ID) versionId: String,
+ ): Response
+
+ // Permissions
+ @GET("$V1/$DRIVE/$ITEMS/{$ITEM_ID}/$PERMISSIONS")
+ suspend fun getItemPermissions(
+ @Path(ITEM_ID) itemId: String,
+ ): Response
+
+ @POST("$V1/$DRIVE/$ITEMS/{$ITEM_ID}/$INVITE")
+ suspend fun createPermission(
+ @Path(ITEM_ID) itemId: String,
+ @Body body: CreatePermissionRequestBody,
+ ): Response
+
+ @PATCH("$V1/$DRIVE/$ITEMS/{$ITEM_ID}/$PERMISSIONS/{$PERMISSION_ID}")
+ suspend fun updatePermission(
+ @Path(ITEM_ID) itemId: String,
+ @Path(PERMISSION_ID) permissionId: String,
+ @Body body: UpdatePermissionRequestBody,
+ ): Response
+
+ @DELETE("$V1/$DRIVE/$ITEMS/{$ITEM_ID}/$PERMISSIONS/{$PERMISSION_ID}")
+ suspend fun deletePermission(
+ @Path(ITEM_ID) itemId: String,
+ @Path(PERMISSION_ID) permissionId: String,
+ ): Response
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/repository/OneDriveRestfulFileRepository.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/repository/OneDriveRestfulFileRepository.kt
new file mode 100644
index 00000000..8f7fb356
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/repository/OneDriveRestfulFileRepository.kt
@@ -0,0 +1,448 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.repository
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.openmobilehub.android.storage.core.model.OmhCreatePermission
+import com.openmobilehub.android.storage.core.model.OmhFileVersion
+import com.openmobilehub.android.storage.core.model.OmhIdentity
+import com.openmobilehub.android.storage.core.model.OmhPermission
+import com.openmobilehub.android.storage.core.model.OmhPermissionRecipient
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.core.model.OmhStorageEntity
+import com.openmobilehub.android.storage.core.model.OmhStorageException
+import com.openmobilehub.android.storage.core.restful.common.utils.isNotSuccessful
+import com.openmobilehub.android.storage.core.restful.common.utils.toApiException
+import com.openmobilehub.android.storage.core.restful.common.utils.toByteArrayOutputStream
+import com.openmobilehub.android.storage.core.utils.fromRFC3339StringToDate
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.OneDriveApiService
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.OneDriveApiService.Companion.APPLICATION_OCTET_STREAM
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.OneDriveApiService.Companion.DRIVE
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.OneDriveApiService.Companion.ROOT
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body.CreateFolderRequestBody
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.mapper.toOmhPermissions
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.mapper.toOmhStorageEntity
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.mapper.toOneDriveInviteBody
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.mapper.toOneDriveUpdateBody
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.Drive
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.DriveItem
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.ListFolderResponse
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileNotFoundException
+import java.net.URLEncoder
+import java.util.WeakHashMap
+import kotlin.math.min
+
+@Suppress("TooManyFunctions")
+internal class OneDriveRestfulFileRepository(
+ private val apiService: OneDriveApiService,
+ private val httpClient: OkHttpClient,
+ smallFileLimitInMB: Int = 4
+) {
+
+ private val objectMapper = ObjectMapper()
+
+ private val downloadFileUrls: MutableMap = WeakHashMap()
+
+ private val smallFileLimit = smallFileLimitInMB * ONE_MEGABYTE
+
+ companion object {
+ private const val ONE_MEGABYTE = 1024 * 1024
+ private const val CONTENT_LENGTH = "Content-Length"
+ private const val CONTENT_RANGE = "Content-Range"
+ private val APPLICATION_OCTET_STREAM_MEDIA_TYPE =
+ APPLICATION_OCTET_STREAM.toMediaType()
+ private val EMPTY_BYTE_ARRAY = "".toByteArray()
+ }
+
+ suspend fun getFilesList(parentId: String): List {
+ val result = mutableListOf()
+
+ // Helper to fetch a page depending on root vs folder id
+ suspend fun fetchPage(skipToken: String?): retrofit2.Response =
+ if (parentId == OneDriveApiService.ROOT) {
+ apiService.getRootFileList(skipToken)
+ } else {
+ apiService.getFolderFileList(folderId = parentId, skipToken = skipToken)
+ }
+
+ var prevSkip: String? = null
+ var response = fetchPage(skipToken = null)
+ if (response.isNotSuccessful || response.body() == null) {
+ throw response.toApiException()
+ }
+ response.body()!!.entries.let { entries ->
+ result += entries.map { it.toOmhStorageEntity() }
+ }
+ var skipToken = response.body()!!.getSkipToken()
+
+ while (!skipToken.isNullOrBlank() && skipToken != prevSkip) {
+ prevSkip = skipToken
+ response = fetchPage(skipToken)
+ if (response.isNotSuccessful || response.body() == null) {
+ throw response.toApiException()
+ }
+ val body = response.body()!!
+ if (body.entries.isNotEmpty()) {
+ result += body.entries.map { it.toOmhStorageEntity() }
+ }
+ skipToken = body.getSkipToken()
+ }
+
+ return result
+ }
+
+ suspend fun search(query: String): List {
+ val result = mutableListOf()
+ var prevSkip: String? = null
+ var response = apiService.searchInRoot(query, null)
+ if (response.isNotSuccessful || response.body() == null) {
+ throw response.toApiException()
+ }
+ response.body()!!.entries.let { entries ->
+ result += entries.map { it.toOmhStorageEntity() }
+ }
+ var skipToken = response.body()!!.getSkipToken()
+ while (!skipToken.isNullOrBlank() && skipToken != prevSkip) {
+ prevSkip = skipToken
+ response = apiService.searchInRoot(query, skipToken)
+ if (response.isNotSuccessful || response.body() == null) {
+ throw response.toApiException()
+ }
+ val body = response.body()!!
+ if (body.entries.isNotEmpty()) {
+ result += body.entries.map { it.toOmhStorageEntity() }
+ }
+ skipToken = body.getSkipToken()
+ }
+ return result
+ }
+
+ suspend fun createFile(
+ name: String,
+ parentId: String?,
+ ): OmhStorageEntity.OmhFile? {
+ return uploadSmallFile(name, EMPTY_BYTE_ARRAY.toRequestBody(null), parentId)
+ }
+
+ suspend fun createFolder(name: String, parentId: String): OmhStorageEntity.OmhFolder {
+ val createFolderRequestBody = CreateFolderRequestBody(name = name)
+ return (
+ if (parentId == OneDriveApiService.ROOT) {
+ apiService.createFolderFromRoot(createFolderRequestBody)
+ } else {
+ apiService.createFolder(parentId, createFolderRequestBody)
+ }
+ ).body()?.toOmhStorageEntity() as OmhStorageEntity.OmhFolder
+ }
+
+ suspend fun deleteFile(fileId: String): Boolean {
+ val response = apiService.deleteFile(fileId)
+ if (response.isNotSuccessful) {
+ throw response.toApiException()
+ }
+ return true
+ }
+
+ suspend fun uploadFile(
+ localFileToUpload: File,
+ parentId: String?,
+ ): OmhStorageEntity.OmhFile? {
+ return if (localFileToUpload.length() < smallFileLimit) {
+ uploadSmallFile(
+ localFileToUpload.name,
+ localFileToUpload.asRequestBody(contentType = APPLICATION_OCTET_STREAM_MEDIA_TYPE),
+ parentId,
+ )
+ } else {
+ uploadBigFile(localFileToUpload, parentId)
+ }
+ }
+
+ suspend fun updateFile(
+ localFileToUpload: File,
+ itemId: String,
+ ): OmhStorageEntity.OmhFile? {
+ return if (localFileToUpload.length() < smallFileLimit) {
+ val response = apiService.updateFileContent(
+ itemId,
+ localFileToUpload.asRequestBody(APPLICATION_OCTET_STREAM_MEDIA_TYPE)
+ )
+ if (response.isNotSuccessful) throw response.toApiException()
+ response.body()?.toOmhStorageEntity() as OmhStorageEntity.OmhFile
+ } else {
+ updateBigFile(localFileToUpload, itemId)
+ }
+ }
+
+ suspend fun downloadFile(
+ fileId: String,
+ ): ByteArrayOutputStream {
+ val fileDownloadUrl: String =
+ if (downloadFileUrls.containsKey(fileId)) {
+ downloadFileUrls[fileId]!!
+ } else {
+ val node = getNodeMetaDataById(fileId)
+ node?.run {
+ val url = this.fileDownloadUrl
+ downloadFileUrls[fileId] = url!!
+ url
+ } ?: throw OmhStorageException.DownloadException(
+ cause = FileNotFoundException("File with id $fileId not found")
+ )
+ }
+ val response =
+ httpClient.newCall(
+ Request.Builder()
+ .get()
+ .url(fileDownloadUrl)
+ .build(),
+ ).execute()
+ return response.body?.toByteArrayOutputStream()!!
+ }
+
+ suspend fun getNodeMetaDataById(id: String): DriveItem? {
+ return apiService.getItemById(URLEncoder.encode(id, Charsets.UTF_8.name())).body()
+ }
+
+ suspend fun getNodeMetaData(path: String): DriveItem? {
+ return apiService.getItemByPath(URLEncoder.encode(path, Charsets.UTF_8.name())).body()
+ }
+
+ // Current authenticated user's Onedrive instance should never be null.
+ suspend fun getDriveInfo(): Drive = apiService.getDrive().body()!!
+
+ private suspend fun uploadSmallFile(
+ filename: String,
+ requestBody: RequestBody,
+ parentId: String?,
+ ): OmhStorageEntity.OmhFile? {
+ val path = "${getParentPath(parentId)}/$filename"
+ return apiService.uploadFile(
+ path,
+ requestBody,
+ ).body()?.toOmhStorageEntity() as OmhStorageEntity.OmhFile
+ }
+
+ private suspend fun uploadBigFile(
+ localFileToUpload: File,
+ parentId: String?,
+ ): OmhStorageEntity.OmhFile? {
+ val path = "${getParentPath(parentId)}/${localFileToUpload.name}"
+ val session =
+ requireNotNull(
+ apiService.createUploadSession(path),
+ )
+ val uploadUrl = requireNotNull(session.body()?.uploadUrl)
+
+ val chunkSize = smallFileLimit
+ var bytesRead = 0
+ var offset = 0
+ val bytes = ByteArray(chunkSize)
+ val filebin = localFileToUpload.inputStream()
+ lateinit var response: Response
+
+ while (withContext(Dispatchers.IO) {
+ filebin.read(bytes)
+ }.also { bytesRead = it } != -1
+ ) {
+ val end = min((offset + bytesRead).toLong(), localFileToUpload.length())
+ response =
+ httpClient.newCall(
+ Request.Builder()
+ .url(uploadUrl)
+ .header(CONTENT_LENGTH, bytesRead.toString())
+ .header(CONTENT_RANGE, "bytes $offset-${end - 1}/${localFileToUpload.length()}")
+ .put(bytes.toRequestBody(APPLICATION_OCTET_STREAM_MEDIA_TYPE, 0, bytesRead))
+ .build(),
+ ).execute()
+ offset += bytesRead
+ bytes.fill(0)
+ }
+
+ // When all parts are uploaded, response is expected to be non-empty JSON string
+ return response.body?.run {
+ val finishUploadResult = objectMapper.readValue(response.body!!.string(), DriveItem::class.java)
+ finishUploadResult.toOmhStorageEntity() as OmhStorageEntity.OmhFile
+ }
+ }
+
+ private suspend fun updateBigFile(
+ localFileToUpload: File,
+ itemId: String,
+ ): OmhStorageEntity.OmhFile? {
+ val session = requireNotNull(apiService.createUploadSessionForItem(itemId))
+ val uploadUrl = requireNotNull(session.body()?.uploadUrl)
+
+ val chunkSize = smallFileLimit
+ var bytesRead = 0
+ var offset = 0
+ val bytes = ByteArray(chunkSize)
+ val filebin = localFileToUpload.inputStream()
+ lateinit var response: Response
+
+ while (withContext(Dispatchers.IO) { filebin.read(bytes) }.also { bytesRead = it } != -1) {
+ val end = min((offset + bytesRead).toLong(), localFileToUpload.length())
+ response = httpClient.newCall(
+ Request.Builder()
+ .url(uploadUrl)
+ .header(CONTENT_LENGTH, bytesRead.toString())
+ .header(CONTENT_RANGE, "bytes $offset-${end - 1}/${localFileToUpload.length()}")
+ .put(bytes.toRequestBody(APPLICATION_OCTET_STREAM_MEDIA_TYPE, 0, bytesRead))
+ .build()
+ ).execute()
+ offset += bytesRead
+ bytes.fill(0)
+ }
+
+ return response.body?.run {
+ val finishUploadResult = objectMapper.readValue(response.body!!.string(), DriveItem::class.java)
+ finishUploadResult.toOmhStorageEntity() as OmhStorageEntity.OmhFile
+ }
+ }
+
+ private suspend fun getParentPath(parentId: String?): String {
+ val parentFolder =
+ if (!parentId.isNullOrEmpty()) {
+ getNodeMetaDataById(id = parentId)
+ } else {
+ null
+ }
+ return if (parentId == ROOT) {
+ ""
+ } else {
+ parentFolder?.path?.substringAfter("/$DRIVE/$ROOT:") ?: ""
+ }
+ }
+
+ // Versions
+
+ suspend fun getItemVersions(fileId: String): List {
+ val response = apiService.getItemVersions(fileId)
+ if (response.isNotSuccessful) throw response.toApiException()
+ val body = response.body() ?: return emptyList()
+ return body.versions.mapNotNull { v ->
+ val id = v.id ?: return@mapNotNull null
+ val modified = v.lastModifiedDateTime?.fromRFC3339StringToDate() ?: return@mapNotNull null
+ OmhFileVersion(fileId = fileId, versionId = id, lastModified = modified)
+ }
+ }
+
+ suspend fun downloadVersion(fileId: String, versionId: String): ByteArrayOutputStream {
+ val response = apiService.downloadVersionContent(fileId, versionId)
+ if (response.isNotSuccessful || response.body() == null) throw response.toApiException()
+ return response.body()!!.toByteArrayOutputStream()
+ }
+
+ // Permissions
+
+ suspend fun getNodePermission(id: String): List {
+ val response = apiService.getItemPermissions(id)
+ return if (response.isNotSuccessful) {
+ throw response.toApiException()
+ } else {
+ response.body()?.permissions.toOmhPermissions()
+ }
+ }
+
+ suspend fun createNodePermission(
+ id: String,
+ permission: OmhCreatePermission,
+ sendNotificationEmail: Boolean = true,
+ emailMessage: String? = null,
+ ): OmhPermission {
+ val body = permission.toOneDriveInviteBody(sendNotificationEmail, emailMessage)
+ val response = apiService.createPermission(id, body)
+ if (response.isNotSuccessful) {
+ throw response.toApiException()
+ }
+ // Prefer created permissions from response
+ val created = response.body()?.permissions.toOmhPermissions()
+ if (created.isNotEmpty()) return created.first()
+ // Fallback: re-fetch and locate
+ return findCreatedPermissionOrThrow(id, permission)
+ }
+
+ suspend fun updateNodePermission(
+ id: String,
+ permissionId: String,
+ role: OmhPermissionRole
+ ): OmhPermission {
+ val response = apiService.updatePermission(id, permissionId, role.toOneDriveUpdateBody())
+ if (response.isNotSuccessful) {
+ throw response.toApiException()
+ }
+ val mapped = response.body()?.let { listOf(it) }?.toOmhPermissions()?.firstOrNull()
+ return mapped ?: findUpdatedPermissionOrThrow(id, permissionId)
+ }
+
+ suspend fun deleteNodePermission(
+ id: String,
+ permissionId: String,
+ ): Boolean {
+ val response = apiService.deletePermission(id, permissionId)
+ if (response.isNotSuccessful) {
+ throw response.toApiException()
+ }
+ return true
+ }
+
+ // Helpers to locate created/updated permissions, similar to Dropbox implementation
+ private suspend fun findCreatedPermissionOrThrow(
+ id: String,
+ permission: OmhCreatePermission,
+ ): OmhPermission {
+ val permissions = getNodePermission(id)
+ val match = permissions.firstOrNull { perm ->
+ when (perm) {
+ is OmhPermission.IdentityPermission -> {
+ when (permission) {
+ is OmhCreatePermission.CreateIdentityPermission -> {
+ val recipient = permission.recipient
+ when (recipient) {
+ is OmhPermissionRecipient.User ->
+ (perm.identity as? OmhIdentity.User)
+ ?.emailAddress?.equals(recipient.emailAddress, ignoreCase = true) == true
+ is OmhPermissionRecipient.Group ->
+ (perm.identity as? OmhIdentity.Group)
+ ?.emailAddress?.equals(recipient.emailAddress, ignoreCase = true) == true
+ is OmhPermissionRecipient.WithObjectId -> {
+ when (val ident = perm.identity) {
+ is OmhIdentity.User -> ident.id == recipient.id
+ is OmhIdentity.Group -> ident.id == recipient.id
+ else -> false
+ }
+ }
+ else -> false
+ }
+ }
+ }
+ }
+ }
+ }
+ return match ?: throw OmhStorageException.ApiException(
+ message = "Create succeeded but API failed to return expected permission"
+ )
+ }
+
+ private suspend fun findUpdatedPermissionOrThrow(
+ id: String,
+ permissionId: String,
+ ): OmhPermission {
+ val permissions = getNodePermission(id)
+ val match = permissions.firstOrNull { perm ->
+ (perm as? OmhPermission.IdentityPermission)?.id == permissionId
+ }
+ return match ?: throw OmhStorageException.ApiException(
+ message = "Updated succeeded but API failed to return expected permission"
+ )
+ }
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/retrofit/OneDriveRetrofitImpl.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/retrofit/OneDriveRetrofitImpl.kt
new file mode 100644
index 00000000..1f0ca4e8
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/retrofit/OneDriveRetrofitImpl.kt
@@ -0,0 +1,64 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.retrofit
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import com.openmobilehub.android.auth.core.OmhAuthClient
+import com.openmobilehub.android.storage.core.restful.common.data.repository.StorageAuthenticator
+import com.openmobilehub.android.storage.core.restful.common.utils.accessToken
+import com.openmobilehub.android.storage.plugin.onedrive.restful.BuildConfig
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.OneDriveApiService
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.jackson.JacksonConverterFactory
+
+@Suppress("UnusedPrivateMember")
+internal class OneDriveRetrofitImpl(private val omhAuthClient: OmhAuthClient) {
+
+ val objectMapper: ObjectMapper = ObjectMapper().registerModule(KotlinModule())
+
+ val httpClient: OkHttpClient by lazy {
+ createHttpClient(false)
+ }
+
+ val apiService: OneDriveApiService =
+ Retrofit.Builder()
+ .client(createHttpClient())
+ .addConverterFactory(createConverterFactory())
+ .baseUrl(BuildConfig.MSGRAPH_API_URL)
+ .build().create(OneDriveApiService::class.java)
+
+ private fun createHttpClient(includeAuthToken: Boolean = true): OkHttpClient {
+ val authenticator = StorageAuthenticator(omhAuthClient)
+ val builder =
+ OkHttpClient.Builder()
+ .addInterceptor(
+ HttpLoggingInterceptor().apply {
+ if (BuildConfig.DEBUG) setLevel(HttpLoggingInterceptor.Level.BODY)
+ },
+ )
+ if (includeAuthToken) {
+ builder.addInterceptor { chain ->
+ val request = setupRequestInterceptor(chain)
+ chain.proceed(request)
+ }.authenticator(authenticator)
+ }
+ return builder.build()
+ }
+
+ private fun setupRequestInterceptor(chain: Interceptor.Chain) =
+ chain
+ .request()
+ .newBuilder()
+ .addHeader(
+ StorageAuthenticator.HEADER_AUTHORIZATION_NAME,
+ StorageAuthenticator.BEARER.format(omhAuthClient.accessToken.orEmpty()),
+ )
+ .build()
+
+ private fun createConverterFactory() =
+ JacksonConverterFactory.create(
+ ObjectMapper().registerModule(KotlinModule()),
+ )
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/CreateFolderRequestBody.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/CreateFolderRequestBody.kt
new file mode 100644
index 00000000..c62f398a
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/CreateFolderRequestBody.kt
@@ -0,0 +1,20 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class CreateFolderRequestBody(
+ @JsonProperty("@microsoft.graph.conflictBehavior")
+ val conflictBehaviour: String = "rename",
+ @JsonProperty("name")
+ val name: String,
+ @JsonInclude(JsonInclude.Include.ALWAYS)
+ @JsonProperty("folder")
+ val folder: EmptyObject = EmptyObject(),
+) {
+ class EmptyObject
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/CreatePermissionRequestBody.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/CreatePermissionRequestBody.kt
new file mode 100644
index 00000000..bcfcfd1c
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/CreatePermissionRequestBody.kt
@@ -0,0 +1,29 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class CreatePermissionRequestBody(
+ @JsonProperty("recipients")
+ val recipients: List,
+ @JsonProperty("roles")
+ val roles: List,
+ @JsonProperty("requireSignIn")
+ val requireSignIn: Boolean = true,
+ @JsonProperty("sendInvitation")
+ val sendInvitation: Boolean = true,
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @JsonProperty("message")
+ val message: String? = null,
+) {
+ @Keep
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ data class Recipient(
+ @JsonProperty("email")
+ val email: String,
+ )
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/CreateUploadSessionRequestBody.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/CreateUploadSessionRequestBody.kt
new file mode 100644
index 00000000..05958ac3
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/CreateUploadSessionRequestBody.kt
@@ -0,0 +1,25 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class CreateUploadSessionRequestBody(
+ @JsonProperty("@microsoft.graph.conflictBehavior")
+ val conflictBehaviour: String,
+ @JsonProperty("name")
+ val name: String,
+ @JsonProperty("description")
+ val description: String = "",
+ @JsonProperty("fileSystemInfo")
+ val fileSystemInfo: UploadSessionFileSystemInfo,
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class UploadSessionFileSystemInfo(
+ @JsonProperty("@odata.type")
+ val dataType: String = "microsoft.graph.fileSystemInfo",
+)
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/UpdatePermissionRequestBody.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/UpdatePermissionRequestBody.kt
new file mode 100644
index 00000000..7aa9b102
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/body/UpdatePermissionRequestBody.kt
@@ -0,0 +1,12 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class UpdatePermissionRequestBody(
+ @JsonProperty("roles")
+ val roles: List
+)
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/mapper/Mappers.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/mapper/Mappers.kt
new file mode 100644
index 00000000..d03742cc
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/mapper/Mappers.kt
@@ -0,0 +1,44 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.mapper
+
+import android.webkit.MimeTypeMap
+import com.openmobilehub.android.storage.core.model.OmhStorageEntity
+import com.openmobilehub.android.storage.core.model.OmhStorageMetadata
+import com.openmobilehub.android.storage.core.utils.fromRFC3339StringToDate
+import com.openmobilehub.android.storage.core.utils.getMimeTypeFromUrl
+import com.openmobilehub.android.storage.core.utils.removeSpecialCharacters
+import com.openmobilehub.android.storage.core.utils.removeWhitespaces
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.DriveItem
+
+fun DriveItem.toOmhStorageEntity(): OmhStorageEntity {
+ return if (this.folderInfo == null) {
+ val sanitizedName = name.removeWhitespaces().removeSpecialCharacters()
+ val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromUrl(sanitizedName)
+ val extension = MimeTypeMap.getFileExtensionFromUrl(sanitizedName)?.ifEmpty { null }
+
+ OmhStorageEntity.OmhFile(
+ id = this.id,
+ name = this.name,
+ modifiedTime = this.updatedAt.fromRFC3339StringToDate(),
+ createdTime = this.createdAt.fromRFC3339StringToDate(),
+ parentId = this.parentReference.id,
+ extension = extension,
+ mimeType = this.fileInfo?.mimeType ?: mimeType,
+ size = this.size.toInt()
+ )
+ } else {
+ OmhStorageEntity.OmhFolder(
+ id = this.id,
+ name = this.name,
+ modifiedTime = this.updatedAt.fromRFC3339StringToDate(),
+ createdTime = this.createdAt.fromRFC3339StringToDate(),
+ parentId = this.parentReference.id,
+ )
+ }
+}
+
+fun DriveItem.toOmhStorageMetadata(): OmhStorageMetadata {
+ return OmhStorageMetadata(
+ entity = this.toOmhStorageEntity(),
+ originalMetadata = this
+ )
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/mapper/PermissionMappers.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/mapper/PermissionMappers.kt
new file mode 100644
index 00000000..03a101ef
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/mapper/PermissionMappers.kt
@@ -0,0 +1,130 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.mapper
+
+import com.openmobilehub.android.storage.core.model.OmhCreatePermission
+import com.openmobilehub.android.storage.core.model.OmhIdentity
+import com.openmobilehub.android.storage.core.model.OmhPermission
+import com.openmobilehub.android.storage.core.model.OmhPermissionRecipient
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body.CreatePermissionRequestBody
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body.UpdatePermissionRequestBody
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.GrantedToV2
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.Identity
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.PermissionResponse
+
+// Permissions mappers
+
+private const val ONEDRIVE_ROLE_READ = "read"
+private const val ONEDRIVE_ROLE_WRITE = "write"
+private const val ONEDRIVE_ROLE_OWNER = "owner"
+
+private data class IdentityWrapper(val user: Identity? = null, val group: Identity? = null)
+
+private fun GrantedToV2.extractIdentityWrappers(): List {
+ val list = mutableListOf()
+ // Prioritize user/siteUser
+ val userLike = this.user ?: this.siteUser
+ val groupLike = this.group ?: this.siteGroup
+ if (userLike != null) list += IdentityWrapper(user = userLike)
+ if (groupLike != null) list += IdentityWrapper(group = groupLike)
+ return list
+}
+
+private fun PermissionResponse.collectIdentities(): List {
+ val out = mutableListOf()
+ grantedToV2?.let { out += it.extractIdentityWrappers() }
+ grantedToIdentitiesV2.orEmpty().forEach { g -> out += g.extractIdentityWrappers() }
+ return out
+}
+
+// Produce multiple permissions (one per identity) for a single PermissionResponse
+@Suppress("ReturnCount")
+private fun PermissionResponse.expand(): List {
+ val id = this.id ?: return emptyList()
+ val role = this.roles?.toOmhRole() ?: return emptyList()
+ val isInherited = this.inheritedFrom != null
+ return collectIdentities().mapNotNull { wrap ->
+ val identity: OmhIdentity = when {
+ wrap.user != null -> OmhIdentity.User(
+ id = wrap.user.id,
+ displayName = wrap.user.displayName,
+ emailAddress = wrap.user.email,
+ expirationTime = null,
+ deleted = null,
+ photoLink = null,
+ pendingOwner = null,
+ )
+ wrap.group != null -> OmhIdentity.Group(
+ id = wrap.group.id,
+ displayName = wrap.group.displayName,
+ emailAddress = wrap.group.email,
+ expirationTime = null,
+ deleted = null,
+ )
+ else -> return@mapNotNull null
+ }
+ OmhPermission.IdentityPermission(
+ id = id,
+ role = role,
+ isInherited = isInherited,
+ identity = identity,
+ )
+ }
+}
+
+@Suppress("ReturnCount")
+internal fun PermissionResponse.toOmhPermission(): OmhPermission? = expand().firstOrNull()
+
+internal fun List?.toOmhPermissions(): List =
+ this?.flatMap { it.expand() }.orEmpty()
+
+private fun List.toOmhRole(): OmhPermissionRole? = when {
+ this.any { it.equals(ONEDRIVE_ROLE_OWNER, true) } -> OmhPermissionRole.OWNER
+ this.any { it.equals(ONEDRIVE_ROLE_WRITE, true) } -> OmhPermissionRole.WRITER
+ this.any { it.equals(ONEDRIVE_ROLE_READ, true) } -> OmhPermissionRole.READER
+ else -> null
+}
+
+internal fun OmhPermissionRole.toOneDriveRoleString(): String = when (this) {
+ OmhPermissionRole.OWNER -> ONEDRIVE_ROLE_OWNER
+ OmhPermissionRole.WRITER -> ONEDRIVE_ROLE_WRITE
+ OmhPermissionRole.COMMENTER -> ONEDRIVE_ROLE_READ
+ OmhPermissionRole.READER -> ONEDRIVE_ROLE_READ
+}
+
+internal fun OmhPermissionRole.toOneDriveUpdateBody(): UpdatePermissionRequestBody =
+ UpdatePermissionRequestBody(roles = listOf(this.toOneDriveRoleString()))
+
+internal fun OmhCreatePermission.toOneDriveInviteBody(
+ sendNotificationEmail: Boolean,
+ emailMessage: String?,
+): CreatePermissionRequestBody = when (this) {
+ is OmhCreatePermission.CreateIdentityPermission -> this.recipient.toOneDriveInviteBody(
+ role = this.role.toOneDriveRoleString(),
+ sendNotificationEmail = sendNotificationEmail,
+ emailMessage = emailMessage,
+ )
+}
+
+@Suppress("ThrowingExceptionsWithoutMessageOrCause")
+internal fun OmhPermissionRecipient.toOneDriveInviteBody(
+ role: String,
+ sendNotificationEmail: Boolean,
+ emailMessage: String?,
+): CreatePermissionRequestBody = when (this) {
+ is OmhPermissionRecipient.User -> CreatePermissionRequestBody(
+ recipients = listOf(CreatePermissionRequestBody.Recipient(this.emailAddress)),
+ roles = listOf(role),
+ sendInvitation = sendNotificationEmail,
+ message = emailMessage,
+ )
+ is OmhPermissionRecipient.Group -> CreatePermissionRequestBody(
+ recipients = listOf(CreatePermissionRequestBody.Recipient(this.emailAddress)),
+ roles = listOf(role),
+ sendInvitation = sendNotificationEmail,
+ message = emailMessage,
+ )
+ is OmhPermissionRecipient.Domain -> throw UnsupportedOperationException()
+ is OmhPermissionRecipient.Anyone -> throw UnsupportedOperationException()
+ is OmhPermissionRecipient.WithObjectId -> throw UnsupportedOperationException()
+ is OmhPermissionRecipient.WithAlias -> throw UnsupportedOperationException()
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/ApiError.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/ApiError.kt
new file mode 100644
index 00000000..a4a1432d
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/ApiError.kt
@@ -0,0 +1,34 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ApiError(
+ @JsonProperty("error")
+ val error: ErrorDetails,
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ErrorDetails(
+ @JsonProperty("code")
+ val code: String,
+ @JsonProperty("message")
+ val message: String,
+ @JsonProperty("innerError")
+ val innerError: InnerError,
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class InnerError(
+ @JsonProperty("code")
+ val code: String,
+ @JsonProperty("date")
+ val date: String,
+ @JsonProperty("request-id")
+ val requestId: String,
+)
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/Drive.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/Drive.kt
new file mode 100644
index 00000000..ff0b5715
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/Drive.kt
@@ -0,0 +1,29 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class Drive(
+ @JsonProperty("id")
+ val id: String,
+ @JsonProperty("quota")
+ val quota: QuotaInfo
+) {
+ @Keep
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ data class QuotaInfo(
+ @JsonProperty("total")
+ val total: Long,
+ @JsonProperty("used")
+ val used: Long,
+ @JsonProperty("remaining")
+ val remaining: Long,
+ @JsonProperty("deleted")
+ val deleted: Long,
+ @JsonProperty("state")
+ val state: String
+ )
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/DriveItem.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/DriveItem.kt
new file mode 100644
index 00000000..dea82d89
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/DriveItem.kt
@@ -0,0 +1,61 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+import java.net.URLDecoder
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class DriveItem(
+ @JsonProperty("id")
+ val id: String,
+ @JsonProperty("name")
+ val name: String,
+ @JsonProperty("createdDateTime")
+ val createdAt: String,
+ @JsonProperty("lastModifiedDateTime")
+ val updatedAt: String,
+ @JsonProperty("size")
+ val size: Long,
+ @JsonProperty("file")
+ val fileInfo: FileInfo?,
+ @JsonProperty("folder")
+ val folderInfo: FolderInfo?,
+ @JsonProperty("@microsoft.graph.downloadUrl")
+ val fileDownloadUrl: String?,
+ @JsonProperty("parentReference")
+ val parentReference: ParentReference,
+ @JsonProperty("webUrl")
+ val webUrl: String?
+) {
+ val path: String
+ get() = URLDecoder.decode("${parentReference.path}/$name", Charsets.UTF_8.name())
+
+ @Keep
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ data class FileInfo(
+ @JsonProperty("mimeType")
+ val mimeType: String,
+ )
+
+ @Keep
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ data class FolderInfo(
+ @JsonProperty("count")
+ val childrenCount: Long,
+ )
+
+ @Keep
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ data class ParentReference(
+ @JsonProperty("id")
+ val id: String? = null,
+ @JsonProperty("driveId")
+ val driveId: String,
+ @JsonProperty("name")
+ val name: String? = "",
+ @JsonProperty("path")
+ val path: String? = "",
+ )
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/FileVersionsListResponse.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/FileVersionsListResponse.kt
new file mode 100644
index 00000000..0e0797f9
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/FileVersionsListResponse.kt
@@ -0,0 +1,18 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class FileVersionsListResponse(
+ @JsonProperty("value") val versions: List
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class FileVersionResponse(
+ @JsonProperty("id") val id: String?,
+ @JsonProperty("lastModifiedDateTime") val lastModifiedDateTime: String?
+)
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/ListFolderResponse.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/ListFolderResponse.kt
new file mode 100644
index 00000000..0f854f56
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/ListFolderResponse.kt
@@ -0,0 +1,29 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+import java.net.URLDecoder
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Suppress("ReturnCount")
+data class ListFolderResponse(
+ @JsonProperty("value")
+ val entries: List,
+ @JsonProperty("@odata.nextLink")
+ val nextLink: String?,
+) {
+ fun getSkipToken(): String? {
+ val link = nextLink ?: return null
+ // Match either $skiptoken=... or %24skiptoken=... (case-insensitive). Accept plain skiptoken= as well.
+ val regex = Regex("(?:\\$|%24)?skiptoken=([^&]+)", RegexOption.IGNORE_CASE)
+ val match = regex.find(link) ?: return null
+ val rawToken = match.groupValues.getOrNull(1) ?: return null
+ return try {
+ URLDecoder.decode(rawToken, Charsets.UTF_8.name())
+ } catch (_: Exception) {
+ rawToken
+ }
+ }
+}
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/PermissionResponse.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/PermissionResponse.kt
new file mode 100644
index 00000000..b75512cc
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/PermissionResponse.kt
@@ -0,0 +1,47 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class PermissionResponse(
+ @JsonProperty("id") val id: String?,
+ @JsonProperty("roles") val roles: List?,
+ @JsonProperty("grantedToV2") val grantedToV2: GrantedToV2? = null,
+ @JsonProperty("grantedToIdentitiesV2") val grantedToIdentitiesV2: List? = null,
+ @JsonProperty("inheritedFrom") val inheritedFrom: ItemReference?,
+ @JsonProperty("link") val link: SharingLink?,
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class GrantedToV2(
+ @JsonProperty("user") val user: Identity? = null,
+ @JsonProperty("group") val group: Identity? = null,
+ @JsonProperty("siteUser") val siteUser: Identity? = null,
+ @JsonProperty("siteGroup") val siteGroup: Identity? = null,
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class Identity(
+ @JsonProperty("id") val id: String?,
+ @JsonProperty("displayName") val displayName: String?,
+ @JsonProperty("email") val email: String?,
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ItemReference(
+ @JsonProperty("id") val id: String?,
+)
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class SharingLink(
+ @JsonProperty("scope") val scope: String?,
+ @JsonProperty("type") val type: String?,
+ @JsonProperty("webUrl") val webUrl: String?,
+)
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/PermissionsListResponse.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/PermissionsListResponse.kt
new file mode 100644
index 00000000..80fcf2fb
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/PermissionsListResponse.kt
@@ -0,0 +1,11 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class PermissionsListResponse(
+ @JsonProperty("value") val permissions: List?
+)
diff --git a/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/UploadSessionResponse.kt b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/UploadSessionResponse.kt
new file mode 100644
index 00000000..d84c18d1
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/main/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/source/response/UploadSessionResponse.kt
@@ -0,0 +1,14 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response
+
+import androidx.annotation.Keep
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+
+@Keep
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class UploadSessionResponse(
+ @JsonProperty("uploadUrl")
+ val uploadUrl: String,
+ @JsonProperty("expirationDateTime")
+ val expirationDateTime: String,
+)
diff --git a/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/OneDriveRestfulOmhStorageClientImplTest.kt b/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/OneDriveRestfulOmhStorageClientImplTest.kt
new file mode 100644
index 00000000..da368b5f
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/OneDriveRestfulOmhStorageClientImplTest.kt
@@ -0,0 +1,77 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful
+
+import com.openmobilehub.android.auth.core.OmhAuthClient
+import com.openmobilehub.android.storage.core.model.OmhIdentity
+import com.openmobilehub.android.storage.core.model.OmhPermission
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.repository.OneDriveRestfulFileRepository
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class OneDriveRestfulOmhStorageClientImplTest {
+
+ @MockK(relaxed = true)
+ private lateinit var authClient: OmhAuthClient
+
+ @MockK
+ private lateinit var repository: OneDriveRestfulFileRepository
+
+ private lateinit var client: OneDriveRestfulOmhStorageClientImpl
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ client = OneDriveRestfulOmhStorageClientImpl(authClient, repository)
+ }
+
+ @Test
+ fun `deletePermission delegates to repository`() = runTest {
+ // Arrange
+ val fileId = "ITEM_DEL"
+ val permissionId = "PERM123"
+ coEvery { repository.deleteNodePermission(fileId, permissionId) } returns true
+
+ // Act
+ client.deletePermission(fileId, permissionId)
+
+ // Assert
+ coVerify(exactly = 1) { repository.deleteNodePermission(fileId, permissionId) }
+ }
+
+ @Test
+ fun `updatePermission delegates to repository and returns mapped permission`() = runTest {
+ // Arrange
+ val fileId = "ITEM_UPD"
+ val permissionId = "PERM456"
+ val expected = OmhPermission.IdentityPermission(
+ id = permissionId,
+ role = OmhPermissionRole.WRITER,
+ isInherited = false,
+ identity = OmhIdentity.User(
+ id = "U1",
+ displayName = "Alice",
+ emailAddress = "alice@example.com",
+ expirationTime = null,
+ deleted = false,
+ photoLink = null,
+ pendingOwner = false
+ )
+ )
+ coEvery { repository.updateNodePermission(fileId, permissionId, OmhPermissionRole.WRITER) } returns expected
+
+ // Act
+ val result = client.updatePermission(fileId, permissionId, OmhPermissionRole.WRITER)
+
+ // Assert
+ coVerify(exactly = 1) { repository.updateNodePermission(fileId, permissionId, OmhPermissionRole.WRITER) }
+ assertEquals(expected, result)
+ }
+}
diff --git a/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/repository/OneDriveRestfulFileRepositoryTest.kt b/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/repository/OneDriveRestfulFileRepositoryTest.kt
new file mode 100644
index 00000000..7f421193
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/repository/OneDriveRestfulFileRepositoryTest.kt
@@ -0,0 +1,829 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.repository
+
+import android.webkit.MimeTypeMap
+import com.openmobilehub.android.storage.core.model.OmhCreatePermission
+import com.openmobilehub.android.storage.core.model.OmhIdentity
+import com.openmobilehub.android.storage.core.model.OmhPermission
+import com.openmobilehub.android.storage.core.model.OmhPermissionRecipient
+import com.openmobilehub.android.storage.core.model.OmhPermissionRole
+import com.openmobilehub.android.storage.core.model.OmhStorageEntity
+import com.openmobilehub.android.storage.core.model.OmhStorageException
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.OneDriveApiService
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body.CreatePermissionRequestBody
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.body.UpdatePermissionRequestBody
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.DriveItem
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.GrantedToV2
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.Identity
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.ItemReference
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.PermissionResponse
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.PermissionsListResponse
+import com.openmobilehub.android.storage.plugin.onedrive.restful.testdoubles.TestListFolderResponse
+import com.openmobilehub.android.storage.plugin.onedrive.restful.testdoubles.TestListFolderResponse.paginatedFirstPage
+import com.openmobilehub.android.storage.plugin.onedrive.restful.testdoubles.TestListFolderResponse.paginatedSecondPage
+import com.openmobilehub.android.storage.plugin.onedrive.restful.testdoubles.TestListFolderResponse.rootSinglePage
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import retrofit2.Response
+import java.io.File
+
+@Suppress(
+ "LargeClass",
+ "UnusedPrivateMember",
+ "ForbiddenComment",
+ "MaxLineLength",
+ "MaximumLineLength",
+ "LongMethod"
+)
+@OptIn(ExperimentalCoroutinesApi::class)
+class OneDriveRestfulFileRepositoryTest {
+
+ companion object {
+ private const val TEST_MIME_TYPE = "application/x-test-mimetype"
+ }
+
+ @MockK(relaxed = true)
+ private lateinit var mimeTypeMap: MimeTypeMap
+
+ @MockK
+ private lateinit var apiService: OneDriveApiService
+
+ private lateinit var repository: OneDriveRestfulFileRepository
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ mockkStatic(MimeTypeMap::class)
+ every { MimeTypeMap.getSingleton() } returns mimeTypeMap
+ every { mimeTypeMap.getMimeTypeFromExtension(any()) } returns TEST_MIME_TYPE
+ repository = OneDriveRestfulFileRepository(apiService, OkHttpClient())
+ }
+
+ @Test
+ fun `given root parentId when listFiles single page then returns mapped entities`() = runTest {
+ // Arrange
+ coEvery {
+ apiService.getRootFileList(null)
+ } returns Response.success(rootSinglePage)
+
+ // Act
+ val result = repository.getFilesList(OneDriveApiService.ROOT)
+
+ // Assert
+ assertEquals(2, result.size)
+ assertTrue(result[0] is OmhStorageEntity.OmhFile)
+ assertTrue(result[1] is OmhStorageEntity.OmhFolder)
+ assertEquals("File 1", result[0].name)
+ assertEquals("Folder 1", result[1].name)
+ }
+
+ @Test
+ fun `given folder id when listFiles paginated then aggregates all pages`() = runTest {
+ // Arrange
+ val folderId = "FOLDER123"
+ coEvery {
+ apiService.getFolderFileList(folderId, null)
+ } returns Response.success(paginatedFirstPage)
+ coEvery {
+ apiService.getFolderFileList(folderId, "abc")
+ } returns Response.success(paginatedSecondPage)
+
+ // Act
+ val result = repository.getFilesList(folderId)
+
+ // Assert
+ assertEquals(2, result.size)
+ assertTrue(result[0] is OmhStorageEntity.OmhFile)
+ assertTrue(result[1] is OmhStorageEntity.OmhFolder)
+ assertEquals(TestListFolderResponse.page1File.name, result[0].name)
+ assertEquals(TestListFolderResponse.page2Folder.name, result[1].name)
+ }
+
+ @Test(expected = OmhStorageException.ApiException::class)
+ fun `given api error when listing then throws ApiException`() = runTest {
+ // Arrange
+ coEvery {
+ apiService.getFolderFileList("BAD_ID", null)
+ } returns Response.error(
+ 404,
+ "not found".toResponseBody("application/json".toMediaType())
+ )
+
+ // Act
+ repository.getFilesList("BAD_ID")
+ }
+
+ @Test
+ fun `given valid fileId when deleteFile succeeds then return true`() = runTest {
+ // Arrange
+ coEvery { apiService.deleteFile("FILE123") } returns Response.success(Unit)
+
+ // Act
+ val result = repository.deleteFile("FILE123")
+
+ // Assert
+ assertTrue(result)
+ }
+
+ @Test(expected = OmhStorageException.ApiException::class)
+ fun `given api error when deleteFile then throws ApiException`() = runTest {
+ // Arrange
+ coEvery { apiService.deleteFile("BAD_ID") } returns Response.error(
+ 400,
+ "bad request".toResponseBody("text/plain".toMediaType())
+ )
+
+ // Act
+ repository.deleteFile("BAD_ID")
+ }
+
+ @Test
+ fun `given small file when uploadFile then uses simple upload endpoint with full body`() = runTest {
+ // Arrange
+ val mockedClient = OkHttpClient() // not used in simple upload path
+ val repo = OneDriveRestfulFileRepository(apiService, mockedClient)
+ val tmp = File.createTempFile("small-upload", ".txt").apply {
+ writeText("hello-world") // 11 bytes
+ deleteOnExit()
+ }
+ val capturedPath = slot()
+ val capturedBody = slot()
+ coEvery {
+ apiService.uploadFile(capture(capturedPath), capture(capturedBody))
+ } returns Response.success(
+ TestListFolderResponse.testFile1
+ )
+
+ // Act
+ val result = repo.uploadFile(tmp, null)
+
+ // Assert
+ assertTrue(result is OmhStorageEntity.OmhFile)
+ assertEquals("/${tmp.name}", capturedPath.captured)
+ assertEquals(tmp.length(), capturedBody.captured.contentLength())
+ }
+
+ // Permissions: getNodePermission
+
+ @Test
+ fun `given permissions for user and group when getNodePermission then maps to OmhPermission list`() = runTest {
+ // Arrange
+ val itemId = "ITEM123"
+ val userPerm = PermissionResponse(
+ id = "perm1",
+ roles = listOf("read"),
+ grantedToV2 = GrantedToV2(
+ user = Identity(id = "U1", displayName = "Alice", email = "alice@example.com"),
+ group = null
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ val groupPerm = PermissionResponse(
+ id = "perm2",
+ roles = listOf("write"),
+ grantedToV2 = GrantedToV2(
+ user = null,
+ group = Identity(id = "G1", displayName = "Team", email = "team@example.com")
+ ),
+ inheritedFrom = ItemReference(id = "PARENT1"),
+ link = null
+ )
+ coEvery { apiService.getItemPermissions(itemId) } returns Response.success(
+ PermissionsListResponse(listOf(userPerm, groupPerm))
+ )
+
+ // Act
+ val result = repository.getNodePermission(itemId)
+
+ // Assert
+ assertEquals(2, result.size)
+ val p1 = result[0] as OmhPermission.IdentityPermission
+ assertEquals("perm1", p1.id)
+ assertEquals(OmhPermissionRole.READER, p1.role)
+ assertTrue(p1.identity is OmhIdentity.User)
+ assertEquals(false, p1.isInherited)
+
+ val p2 = result[1] as OmhPermission.IdentityPermission
+ assertEquals("perm2", p2.id)
+ assertEquals(OmhPermissionRole.WRITER, p2.role)
+ assertTrue(p2.identity is OmhIdentity.Group)
+ assertEquals(true, p2.isInherited)
+ }
+
+ @Test
+ fun `given roles include owner when getNodePermission then most permissive role mapped`() = runTest {
+ // Arrange
+ val itemId = "ITEM456"
+ val ownerPerm = PermissionResponse(
+ id = "perm3",
+ roles = listOf("read", "owner"),
+ grantedToV2 = GrantedToV2(
+ user = Identity(id = "U2", displayName = "Bob", email = "bob@example.com"),
+ group = null
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ coEvery { apiService.getItemPermissions(itemId) } returns Response.success(
+ PermissionsListResponse(listOf(ownerPerm))
+ )
+
+ // Act
+ val result = repository.getNodePermission(itemId)
+
+ // Assert
+ assertEquals(1, result.size)
+ val p = result.first() as OmhPermission.IdentityPermission
+ assertEquals(OmhPermissionRole.OWNER, p.role)
+ }
+
+ @Test
+ fun `given invalid entries when getNodePermission then filters them out`() = runTest {
+ // Arrange
+ val itemId = "ITEM789"
+ val noRoles = PermissionResponse(
+ id = "perm4",
+ roles = null,
+ grantedToV2 = GrantedToV2(
+ user = Identity("U3", "Cara", "cara@example.com"),
+ group = null
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ val noGrantee = PermissionResponse(
+ id = "perm5",
+ roles = listOf("read"),
+ grantedToV2 = GrantedToV2(user = null, group = null),
+ inheritedFrom = null,
+ link = null
+ )
+ val valid = PermissionResponse(
+ id = "perm6",
+ roles = listOf("read"),
+ grantedToV2 = GrantedToV2(
+ user = Identity(
+ "U4",
+ "Dan",
+ "dan@example.com"
+ ),
+ group = null
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ coEvery { apiService.getItemPermissions(itemId) } returns Response.success(
+ PermissionsListResponse(listOf(noRoles, noGrantee, valid))
+ )
+
+ // Act
+ val result = repository.getNodePermission(itemId)
+
+ // Assert
+ assertEquals(1, result.size)
+ val p = result.first() as OmhPermission.IdentityPermission
+ assertEquals("perm6", p.id)
+ assertEquals(OmhPermissionRole.READER, p.role)
+ }
+
+ @Test(expected = OmhStorageException.ApiException::class)
+ fun `given api error when getNodePermission then throws ApiException`() = runTest {
+ // Arrange
+ val itemId = "BAD_PERMS"
+ coEvery { apiService.getItemPermissions(itemId) } returns Response.error(
+ 403,
+ "forbidden".toResponseBody("application/json".toMediaType())
+ )
+
+ // Act
+ repository.getNodePermission(itemId)
+ }
+
+ // Permissions: createNodePermission
+
+ @Test
+ fun `given user recipient when createNodePermission returns list then returns mapped first`() = runTest {
+ // Arrange
+ val itemId = "ITEM_CREATE_1"
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ recipient = OmhPermissionRecipient.User(emailAddress = "alice@example.com"),
+ role = OmhPermissionRole.READER
+ )
+ val bodySlot = slot()
+ val createdPerm = PermissionResponse(
+ id = "permC1",
+ roles = listOf("read"),
+ grantedToV2 = GrantedToV2(
+ user = Identity(id = "U1", displayName = "Alice", email = "alice@example.com"),
+ group = null
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ coEvery {
+ apiService.createPermission(itemId, capture(bodySlot))
+ } returns Response.success(
+ PermissionsListResponse(listOf(createdPerm))
+ )
+
+ // Act
+ val result = repository.createNodePermission(
+ id = itemId,
+ permission = create,
+ sendNotificationEmail = true,
+ emailMessage = null
+ )
+
+ // Assert
+ val p = result as OmhPermission.IdentityPermission
+ assertEquals("permC1", p.id)
+ assertEquals(OmhPermissionRole.READER, p.role)
+ assertTrue(p.identity is OmhIdentity.User)
+ // Verify request body
+ assertEquals(listOf("read"), bodySlot.captured.roles)
+ assertEquals(1, bodySlot.captured.recipients.size)
+ assertEquals("alice@example.com", bodySlot.captured.recipients.first().email)
+ assertTrue(bodySlot.captured.sendInvitation)
+ assertEquals(true, bodySlot.captured.requireSignIn)
+ assertEquals(null, bodySlot.captured.message)
+ }
+
+ @Test
+ fun `given empty response when createNodePermission then falls back to refetch and returns created`() = runTest {
+ // Arrange
+ val itemId = "ITEM_CREATE_2"
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ recipient = OmhPermissionRecipient.User(emailAddress = "bob@example.com"),
+ role = OmhPermissionRole.WRITER
+ )
+ val bodySlot = slot()
+ // First call returns empty list
+ coEvery { apiService.createPermission(itemId, capture(bodySlot)) } returns Response.success(
+ PermissionsListResponse(emptyList())
+ )
+ // Subsequent permission fetch returns the created one
+ val refetched = PermissionResponse(
+ id = "permC2",
+ roles = listOf("write"),
+ grantedToV2 = GrantedToV2(
+ user = Identity(id = "U2", displayName = "Bob", email = "bob@example.com"),
+ group = null
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ coEvery { apiService.getItemPermissions(itemId) } returns Response.success(
+ PermissionsListResponse(listOf(refetched))
+ )
+
+ // Act
+ val result = repository.createNodePermission(
+ id = itemId,
+ permission = create,
+ sendNotificationEmail = false,
+ emailMessage = "Welcome"
+ )
+
+ // Assert
+ val p = result as OmhPermission.IdentityPermission
+ assertEquals("permC2", p.id)
+ assertEquals(OmhPermissionRole.WRITER, p.role)
+ assertTrue(p.identity is OmhIdentity.User)
+ // Verify request body
+ assertEquals(listOf("write"), bodySlot.captured.roles)
+ assertEquals(1, bodySlot.captured.recipients.size)
+ assertEquals("bob@example.com", bodySlot.captured.recipients.first().email)
+ assertEquals(false, bodySlot.captured.sendInvitation)
+ assertEquals("Welcome", bodySlot.captured.message)
+ }
+
+ @Test(expected = OmhStorageException.ApiException::class)
+ fun `given api error when createNodePermission then throws ApiException`() = runTest {
+ // Arrange
+ val itemId = "ITEM_CREATE_ERR"
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ recipient = OmhPermissionRecipient.User(emailAddress = "err@example.com"),
+ role = OmhPermissionRole.READER
+ )
+ coEvery { apiService.createPermission(itemId, any()) } returns Response.error(
+ 400,
+ "bad request".toResponseBody("application/json".toMediaType())
+ )
+
+ // Act
+ repository.createNodePermission(
+ id = itemId,
+ permission = create,
+ sendNotificationEmail = true,
+ emailMessage = null
+ )
+ }
+
+ @Test
+ @Suppress("MaxLineLength", "MaximumLineLength")
+ fun `given group recipient when createNodePermission returns list then returns mapped group permission`() =
+ runTest {
+ // Arrange
+ val itemId = "ITEM_CREATE_G1"
+ val create = OmhCreatePermission.CreateIdentityPermission(
+ recipient = OmhPermissionRecipient.Group(emailAddress = "team@example.com"),
+ role = OmhPermissionRole.READER
+ )
+ val bodySlot = slot()
+ val createdPerm = PermissionResponse(
+ id = "permG1",
+ roles = listOf("read"),
+ grantedToV2 = GrantedToV2(
+ user = null,
+ group = Identity(id = "G1", displayName = "Team", email = "team@example.com")
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ coEvery { apiService.createPermission(itemId, capture(bodySlot)) } returns Response.success(
+ PermissionsListResponse(listOf(createdPerm))
+ )
+
+ // Act
+ val result = repository.createNodePermission(
+ id = itemId,
+ permission = create,
+ sendNotificationEmail = true,
+ emailMessage = null
+ )
+
+ // Assert
+ val p = result as OmhPermission.IdentityPermission
+ assertEquals("permG1", p.id)
+ assertEquals(OmhPermissionRole.READER, p.role)
+ assertTrue(p.identity is OmhIdentity.Group)
+ // Verify request body
+ assertEquals(listOf("read"), bodySlot.captured.roles)
+ assertEquals(1, bodySlot.captured.recipients.size)
+ assertEquals("team@example.com", bodySlot.captured.recipients.first().email)
+ }
+
+ // Permissions: updateNodePermission
+
+ @Test
+ fun `given update returns mappable permission then updateNodePermission returns mapped`() = runTest {
+ // Arrange
+ val itemId = "ITEM_UPD_1"
+ val permissionId = "permU1"
+ val bodySlot = slot()
+ val responsePerm = PermissionResponse(
+ id = permissionId,
+ roles = listOf("write"),
+ grantedToV2 = GrantedToV2(
+ user = Identity(id = "U10", displayName = "User10", email = "u10@example.com"),
+ group = null
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ coEvery { apiService.updatePermission(itemId, permissionId, capture(bodySlot)) } returns Response.success(
+ responsePerm
+ )
+
+ // Act
+ val result = repository.updateNodePermission(itemId, permissionId, OmhPermissionRole.WRITER)
+
+ // Assert
+ val p = result as OmhPermission.IdentityPermission
+ assertEquals(permissionId, p.id)
+ assertEquals(OmhPermissionRole.WRITER, p.role)
+ assertTrue(p.identity is OmhIdentity.User)
+ assertEquals(listOf("write"), bodySlot.captured.roles)
+ }
+
+ @Test
+ fun `given update returns unmappable then updateNodePermission falls back and returns refetched`() = runTest {
+ // Arrange
+ val itemId = "ITEM_UPD_2"
+ val permissionId = "permU2"
+ val unmappable = PermissionResponse(
+ id = null, // triggers mapper to return null
+ roles = listOf("read"),
+ grantedToV2 = GrantedToV2(
+ user = Identity(id = "U20", displayName = "User20", email = "u20@example.com"),
+ group = null
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ coEvery {
+ apiService.updatePermission(itemId, permissionId, any())
+ } returns Response.success(
+ unmappable
+ )
+ val refetched = PermissionResponse(
+ id = permissionId,
+ roles = listOf("read"),
+ grantedToV2 = GrantedToV2(
+ user = Identity(id = "U20", displayName = "User20", email = "u20@example.com"),
+ group = null
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ coEvery { apiService.getItemPermissions(itemId) } returns Response.success(
+ PermissionsListResponse(listOf(refetched))
+ )
+
+ // Act
+ val result = repository.updateNodePermission(itemId, permissionId, OmhPermissionRole.READER)
+
+ // Assert
+ val p = result as OmhPermission.IdentityPermission
+ assertEquals(permissionId, p.id)
+ assertEquals(OmhPermissionRole.READER, p.role)
+ }
+
+ @Test(expected = OmhStorageException.ApiException::class)
+ fun `given api error when updateNodePermission then throws ApiException`() = runTest {
+ // Arrange
+ val itemId = "ITEM_UPD_ERR"
+ val permissionId = "permUErr"
+ coEvery {
+ apiService.updatePermission(itemId, permissionId, any())
+ } returns Response.error(
+ 400,
+ "bad request".toResponseBody("application/json".toMediaType())
+ )
+
+ // Act
+ repository.updateNodePermission(itemId, permissionId, OmhPermissionRole.READER)
+ }
+
+ // Permissions: deleteNodePermission
+
+ @Test
+ fun `given valid ids when deleteNodePermission succeeds then returns true`() = runTest {
+ // Arrange
+ val itemId = "ITEM_DEL_1"
+ val permissionId = "permD1"
+ coEvery {
+ apiService.deletePermission(itemId, permissionId)
+ } returns Response.success(Unit)
+
+ // Act
+ val result = repository.deleteNodePermission(itemId, permissionId)
+
+ // Assert
+ assertTrue(result)
+ }
+
+ @Test(expected = OmhStorageException.ApiException::class)
+ fun `given api error when deleteNodePermission then throws ApiException`() = runTest {
+ // Arrange
+ val itemId = "ITEM_DEL_ERR"
+ val permissionId = "permDErr"
+ coEvery { apiService.deletePermission(itemId, permissionId) } returns Response.error(
+ 403,
+ "forbidden".toResponseBody("application/json".toMediaType())
+ )
+
+ // Act
+ repository.deleteNodePermission(itemId, permissionId)
+ }
+
+ @Test
+ fun `given big file when uploadFile then splits into chunks and PUTs with correct Content-Range`() = runTest {
+ // Arrange: force chunking with 1MB chunk size and create a 3MB file
+ val httpClient = mockk()
+ val repo = OneDriveRestfulFileRepository(apiService, httpClient, smallFileLimitInMB = 1)
+ val file = File.createTempFile("big-upload", ".bin").apply {
+ // Write exactly 3 MiB
+ val content = ByteArray(3 * 1024 * 1024) { 0x41 }
+ writeBytes(content)
+ deleteOnExit()
+ }
+ val uploadUrl = "http://localhost:3000/uploadSession"
+ coEvery { apiService.createUploadSession("/${file.name}", any()) } returns Response.success(
+ com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.UploadSessionResponse(
+ uploadUrl = uploadUrl,
+ expirationDateTime = "2099-12-31T23:59:59.999Z"
+ )
+ )
+
+ val capturedRequests = mutableListOf()
+ var callIndex = 0
+ every { httpClient.newCall(any()) } answers {
+ val req = firstArg()
+ capturedRequests.add(req)
+ val code = if (callIndex < 2) 202 else 200
+ callIndex++
+ val respBuilder = okhttp3.Response.Builder()
+ .code(code)
+ .protocol(Protocol.HTTP_1_1)
+ .message("")
+ .request(req)
+ if (code == 200) {
+ val finalJson = (
+ "{" +
+ "\"id\":\"FIN123\"," +
+ "\"name\":\"${file.name}\"," +
+ "\"createdDateTime\":\"2024-01-01T00:00:00.000Z\"," +
+ "\"lastModifiedDateTime\":\"2024-01-01T00:00:00.000Z\"," +
+ "\"size\":${file.length()}," +
+ "\"file\":{\"mimeType\":\"application/octet-stream\"}," +
+ "\"folder\":null," +
+ "\"@microsoft.graph.downloadUrl\":null," +
+ "\"parentReference\":{\"id\":\"PARENT\",\"driveId\":\"DRIVE\",\"path\":\"/drive/root:\"}," +
+ "\"webUrl\":\"http://localhost/${file.name}\"" +
+ "}"
+ ).toResponseBody("application/json".toMediaTypeOrNull())
+ respBuilder.body(finalJson)
+ }
+ val call = mockk()
+ every { call.execute() } returns respBuilder.build()
+ call
+ }
+
+ // Act
+ val result = repo.uploadFile(file, null)
+
+ // Assert: verify three chunk PUTs with correct headers
+ assertTrue(result is OmhStorageEntity.OmhFile)
+ assertEquals(file.name, result?.name)
+ assertEquals(file.length().toInt(), (result as OmhStorageEntity.OmhFile).size)
+ assertEquals(3, capturedRequests.size)
+
+ // Chunk size 1 MiB
+ val oneMiB = 1024 * 1024
+ val total = 3 * oneMiB
+ fun assertChunk(req: Request, start: Int, endInclusive: Int) {
+ assertEquals(uploadUrl, req.url.toString())
+ assertEquals("PUT", req.method)
+ assertEquals(oneMiB.toString(), req.header("Content-Length"))
+ assertEquals("bytes $start-$endInclusive/$total", req.header("Content-Range"))
+ assertEquals(oneMiB.toLong(), req.body?.contentLength())
+ }
+ assertChunk(capturedRequests[0], 0, oneMiB - 1)
+ assertChunk(capturedRequests[1], oneMiB, (2 * oneMiB) - 1)
+ assertChunk(capturedRequests[2], 2 * oneMiB, (3 * oneMiB) - 1)
+ }
+
+ // Download file
+
+ @Test
+ fun `given valid fileId when downloadFile then returns bytes and caches url`() = runTest {
+ // Arrange
+ val http = mockk()
+ val repo = OneDriveRestfulFileRepository(apiService, http)
+ val fileId = "FILE123"
+ val downloadUrl = "http://localhost:3000/download/$fileId"
+ val item = DriveItem(
+ id = fileId,
+ name = "file.txt",
+ createdAt = "2024-01-01T00:00:00.000Z",
+ updatedAt = "2024-01-01T00:00:00.000Z",
+ size = 11,
+ fileInfo = DriveItem.FileInfo(
+ mimeType = "text/plain"
+ ),
+ folderInfo = null,
+ fileDownloadUrl = downloadUrl,
+ parentReference = DriveItem.ParentReference(
+ id = "PARENT",
+ driveId = "DRIVE",
+ path = "/drive/root:"
+ ),
+ webUrl = "http://localhost/file.txt"
+ )
+ coEvery { apiService.getItemById(fileId) } returns Response.success(item)
+
+ val bytes = "hello world".toByteArray()
+ every { http.newCall(any()) } answers {
+ val req = firstArg()
+ val call = mockk()
+ val resp = okhttp3.Response.Builder()
+ .code(200)
+ .protocol(Protocol.HTTP_1_1)
+ .message("")
+ .request(req)
+ .body(bytes.toResponseBody("application/octet-stream".toMediaTypeOrNull()))
+ .build()
+ every { call.execute() } returns resp
+ call
+ }
+
+ // Act: call twice to exercise cache
+ val out1 = repo.downloadFile(fileId)
+ val out2 = repo.downloadFile(fileId)
+
+ // Assert
+ assertArrayEquals(bytes, out1.toByteArray())
+ assertArrayEquals(bytes, out2.toByteArray())
+ coVerify(exactly = 1) { apiService.getItemById(fileId) }
+ }
+
+ @Test(expected = OmhStorageException.DownloadException::class)
+ fun `given missing fileId when downloadFile then throws DownloadException`() = runTest {
+ // Arrange
+ val http = OkHttpClient()
+ val repo = OneDriveRestfulFileRepository(apiService, http)
+ val fileId = "MISSING"
+ coEvery { apiService.getItemById(fileId) } returns Response.error(
+ 404,
+ "not found".toResponseBody("application/json".toMediaType())
+ )
+
+ // Act
+ repo.downloadFile(fileId)
+ }
+
+ @Test
+ fun `given siteUser inside grantedToIdentitiesV2 when getNodePermission then maps to user identity`() = runTest {
+ val itemId = "ITEM_SITEUSER"
+ val perm = PermissionResponse(
+ id = "permSite",
+ roles = listOf("write"),
+ grantedToV2 = null,
+ grantedToIdentitiesV2 = listOf(
+ GrantedToV2(
+ user = null,
+ group = null,
+ siteUser = Identity(id = "SU1", displayName = "Site User 1", email = "su1@example.com"),
+ siteGroup = null
+ )
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ coEvery { apiService.getItemPermissions(itemId) } returns Response.success(
+ PermissionsListResponse(listOf(perm))
+ )
+
+ val result = repository.getNodePermission(itemId)
+ assertEquals(1, result.size)
+ val p = result.first() as OmhPermission.IdentityPermission
+ assertEquals("permSite", p.id)
+ assertEquals(OmhPermissionRole.WRITER, p.role)
+ assertTrue(p.identity is OmhIdentity.User)
+ assertEquals("Site User 1", (p.identity as OmhIdentity.User).displayName)
+ }
+
+ @Test
+ fun `given multiple grantedToIdentitiesV2 entries when getNodePermission then returns multiple permissions`() =
+ runTest {
+ val itemId = "ITEM_MULTI_IDENTITIES"
+ val perm = PermissionResponse(
+ id = "permMulti",
+ roles = listOf("read"),
+ grantedToV2 = null,
+ grantedToIdentitiesV2 = listOf(
+ GrantedToV2(
+ user = Identity(id = "U100", displayName = "User100", email = "u100@example.com"),
+ group = null,
+ siteUser = null,
+ siteGroup = null
+ ),
+ GrantedToV2(
+ user = null,
+ group = Identity(id = "G200", displayName = "Group200", email = "g200@example.com"),
+ siteUser = null,
+ siteGroup = null
+ ),
+ GrantedToV2(
+ user = null,
+ group = null,
+ siteUser = Identity(id = "SU300", displayName = "SiteUser300", email = "su300@example.com"),
+ siteGroup = null
+ )
+ ),
+ inheritedFrom = null,
+ link = null
+ )
+ coEvery { apiService.getItemPermissions(itemId) } returns Response.success(
+ PermissionsListResponse(listOf(perm))
+ )
+
+ val result = repository.getNodePermission(itemId)
+ // Expect 3 permissions (user, group, siteUser treated as user)
+ assertEquals(3, result.size)
+ val identities = result.map { (it as OmhPermission.IdentityPermission).identity }
+ assertTrue(identities.any { it is OmhIdentity.User && it.id == "U100" })
+ assertTrue(identities.any { it is OmhIdentity.Group && it.id == "G200" })
+ assertTrue(identities.any { it is OmhIdentity.User && it.id == "SU300" })
+ }
+}
diff --git a/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/repository/OneDriveRestfulFileRepositoryVersionsTest.kt b/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/repository/OneDriveRestfulFileRepositoryVersionsTest.kt
new file mode 100644
index 00000000..7bef98de
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/data/repository/OneDriveRestfulFileRepositoryVersionsTest.kt
@@ -0,0 +1,43 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.data.repository
+
+import com.openmobilehub.android.storage.core.model.OmhFileVersion
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.OneDriveApiService
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.FileVersionResponse
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.FileVersionsListResponse
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import okhttp3.OkHttpClient
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import retrofit2.Response
+
+class OneDriveRestfulFileRepositoryVersionsTest {
+
+ private val api: OneDriveApiService = mockk()
+ private val httpClient = OkHttpClient.Builder().build()
+ private val repository = OneDriveRestfulFileRepository(api, httpClient)
+
+ @Test
+ fun `getItemVersions returns all versions even with varying fractional seconds`() = runTest {
+ // Given: versions with 7-digit, 3-digit, 0-digit and 2-digit fractions
+ val versions = listOf(
+ FileVersionResponse(id = "1", lastModifiedDateTime = "2024-05-01T00:00:00.1234567Z"),
+ FileVersionResponse(id = "2", lastModifiedDateTime = "2024-05-01T00:00:01.123Z"),
+ FileVersionResponse(id = "3", lastModifiedDateTime = "2024-05-01T00:00:02Z"),
+ FileVersionResponse(id = "4", lastModifiedDateTime = "2024-05-01T00:00:03.12Z"),
+ )
+ coEvery {
+ api.getItemVersions("file123")
+ } returns Response.success(FileVersionsListResponse(versions))
+
+ // When
+ val result: List = repository.getItemVersions("file123")
+
+ // Then
+ assertEquals(4, result.size)
+ assertTrue(result.all { it.lastModified != null })
+ assertEquals(listOf("1", "2", "3", "4"), result.map { it.versionId })
+ }
+}
diff --git a/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/testdoubles/TestDriveItem.kt b/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/testdoubles/TestDriveItem.kt
new file mode 100644
index 00000000..54dba6bc
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/testdoubles/TestDriveItem.kt
@@ -0,0 +1,59 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.testdoubles
+
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.DriveItem
+
+@Suppress("LongParameterList")
+object TestDriveItem {
+ fun file(
+ id: String = "file-id",
+ name: String = "test-file.txt",
+ size: Long = 123,
+ mimeType: String = "text/plain",
+ parentId: String = "PARENT",
+ driveId: String = "DRIVE",
+ parentPath: String = "/drive/root:/parent",
+ webUrl: String = "https://web/${'$'}id"
+ ): DriveItem =
+ DriveItem(
+ id = id,
+ name = name,
+ createdAt = "2023-01-01T00:00:00Z",
+ updatedAt = "2023-01-02T00:00:00Z",
+ size = size,
+ fileInfo = DriveItem.FileInfo(mimeType = mimeType),
+ folderInfo = null,
+ fileDownloadUrl = "https://download/${'$'}id",
+ parentReference = DriveItem.ParentReference(
+ id = parentId,
+ driveId = driveId,
+ path = parentPath
+ ),
+ webUrl = webUrl
+ )
+
+ fun folder(
+ id: String = "folder-id",
+ name: String = "test-folder",
+ childrenCount: Long = 0,
+ parentId: String = "PARENT",
+ driveId: String = "DRIVE",
+ parentPath: String = "/drive/root:/parent",
+ webUrl: String = "https://web/${'$'}id"
+ ): DriveItem =
+ DriveItem(
+ id = id,
+ name = name,
+ createdAt = "2023-01-01T00:00:00Z",
+ updatedAt = "2023-01-02T00:00:00Z",
+ size = 0,
+ fileInfo = null,
+ folderInfo = DriveItem.FolderInfo(childrenCount = childrenCount),
+ fileDownloadUrl = null,
+ parentReference = DriveItem.ParentReference(
+ id = parentId,
+ driveId = driveId,
+ path = parentPath
+ ),
+ webUrl = webUrl
+ )
+}
diff --git a/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/testdoubles/TestListFolderResponse.kt b/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/testdoubles/TestListFolderResponse.kt
new file mode 100644
index 00000000..87966014
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/test/java/com/openmobilehub/android/storage/plugin/onedrive/restful/testdoubles/TestListFolderResponse.kt
@@ -0,0 +1,91 @@
+package com.openmobilehub.android.storage.plugin.onedrive.restful.testdoubles
+
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.DriveItem
+import com.openmobilehub.android.storage.plugin.onedrive.restful.data.source.response.ListFolderResponse
+
+object TestListFolderResponse {
+ // Root listing single page
+ val testFile1 = DriveItem(
+ id = "file1",
+ name = "File 1",
+ createdAt = "2023-01-01T00:00:00Z",
+ updatedAt = "2023-01-02T00:00:00Z",
+ size = 123,
+ fileInfo = DriveItem.FileInfo(mimeType = "text/plain"),
+ folderInfo = null,
+ fileDownloadUrl = "https://download/file1",
+ parentReference = DriveItem.ParentReference(
+ id = "PARENT",
+ driveId = "DRIVE",
+ path = "/drive/root:/parent"
+ ),
+ webUrl = "https://web/file1"
+ )
+
+ val testFolder1 = DriveItem(
+ id = "folder1",
+ name = "Folder 1",
+ createdAt = "2023-01-01T00:00:00Z",
+ updatedAt = "2023-01-02T00:00:00Z",
+ size = 0,
+ fileInfo = null,
+ folderInfo = DriveItem.FolderInfo(childrenCount = 0),
+ fileDownloadUrl = null,
+ parentReference = DriveItem.ParentReference(
+ id = "PARENT",
+ driveId = "DRIVE",
+ path = "/drive/root:/parent"
+ ),
+ webUrl = "https://web/folder1"
+ )
+
+ val rootSinglePage = ListFolderResponse(
+ entries = listOf(testFile1, testFolder1),
+ nextLink = null
+ )
+
+ // Paginated listing
+ val page1File = DriveItem(
+ id = "f1",
+ name = "P1-File",
+ createdAt = "2023-01-01T00:00:00Z",
+ updatedAt = "2023-01-02T00:00:00Z",
+ size = 10,
+ fileInfo = DriveItem.FileInfo(mimeType = "text/plain"),
+ folderInfo = null,
+ fileDownloadUrl = "https://download/f1",
+ parentReference = DriveItem.ParentReference(
+ id = "PARENT",
+ driveId = "DRIVE",
+ path = "/drive/root:/parent"
+ ),
+ webUrl = "https://web/f1"
+ )
+
+ val page2Folder = DriveItem(
+ id = "d2",
+ name = "P2-Folder",
+ createdAt = "2023-01-01T00:00:00Z",
+ updatedAt = "2023-01-02T00:00:00Z",
+ size = 0,
+ fileInfo = null,
+ folderInfo = DriveItem.FolderInfo(childrenCount = 0),
+ fileDownloadUrl = null,
+ parentReference = DriveItem.ParentReference(
+ id = "PARENT",
+ driveId = "DRIVE",
+ path = "/drive/root:/parent"
+ ),
+ webUrl = "https://web/d2"
+ )
+
+ val paginatedFirstPage = ListFolderResponse(
+ entries = listOf(page1File),
+ nextLink = "https://graph/children?%24skiptoken=abc"
+ )
+
+ val paginatedSecondPage = ListFolderResponse(
+ entries = listOf(page2Folder),
+ nextLink = null
+ )
+}
diff --git a/packages/plugin-onedrive-restful/src/test/resources/test.bin b/packages/plugin-onedrive-restful/src/test/resources/test.bin
new file mode 100644
index 00000000..d86c958e
Binary files /dev/null and b/packages/plugin-onedrive-restful/src/test/resources/test.bin differ
diff --git a/packages/plugin-onedrive-restful/src/test/resources/test.txt b/packages/plugin-onedrive-restful/src/test/resources/test.txt
new file mode 100644
index 00000000..1656f923
--- /dev/null
+++ b/packages/plugin-onedrive-restful/src/test/resources/test.txt
@@ -0,0 +1 @@
+abcdefgh
\ No newline at end of file
diff --git a/packages/plugin-onedrive/README.md b/packages/plugin-onedrive/README.md
index 83ef17e0..1310c43a 100644
--- a/packages/plugin-onedrive/README.md
+++ b/packages/plugin-onedrive/README.md
@@ -50,7 +50,7 @@ Add the dependency for the OneDrive storage provider to your project's **build.g
```gradle
dependencies {
- implementation("com.openmobilehub.android.storage:plugin-onedrive-gms:")
+ implementation("com.openmobilehub.android.storage:plugin-onedrive:")
}
```
diff --git a/settings.gradle.kts b/settings.gradle.kts
index d2b2aa94..f3e94c4b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -19,7 +19,10 @@ rootProject.name = "omh-storage"
include(":apps:storage-sample")
include(":packages:core")
+include(":packages:core-restful-common")
include(":packages:plugin-googledrive-gms")
include(":packages:plugin-googledrive-non-gms")
include(":packages:plugin-onedrive")
+include(":packages:plugin-onedrive-restful")
include(":packages:plugin-dropbox")
+include(":packages:plugin-dropbox-restful")
\ No newline at end of file